4 Commits
Author SHA1 Message Date
Tiago Valente 2bda44ebec CheatSpecter 0.4.3 2026-10-12 01:39:58 -07:00
Tiago Valente eda8b9778a CheatSpecter 0.4.2
- Show a notification on the TV when a cheat is switched on or off, or run
- Cheats switched together share one notification; Restore everything announces itself once
2026-10-10 23:10:43 -07:00
Tiago Valente 08c9abb573 CheatSpecter 0.4.1
A quality pass after 0.4.0 was tried on a real console.

- Fix the Learn page crashing in current browsers when changing lesson steps
- Clear feedback for every search outcome; real progress with time left and Cancel
- Dialogs scroll inside the screen with Save and Cancel always in view
- Fix a console error on every closed connection to the payload
- Copy buttons work when the page is served from the PS5
- The practice game sits beside the lesson's tools and lasts between steps
- Damaged cheat files, vanishing error messages, unreadable memory shown as zeros
- Phone layout: one-line header, larger switches, readable rows
- Browser tests under test/ui and a guard against effects that return values
2026-10-10 15:19:56 -07:00
Tiago Valente 814e5bdfc5 CheatSpecter 0.4.0
- Find: text search, grouped search, undo, went up/down by, where to search, before/after compare
- Debug tab: breakpoints, stepping, registers, stack, threads, what code touches, structure compare, pointer re-check
- Engine tab: Unreal, Unity, Godot, GameMaker and C++ class names; hidden settings and switches
- Code cheats with labels, variables, references and shared code; rule builder; script commands
- Cheats kept on by the PS5 without the browser
- Learn section with twelve lessons and an in-page practice game
- Section (master code) community cheats; SHN and MC4 export
- Groups, dropdowns, hotkeys, first-cheat guide, re-find after update, restore everything
- MCP server: 25 tools
- Quick start, wiki, contributor and security guides, changelog
2026-10-10 12:06:50 -07:00
202 changed files with 69387 additions and 1551 deletions

No files matched your search

+57
View File
@@ -0,0 +1,57 @@
name: Bug report
description: Something does not work the way it should
labels: [bug]
body:
- type: markdown
attributes:
value: |
Thanks for reporting. Please do not attach game files.
Security problems should be reported privately: see SECURITY.md.
- type: input
id: version
attributes:
label: CheatSpecter version
description: Shown at the bottom of the page.
placeholder: "0.4.0"
validations:
required: true
- type: dropdown
id: how
attributes:
label: How are you running it?
options:
- Payload on the PS5
- From a computer (bridge)
- Demo console
- MCP server
validations:
required: true
- type: input
id: console
attributes:
label: PS5 firmware and ps5debug-NG version
placeholder: "13.60, ps5debug-NG 1.3.2"
- type: input
id: game
attributes:
label: Game, title ID and version
placeholder: "Example Game, PPSA01234, v01.002.000"
- type: textarea
id: steps
attributes:
label: What did you do?
description: The steps, in order.
validations:
required: true
- type: textarea
id: expected
attributes:
label: What did you expect, and what happened instead?
description: Include the exact wording of any message shown.
validations:
required: true
- type: input
id: browser
attributes:
label: Browser and device
placeholder: "Safari on iPhone, Chrome on Windows"
+8
View File
@@ -0,0 +1,8 @@
blank_issues_enabled: false
contact_links:
- name: Documentation
url: https://github.com/TiagoVal97/cheatspecter/wiki
about: Installation, guides, the MCP server and troubleshooting.
- name: Report a security problem
url: https://github.com/TiagoVal97/cheatspecter/security/advisories/new
about: Please report security problems privately.
@@ -0,0 +1,20 @@
name: Feature request
description: Suggest something CheatSpecter should do
labels: [enhancement]
body:
- type: textarea
id: problem
attributes:
label: What are you trying to do?
description: The goal, not the solution. An example from a game helps.
validations:
required: true
- type: textarea
id: idea
attributes:
label: What would help?
- type: textarea
id: other
attributes:
label: Anything else
description: How other tools do it, or what you tried.
+25
View File
@@ -0,0 +1,25 @@
## Summary
<!-- 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
- [ ] Interface changes checked in a browser at phone and desktop widths
- [ ] Tried on a real PS5 (firmware and game: ...), or: not tried on hardware
## Screenshots
<!-- For interface changes: before and after. Delete this section otherwise. -->
## Notes for the reviewer
<!-- Anything risky, unfinished or worth a closer look. -->
+1
View File
@@ -6,3 +6,4 @@ payload/build/
payload/*.elf
mcp/build/
mcp/*.mcpb
live-test/
+263
View File
@@ -0,0 +1,263 @@
# 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
simulated console (400 tests) and was checked in a browser at desktop and phone
widths. **None of it has been run on a real PS5 yet.** If you want the build
that has, use 0.3.0.
### Find
- Text search: plain and wide (UTF-16) text, with ignore-case.
- Grouped search: several values that sit close together.
- Undo the last narrowing; "went up by" and "went down by"; a rounded match
for decimals.
- "Where to search": pick which parts of memory are searched, or a custom range.
- Before/after compare of a region.
- Hex display, multi-select with Set all / Freeze all, copy address.
### Debug (new tab)
- Pause and resume, breakpoints, single-step, editable registers (including
XMM as float/double), the call chain, the stack and threads.
- "What does this code touch?": the addresses an instruction reads or writes,
grouped by object.
- Structure viewer, and side-by-side compare of several objects.
- Pointer finder: paths through any module, and "check again after a restart".
### Engine (new tab)
- Class, field and object names for Unreal Engine, Unity (IL2CPP), Godot,
GameMaker and C++ games that keep their class names.
- Unreal: a list of console settings hidden from the menu (frame-rate cap,
resolution scale and so on), which can be added as cheats.
- "Find hidden switches": searches any game for debug- and cheat-related text
and shows what uses it.
### Cheats
- Code cheats: assembly with labels, jumps, named variables that can be edited
while the cheat is on, references between cheats, shared code, and several
places under one switch.
- A rule builder ("if this, then that, otherwise ...") that writes the assembly.
- Scripts can declare named commands with inputs, react to hotkeys, and read
and write code-cheat variables.
- "Keep on": patches and held values applied by the PS5 itself, with no
browser open, and re-applied after the game restarts.
- Groups, dropdown values, hotkeys.
- "Guide me through my first cheat".
- Patterns are recorded when a cheat is saved; "re-find my cheats" after a
game update.
- "Restore everything" and a notice when the game has crashed.
- Community cheats that use memory sections other than the main executable
("master code" cheats) now load.
- Export to SHN and MC4 as well as GoldHEN JSON.
### Learn (new)
- Twelve lessons with a practice game that runs in the page. Lessons are
downloaded from this repository only after you choose to load them; nothing
is added to the ELF but the reader.
### Payload
- `GET /api/engine`, `POST /api/engine/reload`: the keep-on engine.
- `GET|PUT /api/prefs/<name>`: small settings stored on the console.
- Idle connections are closed after 15 seconds.
### MCP server
- 25 tools (was 16): addresses with module names, references, engine class
tools, structure reading, text search, kept-cheat status.
### Known limits
- The Unreal and Unity helpers have only been run against constructed test
data.
- Code cheats and scripts cannot be kept on without the browser.
- The practice game does not run injected code, so the lesson on code with
decisions is reading and questions only.
## 0.3.0
- Game library with name, logo, platform badge and size; start a game from
the browser.
- `.cspector` cheat files, saved on the PS5 (loaded automatically for the
running game) or on your device.
- Community tables from etaHEN PS5 Cheats, the GoldHEN Cheat Repository and
HEN Cheats Collection, in JSON, SHN and MC4.
- One switch can change several places.
- "Auto" search type.
- The payload replaces a running older copy of itself, names its process, and
carries its version in the file name.
- MCP server for assistants, packaged as `.mcpb`.
+65
View File
@@ -0,0 +1,65 @@
# Contributing to CheatSpecter
Thank you for helping. This page says what is welcome, how to set up, and what
a change needs before it can be merged.
## What does not belong here
- Copies of game code or assets.
- Copies of other projects' cheat tables. They are read from their own
repositories at runtime.
- Jailbreaks and exploits. CheatSpecter starts from a console that can
already load payloads.
## Reporting a bug
Open an [issue](https://github.com/TiagoVal97/cheatspecter/issues) using the
bug report form. The most useful reports say which CheatSpecter version, PS5
firmware and ps5debug-NG version, which game (title ID and version), what you
did, what you expected and what happened. Please do not attach game files.
Security problems go through [SECURITY.md](SECURITY.md), not public issues.
## Setting up
You need [Node.js](https://nodejs.org) 22.18 or newer for the tests (20.19 is
enough to run the app).
```sh
npm install
npm run dev # the app with hot reload, the bridge, and a simulated console
npm test # every test suite
npm run typecheck
```
No PS5 is needed: the simulated console in `mock/` has a small game that the
whole app and every test run against.
[Building from source](https://github.com/TiagoVal97/cheatspecter/wiki/Building-from-Source)
covers the console payload and the MCP bundle.
## Before you open a pull request
- `npm run typecheck` and `npm test` pass.
- New behavior has a test in `test/`, against the simulated console where a
console is involved.
- Text shown to people is plain and says what to do next. The audience runs
from complete beginners to experienced modders.
- Screens work on a phone (about 390 pixels wide) as well as on a desktop.
- No new dependencies without discussing it in an issue first.
- If you could test on a real PS5, say so and say which firmware and game. If
you could not, say that too; it is fine, and it is recorded in the changelog.
Keep pull requests to one topic. All changes are reviewed and merged by the
maintainer.
## Writing lessons
The lessons on the Learn page are the JSON files in [`learn/`](learn/).
[`learn/README.md`](learn/README.md) describes the format. Run
`node scripts/check-lessons.mjs` before submitting; the tests also complete
every practice step automatically, so a lesson that cannot be finished fails.
## License
By contributing you agree that your contribution is licensed under
GPL-3.0-only, the license of this project.
+162 -181
View File
@@ -1,213 +1,194 @@
# CheatSpecter
A real-time modding tool for jailbroken PS5 consoles that runs in a web
browser. Open it on a Mac, Windows or Linux machine, or on your phone, and it
talks to the [ps5debug-NG](https://github.com/Pharaoh2k/ps5debug-NG) payload
running on the console.
[![Latest release](https://img.shields.io/github/v/release/TiagoVal97/cheatspecter)](https://github.com/TiagoVal97/cheatspecter/releases)
[![License: GPL-3.0](https://img.shields.io/badge/license-GPL--3.0-blue)](LICENSE)
For single-player games you own. There is nothing here for online play.
A real-time modding tool for jailbroken PS5 consoles that runs in a web
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 a Jailbroken 13.60 PS5.
> What you do with it is your responsibility.
![The Cheats tab, with two cheats switched on](docs/screenshots/cheats.png)
## Quick start
You need a jailbroken PS5 that can load ELF payloads, with
[ps5debug-NG](https://github.com/Pharaoh2k/ps5debug-NG) running, and any
device with a browser on the same network.
[Prerequisites](https://github.com/TiagoVal97/cheatspecter/wiki/Prerequisites)
has the details.
1. Download `cheatspecter-v<version>.elf` from the
[Releases page](https://github.com/TiagoVal97/cheatspecter/releases).
2. On the PS5, start **ps5debug-NG** (etaHEN can start it from its toolbox).
3. Send the ELF to the console the way you load any payload, for example by
uploading it in a payload manager.
4. A notification on the TV shows an address. Open it in a browser on the same
network: `http://<PS5 IP>:8744`
5. Start a game. Cheats made by the community for that game appear as switches
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. 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
[Installation](https://github.com/TiagoVal97/cheatspecter/wiki/Installation)
for that and for running the app from a computer instead of the console.
## What it does
For people who just want cheats:
**Use cheats**
- **Find the console.** Consoles running the debugger are discovered on the
network; no IP typing unless discovery is blocked.
- **See what is running.** Name, icon, title ID and version of the game on
screen.
- **Your game library.** Every installed game with its platform (PS5 or PS4)
and size, and a Start button that boots it from the page.
- **Cheats as switches.** Matching cheat tables are looked up online for the
running game and shown as toggles. Before a cheat is switched on, the bytes
in the game's memory are compared with what the table expects; a cheat for
the wrong build is left off instead of corrupting the game.
- **Import** table files: CheatSpecter's own `.cspector` format and the
community JSON, SHN and MC4 formats.
- **Save your own cheats** to the PS5 or to the device you are on. On the PS5
they are kept as `/data/cheatspecter/cheats/<title id>-<version>.cspector`
and load by themselves whenever that game is running, from any device.
- Community cheat tables for the running game are looked up online and shown
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.
- 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.
The library, game names and icons, starting games and saving to the PS5 need
the CheatSpecter payload on the console (`payload/cheatspecter.elf`), because
the debugger cannot do those things. Everything else works with ps5debug-NG
alone.
**Get around**
For people who make them:
- 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)*
- **Find a value**, known or unknown ("a bar with no number"), narrow it down
as it changes, edit it, hold it. "Auto" searches the common number types at
once when you do not know how the game stores it.
- **Find what writes to it.** A hardware watchpoint lists the instructions
that touch a value, and one click turns "the instruction that lowers my
ammo" into a saved cheat.
- **Memory view**: live hex, the selected bytes read as every number type,
follow pointers, write values or raw bytes.
- **Code view**: disassembly, byte-pattern search, and per-instruction actions
(disable it, hook it, follow a call, copy a signature).
- **Make it survive restarts**: a pointer-path search from the game's
executable to a value on the heap.
- **Your own table**, with five kinds of entry: byte patches (located by
signature so they survive game updates, and able to change several places
under one switch), held values, injected assembly
through a code cave, calls to the game's own functions, and scripts
(JavaScript with a small API for reading, writing, calling and switching
other entries). Export it to share, or as a GoldHEN table where the format
allows.
**Make cheats**
The maker features need ps5debug-NG specifically; with the original ps5debug
the table switches still work.
- **Find** a value: a number you can see, a bar with no number, text, several
values that sit together, or whatever changed between two moments.
- **Find the code** that changes it, and switch that code off: a cheat that
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. 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++
games that keep class names, objects and fields are shown by name.
**What has run on a real PS5** (firmware 13.60, ps5debug-NG 1.3.2): finding
the running game, its name and icon, the game library, loading community
tables, known-value searches, watchpoints, byte patches located by offset and
by pattern, and saving and auto-loading `.cspector` files on the console.
**Learn**
**Only tested against the built-in mock console so far:** injected code,
function calls, scripts, the pointer-path search, unknown-value searches,
starting a game from the library, and PS4 titles.
- Twelve short lessons with a practice game that runs in the page, so every
step can be tried with no console and no risk to a real game.
## Things that can crash a game
These come from how debugging a live process works, not from bugs to be fixed
later. The app says so at the point of use; they are collected here.
- **Tracing** attaches a debugger to the game. If the browser tab is closed
mid-trace, the console detaches without disarming the watchpoint and the
game may die at the next hit. Press Stop first.
- **Injected code** is written while the game keeps running. Switch a hook on
at a quiet moment. A hook replaces at least five bytes; if that spans
several instructions and the game jumps into the middle of them, it crashes.
- **Function calls** borrow a game thread. The function must return within
about two seconds, and one that returns -1 is retried by the console up to
seven times.
- **Scripts and imported entries that run code** are held for review before
first use. Read them. Only use tables from people you trust.
## Run it
Double-click **Start CheatSpecter.command** (macOS) or **Start
CheatSpecter.bat** (Windows). It installs on first run, starts the bridge and
opens the browser. Needs [Node.js](https://nodejs.org) 20.19 or newer.
From a terminal:
```sh
npm install
npm start # real console
npm run demo # adds a pretend PS5 so you can click around without one
```
Tracing ("what writes to this") makes the console connect back to this
computer on TCP port 755, so the firewall has to let that in; macOS will ask
the first time. `npm run demo` uses a different port and is for the pretend
console only.
The start-up message lists the address to open from a phone on the same Wi-Fi.
On the PS5, load ps5debug-NG first (etaHEN can start it from its toolbox). It
shows a "loaded" notification when ready.
## How it fits together
```
Browser (any device) Computer PS5
┌──────────────────────┐ ┌──────────────────┐ ┌──────────────────────┐
│ web app │ │ bridge/server.mjs│ │ ps5debug-NG │
│ protocol client │◄─►│ WebSocket ⇄ TCP │◄─►│ TCP 744 │
│ scanner, cheats │ WS│ serves the app │TCP│ │
└──────────┬───────────┘ └──────────────────┘ └──────────────────────┘
│ HTTPS
▼
cheat table collections (GitHub)
```
Browsers cannot open raw TCP sockets, so something has to relay. There are two
ways to run that relay, and the web app is identical in both:
- **On a computer** (`bridge/server.mjs`), as drawn above. This is what the
launcher starts, and what has been exercised most.
- **On the console itself** (`payload/`). A small ELF serves the app and
relays to the debugger locally, so nothing is installed anywhere and any
device just opens `http://<PS5 IP>:8744`. See `payload/README.md`.
Cheat tables are fetched by the browser, so the console itself never needs
internet access.
| Path | What it is |
| | |
| --- | --- |
| `src/lib/ps5debug.ts` | The debugger's wire protocol |
| `src/lib/scanner.ts` | Known-value, unknown-value and Auto scans |
| `mcp/server.ts` | The same library as MCP tools for Claude |
| `src/lib/cheats.ts` | Community cheat tables: parsing, state detection, applying |
| `src/lib/table.ts` | CheatSpecter's own table format and the runner that applies it |
| `src/lib/locations.ts` | Signatures, offsets and pointer paths, resolved to addresses |
| `src/lib/tracer.ts` | "What writes to this address" |
| `src/lib/pointerscan.ts` | Pointer-path search |
| `src/lib/script.ts` | The script API and its sandbox |
| `src/lib/x86.ts` | Disassembly, relocation and signatures (iced-x86, in a worker) |
| `src/lib/catalog.ts` | Online table lookup and offline cache |
| `src/ui/` | The interface (React, Tailwind) |
| `src/components/` | shadcn/ui and [Kibo UI](https://www.kibo-ui.com) components, added with the shadcn CLI |
| `bridge/server.mjs` | WebSocket-to-TCP relay, discovery, static files |
| `payload/` | The on-console ELF: the same relay in C, with the app embedded |
| `mock/mock-ps5.mjs` | Pretend console with a tiny simulated game |
| `cheats/` | Drop table files here to have them offered alongside the online ones |
| `test/` | End-to-end tests: client → bridge → mock |
| ![The Find tab](docs/screenshots/find.png) | ![The Learn page](docs/screenshots/learn.png) |
## Development
## Documentation
```sh
npm run dev # Vite with hot reload + bridge + demo console
npm test # needs Node 22.18+ (the tests import the TypeScript directly)
npm run typecheck
```
The [wiki](https://github.com/TiagoVal97/cheatspecter/wiki) has the full guide:
| To do this | Read |
| --- | --- |
| Check what you need | [Prerequisites](https://github.com/TiagoVal97/cheatspecter/wiki/Prerequisites) |
| Install on the PS5 or run from a computer | [Installation](https://github.com/TiagoVal97/cheatspecter/wiki/Installation) |
| 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) |
| Use it from Claude | [MCP server](https://github.com/TiagoVal97/cheatspecter/wiki/MCP-Server) and its [tool reference](https://github.com/TiagoVal97/cheatspecter/wiki/MCP-Tool-Reference) |
| Read or write `.cspector` files | [The .cspector format](https://github.com/TiagoVal97/cheatspecter/wiki/The-cspector-Format) |
| Fix a problem, or avoid a crash | [Troubleshooting and safety](https://github.com/TiagoVal97/cheatspecter/wiki/Troubleshooting-and-Safety) |
| Understand how it works, or build it | [Architecture](https://github.com/TiagoVal97/cheatspecter/wiki/Architecture) and [Building from source](https://github.com/TiagoVal97/cheatspecter/wiki/Building-from-Source) |
## What has been tested on a real PS5
CheatSpecter is young, and this list is kept honest.
**Used on a real console** (firmware 13.60, ps5debug-NG 1.3.2), as of 0.3.0:
finding the running game with its name and icon, the game library, loading
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
page), and from earlier versions: function calls, scripts, the pointer-path
search, unknown-value searches, starting a game from the library and PS4
titles. That is why 0.4.0 is published as a pre-release.
[CHANGELOG.md](CHANGELOG.md) has the detail for each version.
## Where cheat tables come from
Tables are read at runtime from these community collections, in their original
location. Nothing from them is copied into this repository.
Tables are read at runtime, by your browser, from these community collections
in their original location. Nothing from them is copied into this repository.
- [etaHEN PS5 Cheats](https://github.com/etaHEN/PS5_Cheats)
- [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. When a repository
has the same table in several formats, the JSON one is used.
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.
## Using it from Claude (MCP)
Game patches come from
[PS4-PS5-Game-Patch](https://github.com/illusion0001/PS4-PS5-Game-Patch),
read the same way, by your browser.
`npm run mcp` builds `mcp/cheatspecter-<version>.mcpb`, a desktop extension
for the Claude app. Open the file to install it and enter the PS5's address
when asked. Claude then has tools to see what is running, search and read
memory, find the code that changes a value, disassemble, and save cheats to
the same `.cspector` file the web app loads. It runs on your computer and
talks to the payload on the console; nothing is installed on the PS5.
## Contributing
To run it by hand instead: `CHEATSPECTER_HOST=<ps5 address> node mcp/build/server/index.mjs`
speaks MCP over stdin/stdout.
## The .cspector format
A `.cspector` file is minified JSON:
```json
{"format":"cheatspecter","formatVersion":1,
"game":{"name":"…","titleId":"PPSA07391","version":"01.009.000"},
"cheats":[{"name":"Infinite ammo","type":"patch","at":{"kind":"module","offset":"0x2040"},"on":"909090","off":"FF4B28"}]}
```
`type` is one of `patch`, `value`, `inject`, `call` or `script`. A patch can
carry `"more": [{"at": …, "on": "…", "off": "…"}]` for further places changed by
the same switch. A formatted copy loads the same as a minified one.
## Safety notes
- The bridge only relays to private network addresses on the debugger's port,
and only accepts WebSocket connections from the page it served.
- Anyone on your network can reach the debugger directly on port 744 anyway;
the bridge does not make that better or worse. Use a network you trust.
Bug reports, lessons and code are welcome. Start with
[CONTRIBUTING.md](CONTRIBUTING.md). To report a security problem privately,
see [SECURITY.md](SECURITY.md).
## License
GPL-3.0-only, matching ps5debug-NG, whose protocol this implements and whose
handler logic the mock console follows.
handler logic the simulated console follows. See [LICENSE](LICENSE).
+29
View File
@@ -0,0 +1,29 @@
# Security
## Reporting a problem
Please report security problems privately, not in a public issue: open the
repository's **Security** tab and choose
[Report a vulnerability](https://github.com/TiagoVal97/cheatspecter/security/advisories/new).
Say what is affected, how to reproduce it, and what an attacker could do.
You will get a reply as soon as the maintainer can look at it.
Only the latest release is supported with fixes.
## What counts
Things worth reporting include:
- a web page on another site being able to drive the payload or the bridge
through a visitor's browser (save files, start games, reach the debugger);
- a cheat table, `.cspector` file or lesson that runs code or changes the game
without the review step or the checks it should pass;
- the payload or bridge reading or writing files outside its own folders;
- the bridge relaying to addresses outside the local network.
## What to keep in mind
CheatSpecter talks to ps5debug-NG, which listens on the console's network port
744 with no password. Anyone on the same network can reach that debugger
directly, with or without CheatSpecter. Use a network you trust. That
behavior belongs to the debugger and is not something this project can fix.
+171 -18
View File
@@ -31,7 +31,9 @@ import { DEMO_TITLES, demoLibraryResponse } from './demo-library.mjs';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const DIST = path.join(ROOT, 'dist');
const CHEATS_DIR = path.join(ROOT, 'cheats');
const LEARN_DIR = path.join(ROOT, 'learn');
const TABLE_MAX = 4 << 20;
const PREF_MAX = 64 << 10;
const VERSION = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8')).version;
const DEBUGGER_PORT = 744;
@@ -164,6 +166,77 @@ function safeJoin(base, urlPath) {
return full === base || full.startsWith(base + path.sep) ? full : null;
}
// ---- the demo console's stand-in for the cheat engine ---------------------------
//
// The real engine is C code inside the payload (payload/src/engine.c). The demo
// console has no payload, so GET /api/engine is answered here in the same
// shape, from the cheat file the app saved: which cheats are marked to keep,
// and which of those the engine would turn down, by the engine's own rules.
// It does not touch the demo game, and says so in each cheat's detail.
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.',
valueAddress: 'This value uses a raw memory address, which changes every time the game starts. Give it a pointer path in CheatSpecter first.',
};
/** The version as it appears in a file name (fileVersion() in src/lib/tablestore.ts). */
const demoFileVersion = (version) => String(version ?? '').replace(/[^0-9A-Za-z.]/g, '').replace(/^\.+/, '').slice(0, 16) || 'unknown';
/** 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];
if (places.some((p) => p?.at?.kind === 'address')) return e.type === 'patch' ? DEMO_REFUSALS.patchAddress : DEMO_REFUSALS.valueAddress;
return null;
}
/**
* @param {{titleId:string, version:string, pid:number}|null} game the game the demo console is running
* @param {Map<string, Buffer>} tables saved cheat files, keyed "<title id>-<version>"
*/
export function demoEngineStatus(game, tables) {
if (!game) return { running: true, game: null, file: '', cheats: [] };
const status = { running: true, game: { titleId: game.titleId, version: game.version, pid: game.pid }, file: '', cheats: [] };
// This version's own file, else the highest-versioned one for the game.
const own = `${game.titleId}-${demoFileVersion(game.version)}`;
const others = [...tables.keys()].filter((k) => k.startsWith(`${game.titleId}-`)).sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
const key = tables.has(own) ? own : others[others.length - 1];
if (!key) return status;
status.file = `${key}.cspector`;
let doc = null;
try {
doc = JSON.parse(tables.get(key).toString('utf8').replace(/^\uFEFF/, ''));
} catch {
// reported below
}
const legacy = doc?.format === 'cheatspecter/1';
const list = legacy ? doc.entries : doc?.format === 'cheatspecter' ? doc.cheats : null;
const says = (legacy ? doc : doc?.game) ?? {};
status.exact =
key === own &&
(!says.titleId || String(says.titleId).toUpperCase() === game.titleId) &&
(!says.version || demoFileVersion(says.version) === demoFileVersion(game.version));
if (!Array.isArray(list)) {
status.error = 'The cheat file for this game is damaged (it is not valid JSON), so no cheats were switched on. Save your cheats again from CheatSpecter.';
return status;
}
for (const e of list) {
if (!e || typeof e !== 'object' || typeof e.name !== 'string' || e.keep !== true) continue;
if (!['patch', 'value', 'inject', 'call', 'script'].includes(e.type)) continue;
const refusal = demoRefusal(e);
status.cheats.push(refusal ? { name: e.name, state: 'refused', detail: refusal } : { name: e.name, state: 'waiting', detail: DEMO_KEPT_DETAIL });
}
return status;
}
/** Community tables dropped into the cheats/ folder: JSON, SHN or MC4. */
const LOCAL_TABLE_RE = /\.(json|shn|mc4)$/i;
@@ -191,14 +264,16 @@ function localCheatIndex() {
* @param {object} [opts]
* @param {number} [opts.port]
* @param {string} [opts.host]
* @param {{host:string, port:number, udpPort:number, label:string}|null} [opts.demo]
* @param {{host:string, port:number, udpPort:number, label:string, game?:() => ({titleId:string, version:string, pid:number}|null)}|null} [opts.demo]
* the built-in demo console; `game` says which game it is running, for the stand-in engine status
* @param {number|null} [opts.eventPort] where consoles connect back with debugger events (default 755, null = off)
*/
export function startBridge(opts = {}) {
const demo = opts.demo ?? null;
const PAYLOAD_PORT = opts.payloadPort ?? DEFAULT_PAYLOAD_PORT;
/** The demo console's saved cheat files, kept only while the bridge runs. */
/** The demo console's saved cheat files and settings, kept only while the bridge runs. */
const demoTables = new Map();
const demoPrefs = new Map();
const allowed = (host, port) =>
(isPrivateAddress(host) && port === DEBUGGER_PORT) || (demo && host === demo.host && port === demo.port);
@@ -239,40 +314,86 @@ export function startBridge(opts = {}) {
});
/**
* The game library, game names, "start this game" and the cheat files saved
* on the PS5 are all handled by the CheatSpecter payload on the console (the
* debugger can do none of them), so these requests are passed on to it.
* The game library, game names, "start this game", the cheat files and
* settings saved on the PS5 and the engine that keeps cheats on by itself
* are all handled by the CheatSpecter payload on the console (the debugger
* can do none of them), so these requests are passed on to it.
*/
async function consoleApi(req, res, url) {
const p = url.pathname;
const host = url.searchParams.get('host') ?? '';
const launching = p.startsWith('/api/launch/');
// The engine: GET /api/engine says what it is keeping on, POST /api/engine/reload makes it read the file again.
const engineReload = p === '/api/engine/reload';
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 methodOk = launching ? req.method === 'POST' : saving || req.method === 'GET';
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 || deleting || req.method === 'GET';
if (!methodOk || !sameOrigin(req)) {
res.writeHead(sameOrigin(req) ? 405 : 403);
res.end();
return;
}
if (p.startsWith('/api/prefs/') && !pref) {
res.writeHead(400);
res.end();
return;
}
let body;
if (saving) {
if (saving || storing) {
const chunks = [];
const limit = storing ? PREF_MAX : TABLE_MAX;
let size = 0;
for await (const chunk of req) {
size += chunk.length;
if (size > TABLE_MAX) {
res.writeHead(413);
res.end();
return;
}
chunks.push(chunk);
// Too much is read to its end and dropped, so the sender gets the answer instead of a cut connection; within reason.
if (size > limit + (16 << 20)) break;
if (size <= limit) chunks.push(chunk);
}
if (size > limit) {
res.writeHead(413);
res.end();
return;
}
body = Buffer.concat(chunks);
}
if (demo && host === demo.host) {
if (engine) {
// The demo console has no engine. The stand-in answers in the engine's shape and takes a reload without doing anything.
if (engineReload) {
res.writeHead(204, { 'cache-control': 'no-store' });
res.end();
} else sendJson(res, 200, demoEngineStatus(demo.game ? demo.game() : null, demoTables));
return;
}
if (pref) {
if (storing) {
let ok = true;
try {
JSON.parse(body.toString('utf8'));
} catch {
ok = false;
}
if (ok) demoPrefs.set(pref[1], body);
res.writeHead(ok ? 204 : 400, { 'cache-control': 'no-store' });
res.end();
} else if (demoPrefs.has(pref[1])) {
res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
res.end(demoPrefs.get(pref[1]));
} else {
res.writeHead(404);
res.end();
}
return;
}
if (launching) {
const known = DEMO_TITLES.some((t) => p === `/api/launch/${t.id}`);
const running = p === '/api/launch/PPSA99999';
@@ -281,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) {
@@ -290,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}`));
@@ -309,7 +442,7 @@ export function startBridge(opts = {}) {
res.end(answer.body);
return;
}
if (!isPrivateAddress(host) || !(table || /^\/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;
@@ -337,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/')) {
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;
}
@@ -375,6 +508,20 @@ export function startBridge(opts = {}) {
return;
}
// The Learn section's lessons normally come from GitHub. With the demo
// console (which is also how `npm run dev` runs) the checkout's own copy is
// served, so lessons can be written and tried before they are published.
if (demo && p.startsWith('/learn/')) {
const lesson = safeJoin(LEARN_DIR, p.slice('/learn/'.length));
if (!lesson || !lesson.endsWith('.json') || !fs.existsSync(lesson)) {
res.writeHead(404);
res.end();
return;
}
serveFile(res, lesson, 'no-store');
return;
}
const file = safeJoin(DIST, p === '/' ? 'index.html' : p.slice(1));
if (!file) {
res.writeHead(404);
@@ -527,13 +674,19 @@ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.me
let demo = null;
// In demo mode nothing real will connect back, so use an unprivileged port
// that works on every system.
const DEMO_EVENT_PORT = 17755;
const DEMO_EVENT_PORT = 17755 + Number(process.env.CS_PORT_OFFSET ?? 0);
if (flag('--demo')) {
const { startMock, MOCK_TCP_PORT, MOCK_UDP_PORT } = await import('../mock/mock-ps5.mjs');
const { startMock, MOCK_TCP_PORT, MOCK_UDP_PORT, GAME } = await import('../mock/mock-ps5.mjs');
// A real console takes a while to scan; the pause makes the demo show progress too.
const mock = startMock({ scanDelayMs: 250, eventPort: DEMO_EVENT_PORT });
await mock.ready;
demo = { host: '127.0.0.1', port: MOCK_TCP_PORT, udpPort: MOCK_UDP_PORT, label: 'Demo console' };
demo = {
host: '127.0.0.1',
port: MOCK_TCP_PORT,
udpPort: MOCK_UDP_PORT,
label: 'Demo console',
game: () => (mock.state.foreground ? { titleId: GAME.titleId, version: GAME.version, pid: GAME.pid } : null),
};
}
const bridge = startBridge({ port, demo, eventPort: demo ? DEMO_EVENT_PORT : EVENT_PORT });
Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

+1 -1
View File
@@ -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>
+240
View File
@@ -0,0 +1,240 @@
# Lessons for CheatSpecter's Learn section
This folder is what the **Learn** page in CheatSpecter shows. The app does not ship with
lessons: when someone opens Learn and presses "Load lessons", their browser downloads
`index.json` and then each lesson from this folder on GitHub. Change a file here and everyone
gets the new version the next time they open the page.
Lessons are **data, never code**. A lesson cannot contain HTML, scripts, links or images. The
app checks every file it downloads against the format below and refuses the whole file if
anything is off, so a typo here means a lesson that will not open. Run the checker before you
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, 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.
To try your lessons in the app before they are on GitHub, run `npm run demo` (or `npm run dev`):
with the demo console running, the app loads this folder from your computer instead.
## Files
learn/index.json the courses, and which lessons are in them, in order
learn/lessons/<id>.json one lesson; <id> is the lesson's id
### index.json
```json
{
"format": "cheatspecter-lessons",
"version": 1,
"edition": "2026-10",
"courses": [
{
"id": "values",
"title": "Finding and changing values",
"summary": "One or two sentences shown under the course title.",
"lessons": [
{ "id": "find-a-number", "title": "Find a number you can see", "summary": "…", "minutes": 8, "practice": true }
]
}
]
}
```
`title`, `summary` and `minutes` must be the same as in the lesson's own file (the list of
lessons is drawn from the index alone). `practice` is `true` when the lesson has at least one
practice step. `edition` is shown at the bottom of the list; change it when you publish. Ids are
lower-case letters, digits and dashes.
### A lesson
```json
{
"format": "cheatspecter-lesson",
"version": 1,
"id": "find-a-number",
"title": "Find a number you can see",
"summary": "Search for your ammo, change it in the game, search again, and edit what is left.",
"minutes": 8,
"games": { "range": { … }, "shop": { … } },
"steps": [ … ]
}
```
`games` holds the practice games the lesson's steps use, by a name of your choosing. `steps`
are shown one at a time, in order.
## Text
Wherever a step has `text` (and in `done` and `explain`), this small markup works. Anything
else is shown as the characters you typed.
| You write | You get |
| --- | --- |
| a blank line | a new paragraph |
| `# Heading` on its own | a heading |
| lines starting with `- ` | a bulleted list |
| lines starting with `1. `, `2. ` | a numbered list |
| `> note: …`, `> tip: …`, `> warning: …` | a callout |
| `**bold**` | **bold**, for the names of buttons and for new words |
| `` `code` `` | `code`, for instructions, addresses and file names |
Write for someone who has never done this. Name buttons exactly as the app shows them, and say
what the learner will see after each action.
## Steps
Every step has a `kind` and a `title`.
**`read`**: something to read. `text`.
**`choice`**: a multiple-choice question. `question`, `options` (2 to 6 strings), `answer`
(the position of the right one, counting from 0), `explain` (shown once they get it right), and
optionally `text` above the question.
**`answer`**: a question answered by typing. `question`, `accept` (the answers that count;
capitals, spacing and a final period are ignored), `explain`, optionally `text`.
**`practice`**: the learner does something in a practice game with the real tools.
```json
{
"kind": "practice",
"title": "Fire, then narrow down",
"text": "What to do, step by step.",
"game": "range",
"tool": "find",
"goal": { "check": "narrowed", "label": "Narrow the list down to one match", "field": "player.ammo", "atMost": 1 },
"hints": ["A nudge.", "A stronger nudge.", "Nearly the answer."],
"done": "Shown when the goal is reached: what just happened, and why it matters."
}
```
- `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
practical lesson should end with one, in a game with different numbers from the guided part.
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
A goal is what the app checks for. Each has a `label`: the sentence the learner sees with a
circle or a check mark next to it. `field` means a value in the game, written `object.field`;
`action` means one of the game's actions.
| `check` | Reached when | Also needs |
| --- | --- | --- |
| `narrowed` | the learner's search results contain the value and are at most `atMost` long | `field`, `atMost` |
| `value` | the value is `atLeast`, `atMost` or `exactly` a number | `field`, `is`, `value` |
| `held` | the value is unchanged after each of `times` uses of an action in a row (frozen, or its code is switched off) | `field`, `action`, `times` |
| `steady` | a value the game changes by itself has not moved for `seconds` | `field`, `seconds` |
| `pressed` | an action has been used `times` times | `action`, `times` |
| `restarted` | the game has been restarted | |
| `updated` | the game's update has been installed (the game needs `"update": true`) | |
| `caught` | the debugger has reported the code that writes the value ("Find what writes to this") | `field` |
| `off` | the code behind the action's first effect is replaced with no-ops | `action` |
| `saved` | "Your cheats" has a cheat of `type` `value` (for a `field`) or `patch` (for an `action`). Add `"named": true` to require a name other than the automatic one, `"grouped": true` to require a group, `"location": "pointer"` to require a pointer path | `type`, and `field` or `action` |
To ask for several things, use `{ "check": "all", "of": [ … ] }` with two to five goals. They
count **in order**: a goal is only looked at once the ones before it are reached, and what it
counts (restarts, uses of an action) is counted from that moment. So "restart, then switch
the cheat on again" is `restarted` followed by `off`.
## Practice games
A practice game is a description; the app builds a small pretend game from it, with real
memory to search and real machine code behind every action.
```json
{
"name": "Specter Range",
"objects": [
{ "id": "player", "fields": [
{ "id": "ammo", "type": "u32", "value": 30, "offset": 40 },
{ "id": "shots", "type": "u32", "value": 0, "offset": 64 }
] }
],
"actions": [
{ "id": "fire", "label": "Fire", "effects": [
{ "field": "player.ammo", "op": "sub", "by": 1, "min": 0, "refill": 30 },
{ "field": "player.shots", "op": "add", "by": 1 }
] }
],
"hud": [ { "label": "Ammo", "field": "player.ammo", "show": "number" } ],
"decoys": [ { "type": "u32", "value": 30, "count": 12 } ],
"copies": [ { "of": "player.ammo", "count": 3, "follow": "fade" } ],
"drifting": 0,
"update": false
}
```
- **`objects`** (1 to 8) are the things the game keeps in memory. Each `field` has a `type`
(`u8`, `u16`, `u32`, `float`, `double`, or `pointer`), a starting `value`, and optionally an
`offset` from the start of the object (a multiple of the type's size; left out, fields follow
each other from 16). A `pointer` has `to` (another object's id) instead of a value.
Objects land somewhere new every time the game starts. One with `"static": true` lives in the
game's own data, at the same place every time: give it a `pointer` to another object and
there is a pointer path to find.
- **`actions`** (up to 8) become buttons. Each `effect` adds to or subtracts from a `u32`,
`float` or `double` field. A subtraction that would go below `min` sets the value to `refill`
(or stops at `min`); an addition stops at `max`. An action with `"every": 1000` runs by itself
every that many milliseconds and has no button (`"hidden": true` hides one without a timer).
Each effect is its own few bytes of code, which "Find what writes to this" finds and no-ops
switch off. Effects that name the same `"site"` share one piece of code, as "take damage" for
the player and for an enemy would; they must do the same thing to the same field of their
objects.
- **`hud`** (up to 6) is what the game shows: a `number` (with `digits` decimals), a `bar`
(filled at `max`; the number is not shown, which is the point), or a `time` (seconds shown as
m:ss).
- **`decoys`** put the same number in unrelated places, so a first search finds more than one.
- **`copies`** follow a value for a few changes and then stop, as a game's own copies of a
number do; `count` 3 means narrowing down takes about four rounds.
- **`drifting`** is how many values change on their own, which gives "stayed the same" a job in
searches that start from "Unknown".
- **`update`** `true` gives the game an "Install game update" button that moves its code.
If someone overwrites a game's code with anything other than whole instructions of no-ops, the
game "crashes" the next time that code runs, and "Restart game" brings it back.
## Changing the format
`version` is 1. The app refuses files with a higher version and tells the user to update, so
new kinds of steps or goals mean a new version number and a new app release first. The types
and the checker are in `src/lib/learn.ts`; the practice game is in `src/lib/practice/`.
+112
View File
@@ -0,0 +1,112 @@
{
"format": "cheatspecter-lessons",
"version": 1,
"edition": "2026-10",
"courses": [
{
"id": "values",
"title": "Finding and changing values",
"summary": "What a game keeps in memory, how to find one number among millions, and how to change it or hold it still.",
"lessons": [
{
"id": "how-games-remember",
"title": "How games remember things",
"summary": "Memory, values and addresses, and why a value is somewhere else every time you start the game.",
"minutes": 6,
"practice": true
},
{
"id": "find-a-number",
"title": "Find a number you can see",
"summary": "Search for your ammo, change it in the game, search again, and edit what is left.",
"minutes": 8,
"practice": true
},
{
"id": "hold-it",
"title": "Change it and hold it",
"summary": "Freeze a value so the game cannot lower it, and see why that stops working after a restart.",
"minutes": 8,
"practice": true
},
{
"id": "no-number",
"title": "Values you cannot see as a number",
"summary": "A health bar has no number to type. Find it by what it did: went down, went up, stayed the same.",
"minutes": 8,
"practice": true
},
{
"id": "decimals",
"title": "Decimals and other types",
"summary": "Why a search for the right number can find nothing, what a float is, and when to let Auto decide.",
"minutes": 8,
"practice": true
}
]
},
{
"id": "lasting",
"title": "Cheats that last",
"summary": "From a number you hold by hand to a cheat you can switch on tomorrow, share, and keep through a game update.",
"lessons": [
{
"id": "find-the-code",
"title": "Find the code that changes it, and switch it off",
"summary": "Ask the debugger which instruction lowers your ammo, then replace it with instructions that do nothing.",
"minutes": 10,
"practice": true
},
{
"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": 9,
"practice": true
},
{
"id": "game-updates",
"title": "Surviving game updates: patterns",
"summary": "An update moves the game's code. A pattern finds your instruction again wherever it went.",
"minutes": 8,
"practice": true
},
{
"id": "pointers",
"title": "Pointers: the way back to a value that moves",
"summary": "The value moves on every start, but the game always knows where it is. Follow the same trail.",
"minutes": 10,
"practice": true
}
]
},
{
"id": "deeper",
"title": "Going deeper",
"summary": "What sits next to your value, how to read a little of the game's code, and the habits that keep your saves and your console safe.",
"lessons": [
{
"id": "structures",
"title": "Structures: the things next to your value",
"summary": "Health, ammo and position live side by side, and enemies are laid out the same way as you.",
"minutes": 10,
"practice": true
},
{
"id": "decisions",
"title": "Code with decisions: only for the player",
"summary": "Reading a little assembly: compare, jump, and how a cheat can tell you from an enemy.",
"minutes": 10,
"practice": false
},
{
"id": "good-habits",
"title": "Good habits",
"summary": "Using it at your own risk, backing up saves, undoing everything at once, and what to do when the game crashes.",
"minutes": 6,
"practice": true
}
]
}
]
}
+244
View File
@@ -0,0 +1,244 @@
{
"format": "cheatspecter-lesson",
"version": 1,
"id": "decimals",
"title": "Decimals and other types",
"summary": "Why a search for the right number can find nothing, what a float is, and when to let Auto decide.",
"minutes": 8,
"games": {
"race": {
"name": "Specter Rally",
"objects": [
{
"id": "car",
"fields": [
{
"id": "fuel",
"type": "float",
"value": 50,
"offset": 32
},
{
"id": "time",
"type": "float",
"value": 90,
"offset": 36
},
{
"id": "boosts",
"type": "u32",
"value": 0,
"offset": 40
}
]
}
],
"actions": [
{
"id": "boost",
"label": "Use boost",
"effects": [
{
"field": "car.fuel",
"op": "sub",
"by": 7.5,
"min": 0,
"refill": 50
},
{
"field": "car.boosts",
"op": "add",
"by": 1
}
]
},
{
"id": "tick",
"label": "Clock",
"hidden": true,
"every": 1000,
"effects": [
{
"field": "car.time",
"op": "sub",
"by": 1,
"min": 0,
"refill": 90
}
]
}
],
"hud": [
{
"label": "Fuel",
"field": "car.fuel",
"show": "number",
"digits": 1
},
{
"label": "Time left",
"field": "car.time",
"show": "time"
}
],
"decoys": [
{
"type": "u32",
"value": 50,
"count": 6
}
],
"drifting": 10
},
"toll": {
"name": "Specter Highway",
"objects": [
{
"id": "driver",
"fields": [
{
"id": "money",
"type": "double",
"value": 1250.75,
"offset": 32
},
{
"id": "trips",
"type": "u32",
"value": 0,
"offset": 40
}
]
}
],
"actions": [
{
"id": "pay",
"label": "Pay the toll (49.25)",
"effects": [
{
"field": "driver.money",
"op": "sub",
"by": 49.25,
"min": 0,
"refill": 1250.75
},
{
"field": "driver.trips",
"op": "add",
"by": 1
}
]
}
],
"hud": [
{
"label": "Money",
"field": "driver.money",
"show": "number",
"digits": 2
}
],
"decoys": [
{
"type": "u32",
"value": 1250,
"count": 5
},
{
"type": "float",
"value": 1250.75,
"count": 3
}
]
}
},
"steps": [
{
"kind": "read",
"title": "The same number, written differently",
"text": "So far every value was a whole number kept in four bytes. That is the most common way, and it is why the Find tab starts on **4 bytes**. It is not the only way.\n\nA game's programmers pick a **type** for each value, and the type decides how the number is turned into bytes:\n\n- **4 bytes**: whole numbers up to about four billion. Ammo, money, scores, counts of things.\n- **1 byte** and **2 bytes**: small whole numbers. Lives, a level number, an on/off switch.\n- **Float**: numbers that can have a decimal part, such as 87.5. Health in many modern games, timers, speeds, positions, anything shown as a bar.\n- **Double**: the same idea as a float with more digits, in eight bytes.\n\nThe catch: **50 as a float looks nothing like 50 as a whole number.** Search for 50 as 4 bytes and you will sail straight past a float holding 50.\n\n> tip: When the game shows a number with a decimal point, or a bar, or a clock, think float. When you are not sure, there is **Auto**: it searches for your number as 4 bytes, as a float and as a double, one after another, and tells you which each match is."
},
{
"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 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": {
"check": "value",
"label": "Have 500 fuel or more",
"field": "car.fuel",
"is": "atLeast",
"value": 500
},
"hints": [
"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."
],
"done": "The match was a Float all along. Auto costs a little time on the first search and saves you from guessing. Once you know a game keeps something as a float, pick Float directly."
},
{
"kind": "choice",
"title": "Check yourself",
"text": "",
"question": "You search for your gold, 2500, as 4 bytes. You spend some, narrow down with the new number, and get zero matches. What is the most likely reason?",
"options": [
"The game is protected against cheating.",
"Gold is stored as another type, such as a float.",
"You need to search more slowly."
],
"answer": 1,
"explain": "An empty list after a correct number nearly always means the wrong type. Start a new search with Auto, or with Float."
},
{
"kind": "read",
"title": "Numbers that will not sit still",
"text": "The clock on the rally car changes every second. By the time you have typed \"87\" it says 86. Searching for an exact number is a race you lose.\n\nTwo tools for that:\n\n- **Between** lets you give a range instead of one number. The clock reads about a minute and a half, so its value is between 0 and 100 if it counts seconds. A range stays true while the number moves inside it.\n- **Went down** (or up) needs no number at all. A countdown goes down, all by itself. Press Narrow down with \"Went down\" a few times, a second or two apart, and almost nothing else in memory keeps going down in step with it.\n\n> note: What the screen shows is not always what is stored. \"1:27\" is drawn from a number of seconds, here 87. A health bar at \"75%\" may be 0.75, or 75, or 750 out of 1000. When an exact search fails, fall back on a range or on went up / went down."
},
{
"kind": "practice",
"title": "Stop the clock",
"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": {
"check": "steady",
"label": "Keep the clock from moving for 4 seconds",
"field": "car.time",
"seconds": 4
},
"hints": [
"The clock shows minutes and seconds, but it is stored as seconds: 1:27 is 87.",
"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.",
"fresh": true
},
{
"kind": "practice",
"title": "On your own: toll money",
"text": "A driver with money shown to the cent, and a toll to pay. Make them rich: **100000** or more.\n\nYou are not told the type.",
"game": "toll",
"tool": "find",
"goal": {
"check": "value",
"label": "Have 100000 money or more",
"field": "driver.money",
"is": "atLeast",
"value": 100000
},
"hints": [
"Cents mean a decimal part, so it is not a plain whole number. Let Auto work it out.",
"Type the money exactly as shown, with its decimals. After paying a toll, narrow down with the new amount.",
"The match is a Double. Type 100000 in its box and press Set."
],
"done": "A double, found without knowing it was one. Types are no longer a way for a value to hide from you.",
"fresh": true,
"challenge": true
}
]
}
+81
View File
@@ -0,0 +1,81 @@
{
"format": "cheatspecter-lesson",
"version": 1,
"id": "decisions",
"title": "Code with decisions: only for the player",
"summary": "Reading a little assembly: compare, jump, and how a cheat can tell you from an enemy.",
"minutes": 10,
"games": {},
"steps": [
{
"kind": "read",
"title": "Instructions, one line at a time",
"text": "The Code tab shows the game's program as a list of **instructions**, one per line. This language is called assembly. You do not need to write it to make good cheats, and you can go a long way reading only a handful of words.\n\nEach instruction is a short verb and the things it works on:\n\n- `mov eax, [rbx+0x10]` means **copy**. Take the number stored at rbx+0x10 and put it in eax.\n- `sub eax, 12` means **subtract** 12 from eax. `add` is the opposite.\n- `mov [rbx+0x10], eax` copies the other way: store eax at rbx+0x10.\n\nThe names `eax`, `rbx`, `rcx` are **registers**: a few scratch boxes inside the processor where numbers are held while being worked on. Square brackets mean \"the memory at this address\". So `[rbx+0x10]` is \"the value 0x10 bytes into the block rbx points at\": the health field from the last lesson.\n\nRead those three lines together and they say: fetch the health, take 12 off, store it back."
},
{
"kind": "choice",
"title": "Check yourself",
"text": "",
"question": "What does \"add dword ptr [rcx+0x40], 25\" do?",
"options": [
"Copies 25 into the register rcx.",
"Adds 25 to the four-byte number stored 0x40 bytes into the block rcx points at.",
"Jumps 25 instructions ahead."
],
"answer": 1,
"explain": "Square brackets are memory; \"dword\" says the number is four bytes; add adds. This is what \"open a chest, get 25 gold\" looks like."
},
{
"kind": "read",
"title": "How code makes a decision",
"text": "Code runs from top to bottom, one instruction after another, unless something tells it to go elsewhere. Decisions are made of two instructions that always travel together:\n\n- `cmp` **compares** two things and remembers the result.\n- A **jump** then goes somewhere else, or does not, depending on that result.\n\nThe jumps you will meet most:\n\n- `je` jump if they were **equal** (also written `jz`)\n- `jne` jump if they were **not equal** (also `jnz`)\n- `jl`, `jg` jump if the first was **less**, or **greater**\n- `jmp` jump always, no question asked\n\nHere is a game deciding whether a hit should hurt, in four lines:\n\n1. `cmp dword ptr [rbx+0x18], 1` compare the fighter's team with 1\n2. `je 0x401040` if it is 1, jump ahead to 0x401040, past the next line\n3. `sub dword ptr [rbx+0x10], 12` otherwise: take 12 off the health\n4. (at 0x401040) the code carries on\n\nIn words: **if the fighter is on team 1, skip the damage.** A game with friendly fire switched off has exactly this somewhere."
},
{
"kind": "choice",
"title": "Check yourself",
"text": "",
"question": "In the four lines above, the fighter's team is 2. What happens?",
"options": [
"The jump is taken and no damage is done.",
"The jump is not taken, and 12 is subtracted from the health.",
"The game crashes, because 2 is not a valid team."
],
"answer": 1,
"explain": "cmp finds that 2 is not equal to 1, so \"jump if equal\" does nothing and the next line runs: the subtraction."
},
{
"kind": "read",
"title": "Making a decision go your way",
"text": "Two classic ways to change a decision, both with the tools you have used.\n\n# Remove the jump\n\nReplace a jump with no-ops and the code can no longer skip what follows. If the jump was \"skip the reward unless you paid\", everyone now gets the reward.\n\n# Make the jump unconditional\n\nChange `je` (jump if equal) into `jmp` (jump always) and what follows is always skipped. If what follows is \"take damage\", nobody is ever hurt.\n\nNeither of these tells you from an enemy, though. They change the rule for everyone, like the guard in lesson 10.\n\n# Add a decision of your own\n\nTo protect only the player, the code needs a new question: **is this block the player's?** The original code has no room for extra instructions, so the trick is a detour:\n\n1. Replace the damage instruction with a jump to an empty piece of memory.\n2. There, put your own few lines: compare something that differs between you and enemies, such as the team number at `[rbx+0x18]`. If it is yours, skip the damage. Otherwise do the original subtraction.\n3. Jump back to the instruction after the one you replaced.\n\nDone that way, enemies take damage as before and you do not. In the Code tab this is what **Run my own code here…** on an instruction's menu is for.\n\n> note: How do you know what differs between you and an enemy? Lesson 10: look at both blocks in the Memory tab, side by side. A team number, a \"controlled by player\" flag, or a pointer that only the player's block has, is usually within a few dozen bytes of the health."
},
{
"kind": "answer",
"title": "Check yourself",
"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 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."
},
{
"kind": "choice",
"title": "Check yourself",
"text": "",
"question": "You want enemies to still take damage, but not you. The damage is done by one shared instruction. What is the right tool?",
"options": [
"Replace the instruction with no-ops.",
"A detour that checks whose block it is before subtracting.",
"Restart the game."
],
"answer": 1,
"explain": "No-ops would protect everyone. A detour with a comparison lets you choose who the subtraction applies to."
},
{
"kind": "soon",
"title": "Practice",
"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."
}
]
}
+249
View File
@@ -0,0 +1,249 @@
{
"format": "cheatspecter-lesson",
"version": 1,
"id": "find-a-number",
"title": "Find a number you can see",
"summary": "Search for your ammo, change it in the game, search again, and edit what is left.",
"minutes": 8,
"games": {
"range": {
"name": "Specter Range",
"objects": [
{
"id": "player",
"fields": [
{
"id": "ammo",
"type": "u32",
"value": 30,
"offset": 40
},
{
"id": "shots",
"type": "u32",
"value": 0,
"offset": 64
}
]
}
],
"actions": [
{
"id": "fire",
"label": "Fire",
"effects": [
{
"field": "player.ammo",
"op": "sub",
"by": 1,
"min": 0,
"refill": 30
},
{
"field": "player.shots",
"op": "add",
"by": 1
}
]
}
],
"hud": [
{
"label": "Ammo",
"field": "player.ammo",
"show": "number"
}
],
"decoys": [
{
"type": "u32",
"value": 30,
"count": 12
},
{
"type": "u32",
"value": 29,
"count": 5
}
],
"copies": [
{
"of": "player.ammo",
"count": 3,
"follow": "fade"
}
]
},
"shop": {
"name": "Specter Market",
"objects": [
{
"id": "wallet",
"fields": [
{
"id": "coins",
"type": "u32",
"value": 480,
"offset": 24
},
{
"id": "items",
"type": "u32",
"value": 0,
"offset": 28
}
]
}
],
"actions": [
{
"id": "buy",
"label": "Buy a potion (35 coins)",
"effects": [
{
"field": "wallet.coins",
"op": "sub",
"by": 35,
"min": 0,
"refill": 480
},
{
"field": "wallet.items",
"op": "add",
"by": 1
}
]
}
],
"hud": [
{
"label": "Coins",
"field": "wallet.coins",
"show": "number"
},
{
"label": "Potions",
"field": "wallet.items",
"show": "number"
}
],
"decoys": [
{
"type": "u32",
"value": 480,
"count": 9
},
{
"type": "u32",
"value": 445,
"count": 4
}
],
"copies": [
{
"of": "wallet.coins",
"count": 2,
"follow": "fade"
}
]
}
},
"steps": [
{
"kind": "read",
"title": "One number among millions",
"text": "Your ammo counter says **30**. Somewhere in the game's memory there is a place that holds that 30. So do thousands of other places that have nothing to do with ammo: a screen setting, a timer, the number of trees in the level.\n\nSearching for 30 once will not tell you which is which. The trick is to make the game change the number and search again:\n\n1. Search for the number you see.\n2. Do something in the game that changes it. Fire a shot.\n3. Search for the new number, but only among the places found last time.\n4. Repeat until one place is left.\n\nA place that held 30 by coincidence will not also hold 29 after your shot, and 28 after the next. Only your ammo follows along every time.\n\n> note: This works on any number the game shows you: ammo, money, lives, a score, a level, how many arrows you carry."
},
{
"kind": "practice",
"title": "The first search",
"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": {
"check": "narrowed",
"label": "Search for the ammo number the game shows",
"field": "player.ammo",
"atMost": 2000
},
"hints": [
"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."
},
{
"kind": "practice",
"title": "Fire, then narrow down",
"text": "Now make the game change the number, and tell the tool what it became:\n\n1. Press **Fire** once. The ammo drops by one.\n2. Type the new number into **Value**.\n3. Press **Narrow down**.\n\nWatch the count of matches fall. Do it again, one shot at a time, until a single match is left.\n\n> tip: Firing several shots before narrowing is fine. What matters is that the number you type is the one on screen at the moment you press Narrow down.",
"game": "range",
"tool": "find",
"goal": {
"check": "narrowed",
"label": "Narrow the list down to one match",
"field": "player.ammo",
"atMost": 1
},
"hints": [
"Press Fire, look at the Ammo number, type it into Value, press Narrow down. One round removes most of the list.",
"A few places keep following your ammo for a round or two: the game copies the number around for its own reasons. Keep going. Fire, type, Narrow down.",
"If the list ever becomes empty, the number you typed was not the one on screen. Press New search and start again from the first search."
],
"done": "One match. That address is where this game keeps your ammo, for as long as it keeps running."
},
{
"kind": "practice",
"title": "Change it",
"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": {
"check": "value",
"label": "Give yourself at least 999 ammo",
"field": "player.ammo",
"is": "atLeast",
"value": 999
},
"hints": [
"In the list of matches, the row has an empty box followed by a Set button. Type 999 there and press Set.",
"If more than one row is left, set each one. Only the real one changes the Ammo number the game shows."
],
"done": "The game now believes you have that much ammo. Fire a shot: it counts down from your number, because nothing has changed about how the game works. You only changed what it remembers."
},
{
"kind": "choice",
"title": "Why did the list shrink?",
"text": "",
"question": "After your first shot, most of the places that held 30 dropped off the list. Why?",
"options": [
"The tool guessed which ones looked like ammo.",
"They did not hold the new number on screen.",
"The game deleted them when you fired."
],
"answer": 1,
"explain": "The tool does not know what ammo is. It only keeps the places whose number matches what you typed, each time. Coincidences cannot keep that up for long."
},
{
"kind": "practice",
"title": "On your own: the market",
"text": "A different game, a different number, and no step-by-step this time.\n\nYou have some coins and a shop that sells potions. Find where the game keeps your coins and give yourself **5000 or more**.",
"game": "shop",
"tool": "find",
"goal": {
"check": "value",
"label": "Have 5000 coins or more",
"field": "wallet.coins",
"is": "atLeast",
"value": 5000
},
"hints": [
"Same method as before: search for the number of coins you see.",
"Buying a potion changes your coins. Buy one, type the new amount, press Narrow down.",
"When one or two matches are left, type 5000 into the box on the row and press Set."
],
"done": "That is the whole method, and it is most of what people mean by \"memory editing\". The next lesson is about keeping a number from going back down.",
"fresh": true,
"challenge": true
}
]
}
+283
View File
@@ -0,0 +1,283 @@
{
"format": "cheatspecter-lesson",
"version": 1,
"id": "find-the-code",
"title": "Find the code that changes it, and switch it off",
"summary": "Ask the debugger which instruction lowers your ammo, then replace it with instructions that do nothing.",
"minutes": 10,
"games": {
"range": {
"name": "Specter Range",
"objects": [
{
"id": "player",
"fields": [
{
"id": "ammo",
"type": "u32",
"value": 20,
"offset": 40
},
{
"id": "shots",
"type": "u32",
"value": 0,
"offset": 64
}
]
}
],
"actions": [
{
"id": "fire",
"label": "Fire",
"effects": [
{
"field": "player.ammo",
"op": "sub",
"by": 1,
"min": 0,
"refill": 20
},
{
"field": "player.shots",
"op": "add",
"by": 1
}
]
}
],
"hud": [
{
"label": "Ammo",
"field": "player.ammo",
"show": "number"
}
],
"decoys": [
{
"type": "u32",
"value": 20,
"count": 12
},
{
"type": "u32",
"value": 19,
"count": 5
}
],
"copies": [
{
"of": "player.ammo",
"count": 2,
"follow": "fade"
}
]
},
"shop": {
"name": "Specter Market",
"objects": [
{
"id": "wallet",
"fields": [
{
"id": "coins",
"type": "u32",
"value": 600,
"offset": 24
},
{
"id": "items",
"type": "u32",
"value": 0,
"offset": 28
}
]
}
],
"actions": [
{
"id": "buy",
"label": "Buy a potion (45 coins)",
"effects": [
{
"field": "wallet.coins",
"op": "sub",
"by": 45,
"min": 0,
"refill": 600
},
{
"field": "wallet.items",
"op": "add",
"by": 1
}
]
}
],
"hud": [
{
"label": "Coins",
"field": "wallet.coins",
"show": "number"
},
{
"label": "Potions",
"field": "wallet.items",
"show": "number"
}
],
"decoys": [
{
"type": "u32",
"value": 600,
"count": 9
},
{
"type": "u32",
"value": 555,
"count": 4
}
],
"copies": [
{
"of": "wallet.coins",
"count": 2,
"follow": "fade"
}
]
}
},
"steps": [
{
"kind": "read",
"title": "Go after the cause",
"text": "Freezing has two weaknesses. It fights the game four times a second rather than stopping it, and it is lost the moment the game restarts, because the value moves.\n\nThere is something that does not move: the game's **code**. Somewhere in it is one small **instruction** that says, in effect, \"take 1 away from the ammo\". It runs every time you fire. It is in the same place every time the game starts.\n\nIf that instruction did nothing, firing would cost nothing. No tug of war, no flicker, and the fix can be switched on again tomorrow.\n\n# How do you find one instruction among millions?\n\nYou ask the **debugger**. Give it the address of your ammo and it watches that spot. The moment any code writes there, the game is paused for an instant, the debugger notes which instruction did it, and the game carries on. You fire once, and it hands you the culprit.\n\n# How do you make an instruction do nothing?\n\nEvery instruction is a few bytes. One special byte, written `90`, is an instruction that means \"do nothing and move on\". It is called a **no-op**. Replace the bytes of \"take 1 away\" with the same number of no-ops and the game steps over the spot where the subtraction used to be.\n\n> note: You replace an instruction with exactly as many no-op bytes as it had. Remove bytes or add them and everything after would shift, and the game would crash. The tool takes care of the count for you."
},
{
"kind": "practice",
"title": "Catch it in the act",
"text": "1. Find your ammo, as in lesson 2, until one match is left.\n2. On that row, open the **…** menu and choose **Find what writes to this**.\n\nThe tool moves to the **Code** tab and says it is watching. Now give it something to see: **Fire** once or twice.",
"game": "range",
"tool": "find",
"goal": {
"check": "caught",
"label": "Find out which code writes to your ammo",
"field": "player.ammo"
},
"hints": [
"First the usual: Find tab, type the Ammo number, Search, Fire, type the new number, Narrow down.",
"The … button is at the very end of the row, after the padlock. Its menu has \"Find what writes to this\".",
"While the Code tab says \"Watching\", press Fire on the practice game. An instruction appears in the list, with a count of how often it ran."
],
"done": "One line, and a count that goes up with each shot. It reads something like \"sub dword ptr [rbx+0x28], 1\": subtract 1 from a four-byte number that sits 0x28 past wherever \"rbx\" points. You do not need to read it fluently. It ran when you fired, and it wrote to your ammo. That is your instruction."
},
{
"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, 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": {
"check": "off",
"label": "Replace that instruction with no-ops",
"action": "fire"
},
"hints": [
"The list of instructions is in the Code tab, under \"What touches a value\". Each line has a Show button and a Disable it button.",
"If the list is empty, the watching was stopped before you fired. Go back to the Find tab, open the … menu on your ammo row and choose \"Find what writes to this\" again, then fire."
],
"done": "It is now a cheat with a switch. Flip the switch off and the original bytes go back, exactly as they were."
},
{
"kind": "practice",
"title": "Try it",
"text": "Fire three times and watch the Ammo number.\n\nThen, if you are curious, flip the switch off, fire, and flip it on again.",
"game": "range",
"tool": "cheats",
"goal": {
"check": "held",
"label": "Fire three times without your ammo going down",
"field": "player.ammo",
"action": "fire",
"times": 3
},
"hints": [
"The cheat needs to be switched on: in Your cheats, its switch should be yellow.",
"Leave a moment between shots."
],
"done": "No flicker this time. Nothing is putting the number back, because nothing takes it away any more."
},
{
"kind": "practice",
"title": "Restart, and switch it back on",
"text": "Here is the part freezing could not do. Press **Restart game**.\n\nThe game loads its code fresh from disc, so the instruction is back and your cheat's switch is off. Your ammo is at a new address, and you do not care: the cheat never needed it.\n\nFlip the switch on. No searching.",
"game": "range",
"tool": "cheats",
"goal": {
"check": "all",
"of": [
{
"check": "restarted",
"label": "Restart the practice game"
},
{
"check": "off",
"label": "Switch your cheat on again",
"action": "fire"
}
]
},
"hints": [
"After the restart, open the Your cheats tab. Your cheat is still listed.",
"Turn its switch on. Then fire to check."
],
"done": "One switch, and it works again, in a game you have not searched at all. This is what a cheat that lasts looks like."
},
{
"kind": "choice",
"title": "Check yourself",
"text": "",
"question": "Why does the switched-off instruction still work as a cheat after a restart, when a frozen value does not?",
"options": [
"The game's code is loaded in the same place every time; values are not.",
"No-ops are stronger than freezes.",
"The debugger keeps running inside the game."
],
"answer": 0,
"explain": "The code comes off the disc the same way on every start. Values are created as the game goes, wherever there is room. A cheat tied to the code can be found again; a cheat tied to a value's address cannot."
},
{
"kind": "practice",
"title": "On your own: free potions",
"text": "A shop that takes your coins. Make potions free, with a cheat that would still be there after a restart.",
"game": "shop",
"tool": "find",
"goal": {
"check": "all",
"of": [
{
"check": "off",
"label": "Switch off the code that takes your coins",
"action": "buy"
},
{
"check": "held",
"label": "Buy three potions without paying",
"field": "wallet.coins",
"action": "buy",
"times": 3
}
]
},
"hints": [
"Find the coins first. Then use the … menu on the row: \"Find what writes to this\".",
"Buy a potion while the Code tab is watching, then press \"Disable it\" on the instruction that showed up.",
"Buy three potions to prove it. Notice that the potion count still goes up: you only removed the paying."
],
"done": "You still get the potions, because the instruction that adds a potion is a different one, and you left it alone. Choosing which instruction to remove is most of the skill.\n\nOn a real game, the Cheats tab has **Guide me through my first cheat**. It walks you through these same steps one screen at a time, and it is a good way to make your first real one.",
"fresh": true,
"challenge": true
}
]
}
+264
View File
@@ -0,0 +1,264 @@
{
"format": "cheatspecter-lesson",
"version": 1,
"id": "game-updates",
"title": "Surviving game updates: patterns",
"summary": "An update moves the game's code. A pattern finds your instruction again wherever it went.",
"minutes": 8,
"games": {
"range": {
"name": "Specter Range",
"objects": [
{
"id": "player",
"fields": [
{
"id": "ammo",
"type": "u32",
"value": 18,
"offset": 40
},
{
"id": "shots",
"type": "u32",
"value": 0,
"offset": 64
}
]
}
],
"actions": [
{
"id": "fire",
"label": "Fire",
"effects": [
{
"field": "player.ammo",
"op": "sub",
"by": 1,
"min": 0,
"refill": 18
},
{
"field": "player.shots",
"op": "add",
"by": 1
}
]
}
],
"hud": [
{
"label": "Ammo",
"field": "player.ammo",
"show": "number"
}
],
"decoys": [
{
"type": "u32",
"value": 18,
"count": 12
},
{
"type": "u32",
"value": 17,
"count": 5
}
],
"copies": [
{
"of": "player.ammo",
"count": 2,
"follow": "fade"
}
],
"update": true
},
"shop": {
"name": "Specter Market",
"objects": [
{
"id": "wallet",
"fields": [
{
"id": "coins",
"type": "u32",
"value": 700,
"offset": 24
},
{
"id": "items",
"type": "u32",
"value": 0,
"offset": 28
}
]
}
],
"actions": [
{
"id": "buy",
"label": "Buy a potion (55 coins)",
"effects": [
{
"field": "wallet.coins",
"op": "sub",
"by": 55,
"min": 0,
"refill": 700
},
{
"field": "wallet.items",
"op": "add",
"by": 1
}
]
}
],
"hud": [
{
"label": "Coins",
"field": "wallet.coins",
"show": "number"
},
{
"label": "Potions",
"field": "wallet.items",
"show": "number"
}
],
"decoys": [
{
"type": "u32",
"value": 700,
"count": 9
},
{
"type": "u32",
"value": 645,
"count": 4
}
],
"copies": [
{
"of": "wallet.coins",
"count": 2,
"follow": "fade"
}
],
"update": true
}
},
"steps": [
{
"kind": "read",
"title": "What an update does to a cheat",
"text": "\"The code is in the same place every time\" has one exception: a **game update**. A patch adds a few instructions here, removes a few there, and everything after them slides along. Your instruction is still in the game, doing the same job. It is just a little further down.\n\nA cheat that says \"the instruction at position 0x1018 in the game\" now points into the middle of something else. That is why cheat files you download are marked with a game version, and why using one on the wrong version is a bad idea.\n\n# A pattern instead of a position\n\nThere is a sturdier way to say where an instruction is: by **what the code around it looks like**.\n\nThink of finding a sentence in a book. \"Page 212, line 4\" breaks as soon as a new edition adds a foreword. \"The sentence that comes right after 'she closed the door and' \" still works.\n\nA **pattern** is that idea in bytes: a short run of the bytes just before and after your instruction, with the parts that tend to change from build to build marked as \"anything goes\" and written `??`. For example: `48 8B 98 ?? ?? ?? ?? ?? ?? ?? ?? ?? ?? ?? 5D C3`.\n\nWhen you switch the cheat on, the tool searches the game's code for the one place that matches, and patches there.\n\n> note: You do not write patterns yourself. When you press \"Disable it\", the tool builds one from the surrounding code, checks that it matches in exactly one place, and keeps the fixed position as a fallback."
},
{
"kind": "practice",
"title": "Make the cheat",
"text": "Make the infinite ammo cheat from lesson 6: find the ammo, **Find what writes to this**, fire, **Disable it**.\n\nThen, in **Your cheats**, open **…** and **Edit** on the new cheat and look at **where** it applies. It will say it is found by a pattern, and show it.",
"game": "range",
"tool": "find",
"goal": {
"check": "off",
"label": "Switch off the code that uses up ammo",
"action": "fire"
},
"hints": [
"Find tab: search for the Ammo number, fire, narrow down to one match.",
"On the row: … then \"Find what writes to this\". Fire while the Code tab is watching, then press \"Disable it\"."
],
"done": "The cheat is on, and it knows its instruction by the code around it."
},
{
"kind": "practice",
"title": "Install the update",
"text": "The practice game has an update waiting. Press **Install game update** on its bar. The game restarts as a new version, and its code has moved.\n\nOpen **Your cheats** and flip your cheat's switch on. Then fire.",
"game": "range",
"tool": "cheats",
"goal": {
"check": "all",
"of": [
{
"check": "updated",
"label": "Install the game update"
},
{
"check": "off",
"label": "Switch the cheat on in the updated game",
"action": "fire"
}
]
},
"hints": [
"\"Install game update\" is next to \"Restart game\" on the practice game's bar.",
"After the update, your cheat is still in Your cheats, switched off. Turn it on.",
"The tool may mention that the cheat was made on another version. That is true, and here the pattern still finds the instruction."
],
"done": "The instruction was not where it used to be. The pattern found it anyway."
},
{
"kind": "choice",
"title": "Check yourself",
"text": "",
"question": "A pattern has some bytes written as ?? . Why?",
"options": [
"They are secret.",
"They are parts of the code that often differ between versions, so they are not compared.",
"The tool could not read them."
],
"answer": 1,
"explain": "Things like distances to other parts of the program change whenever code moves. Leaving them out of the comparison is what lets the same pattern match before and after an update."
},
{
"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 **…** 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. 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"
},
{
"check": "off",
"label": "Have the paying switched off in the updated game",
"action": "buy"
},
{
"check": "held",
"label": "Buy three potions without paying",
"field": "wallet.coins",
"action": "buy",
"times": 3
}
]
},
"hints": [
"Make the cheat first, the lesson 6 way: find the coins, find what writes to them, disable 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,
"challenge": true
}
]
}
+160
View File
@@ -0,0 +1,160 @@
{
"format": "cheatspecter-lesson",
"version": 1,
"id": "good-habits",
"title": "Good habits",
"summary": "Using it at your own risk, backing up saves, undoing everything at once, and what to do when the game crashes.",
"minutes": 6,
"games": {
"arena": {
"name": "Specter Arena",
"objects": [
{
"id": "hero",
"fields": [
{
"id": "health",
"type": "u32",
"value": 100,
"offset": 48
},
{
"id": "gold",
"type": "u32",
"value": 75,
"offset": 52
}
]
}
],
"actions": [
{
"id": "hit",
"label": "Take a hit",
"effects": [
{
"field": "hero.health",
"op": "sub",
"by": 9,
"min": 0,
"refill": 100
}
]
},
{
"id": "loot",
"label": "Pick up gold",
"effects": [
{
"field": "hero.gold",
"op": "add",
"by": 10
}
]
}
],
"hud": [
{
"label": "Health",
"field": "hero.health",
"show": "number"
},
{
"label": "Gold",
"field": "hero.gold",
"show": "number"
}
],
"decoys": [
{
"type": "u32",
"value": 100,
"count": 8
},
{
"type": "u32",
"value": 75,
"count": 6
}
]
}
},
"steps": [
{
"kind": "read",
"title": "At your own risk",
"text": "Changing a running game's memory is never completely safe, and what you do with these tools is your choice and **your responsibility**. Nobody can promise that a change is harmless.\n\n- **Crashes.** A wrong value or a wrong patch can stop the game on the spot. Anything not saved is lost.\n- **Saves.** A game can write a changed value into your save file, and it stays there after the cheat is off.\n\nCheatSpecter has only been tested on 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",
"title": "Protect your save",
"text": "A cheat changes memory. A save file is a snapshot of memory. Put those together: **whatever your cheat did can end up saved for good.**\n\nUsually that is the point: a million gold that is still there tomorrow. Sometimes it is not: a quest that can no longer be finished because an item count went out of range, a character stuck with health the game cannot display.\n\n- **Back up your save before trying a new cheat**, especially one that touches the story or your inventory. On PS5: Settings, then Saved Data and Game/App Settings. Copy the save to USB or to the cloud.\n- **Keep a save from before** in a separate slot when the game allows it.\n- **Avoid saving while something looks wrong.** If the game is acting strangely, do not let it autosave: undo your cheats first, or quit without saving.\n- **Sensible numbers.** 9999 gold is safe almost everywhere. Four billion gold is asking the game what happens when a number overflows."
},
{
"kind": "practice",
"title": "Change things, then put them back",
"text": "Make a change, so there is something to undo. Find your **Gold** and set it to **5000**. Then pick up gold once to see the game carry on from your number.",
"game": "arena",
"tool": "find",
"goal": {
"check": "all",
"of": [
{
"check": "value",
"label": "Set your gold to 5000 or more",
"field": "hero.gold",
"is": "atLeast",
"value": 5000
},
{
"check": "pressed",
"label": "Pick up gold once more",
"action": "loot",
"times": 1
}
]
},
"hints": [
"Find tab: search for the Gold number, pick up gold, type the new number, Narrow down.",
"Type 5000 in the box on the row and press Set. Then press \"Pick up gold\"."
],
"done": "Changed by hand. The tool has quietly written down what was there before."
},
{
"kind": "practice",
"title": "The panic button",
"text": "Open **Your cheats**. At the top there is now a bar: \"Something is on or was changed in this game\", with a button, **Restore everything**.\n\nPress it.\n\nIt switches off every cheat, stops every freeze, and writes back each value you changed by hand to what it was before you touched it.",
"game": "arena",
"tool": "cheats",
"goal": {
"check": "value",
"label": "Put your gold back with \"Restore everything\"",
"field": "hero.gold",
"is": "atMost",
"value": 200
},
"hints": [
"The bar with \"Restore everything\" is at the top of the Your cheats tab. It only appears once something has been changed."
],
"done": "Back to what the game had before you touched it. On a real game, this is the first thing to press when anything looks wrong: before the game saves, and before you try to work out what happened."
},
{
"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. 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",
"title": "One last question",
"text": "",
"question": "A cheat you downloaded says it was made for version 01.04 of a game. You are on 01.07, and switching it on reports that the game's code is not what the cheat expects. What do you do?",
"options": [
"Force it on. It is probably close enough.",
"Leave it off, and look for a version made for 01.07, or use \"Re-find my cheats after a game update\".",
"Delete your save and start over."
],
"answer": 1,
"explain": "The check exists to stop bytes being written into code that has changed. Find a matching version, or let the tool search for the moved code and show you what it found first."
}
]
}
+234
View File
@@ -0,0 +1,234 @@
{
"format": "cheatspecter-lesson",
"version": 1,
"id": "hold-it",
"title": "Change it and hold it",
"summary": "Freeze a value so the game cannot lower it, and see why that stops working after a restart.",
"minutes": 8,
"games": {
"range": {
"name": "Specter Range",
"objects": [
{
"id": "player",
"fields": [
{
"id": "ammo",
"type": "u32",
"value": 24,
"offset": 40
},
{
"id": "shots",
"type": "u32",
"value": 0,
"offset": 64
}
]
}
],
"actions": [
{
"id": "fire",
"label": "Fire",
"effects": [
{
"field": "player.ammo",
"op": "sub",
"by": 1,
"min": 0,
"refill": 24
},
{
"field": "player.shots",
"op": "add",
"by": 1
}
]
}
],
"hud": [
{
"label": "Ammo",
"field": "player.ammo",
"show": "number"
}
],
"decoys": [
{
"type": "u32",
"value": 24,
"count": 12
},
{
"type": "u32",
"value": 23,
"count": 5
}
],
"copies": [
{
"of": "player.ammo",
"count": 2,
"follow": "fade"
}
]
},
"run": {
"name": "Specter Dash",
"objects": [
{
"id": "runner",
"fields": [
{
"id": "energy",
"type": "u32",
"value": 80,
"offset": 32
},
{
"id": "distance",
"type": "u32",
"value": 0,
"offset": 36
}
]
}
],
"actions": [
{
"id": "sprint",
"label": "Sprint",
"effects": [
{
"field": "runner.energy",
"op": "sub",
"by": 15,
"min": 0,
"refill": 80
},
{
"field": "runner.distance",
"op": "add",
"by": 40
}
]
}
],
"hud": [
{
"label": "Energy",
"field": "runner.energy",
"show": "number"
},
{
"label": "Distance",
"field": "runner.distance",
"show": "number"
}
],
"decoys": [
{
"type": "u32",
"value": 80,
"count": 10
}
],
"copies": [
{
"of": "runner.energy",
"count": 2,
"follow": "fade"
}
]
}
},
"steps": [
{
"kind": "read",
"title": "Setting is not keeping",
"text": "In the last lesson you set your ammo to 999, and the next shot made it 998. Setting a value changes it once. The game carries on exactly as before, and its code still takes one away every time you fire.\n\nTo keep a number where you want it, the tool can **freeze** it: it writes your number back into that address four times a second, for as long as the freeze is on. The game takes one away; a moment later it is back.\n\n> note: Freezing is a tug of war that the tool wins by being persistent. Watch closely and you may see the number flicker down and up. Some games notice the in-between value, for instance by ending the level the instant health touches zero. Lesson 6 shows the way to stop the game from lowering the number in the first place."
},
{
"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 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": {
"check": "held",
"label": "Fire three times without your ammo going down",
"field": "player.ammo",
"action": "fire",
"times": 3
},
"hints": [
"Find tab: type the Ammo number, Search. Fire once, type the new number, Narrow down. Repeat until one match is left.",
"The padlock is the small button with a lock on it, on the right of the row. On a phone it is on the second line of the row.",
"Leave about a second between shots, so you can see each one being undone."
],
"done": "The game still subtracts one on every shot. The tool just puts it back before you can blink. To hold a different number, type it into the box on the row and press Set while it is frozen."
},
{
"kind": "practice",
"title": "Restart the game",
"text": "Now close the game and open it again: press **Restart game** on the practice game's bar. Then fire a shot.",
"game": "range",
"tool": "find",
"goal": {
"check": "all",
"of": [
{
"check": "restarted",
"label": "Restart the practice game"
},
{
"check": "pressed",
"label": "Fire once in the restarted game",
"action": "fire",
"times": 1
}
]
},
"hints": [
"\"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."
},
{
"kind": "choice",
"title": "Check yourself",
"text": "",
"question": "Why did the freeze stop working after the restart?",
"options": [
"Freezes wear off after a few minutes.",
"The game detected the cheat and removed it.",
"The ammo is at a different address in the restarted game."
],
"answer": 2,
"explain": "Nothing detected anything. The value simply is not where it was. A freeze, or any change made straight to an address, lasts only as long as that run of the game."
},
{
"kind": "practice",
"title": "On your own: endless energy",
"text": "A runner with an energy number that drops with every sprint. Make sprinting free.",
"game": "run",
"tool": "find",
"goal": {
"check": "held",
"label": "Sprint three times without losing energy",
"field": "runner.energy",
"action": "sprint",
"times": 3
},
"hints": [
"Find the Energy number first: search, sprint, narrow down.",
"Energy drops by 15 each sprint, so the number to type changes by 15 each time.",
"When one match is left, press the padlock on its row, then sprint three times with a second in between."
],
"done": "Find, then freeze: on a real game that is already a working cheat for one play session. The next lessons make it work on values with no number, and then make it last.",
"fresh": true,
"challenge": true
}
]
}
+145
View File
@@ -0,0 +1,145 @@
{
"format": "cheatspecter-lesson",
"version": 1,
"id": "how-games-remember",
"title": "How games remember things",
"summary": "Memory, values and addresses, and why a value is somewhere else every time you start the game.",
"minutes": 6,
"games": {
"lives": {
"name": "Specter Run",
"objects": [
{
"id": "player",
"fields": [
{
"id": "lives",
"type": "u32",
"value": 3
},
{
"id": "steps",
"type": "u32",
"value": 0
}
]
}
],
"actions": [
{
"id": "step",
"label": "Take a step",
"effects": [
{
"field": "player.steps",
"op": "add",
"by": 1
}
]
}
],
"hud": [
{
"label": "Lives",
"field": "player.lives",
"show": "number"
},
{
"label": "Steps",
"field": "player.steps",
"show": "number"
}
],
"decoys": [
{
"type": "u32",
"value": 3,
"count": 40
}
]
}
},
"steps": [
{
"kind": "read",
"title": "A game is a program that remembers",
"text": "While a game runs, everything it knows right now is kept in the console's **memory**: how much health you have, where you are standing, how many coins you picked up, which door is open.\n\nMemory is a very long row of numbered boxes. Each box holds one small number, a **byte**. Bigger things take several boxes in a row: a number like your ammo usually takes four.\n\n- The number of a box is its **address**. Addresses are written in a counting system with sixteen digits, so they look like `0x2A51C0F8`. You never need to do sums with them. Treat an address like a street address: it says where, nothing more.\n- What is in the boxes is a **value**: your ammo, 30.\n\n> note: A cheat tool does one simple thing. It reads those boxes while the game is running, and writes new numbers into them. The game cannot tell the difference between a number it wrote itself and one you wrote."
},
{
"kind": "read",
"title": "Why not just look it up?",
"text": "If ammo lived at the same address for everyone, somebody would publish the address and that would be that. It does not, for two reasons.\n\n# Things are put wherever there is room\n\nWhen you start a level, the game asks for memory for the player, each enemy, each bullet. It gets whatever happens to be free at that moment. Start the game again and the same things end up in different places.\n\n# The game's own program is the exception\n\nThe instructions that make up the game, its **code**, are loaded from the disc in the same order every time. That is why the best cheats change code rather than values. You will get there in lesson 6.\n\nFor now, the rule to remember: **a value's address is good until the game closes.** After a restart you find it again."
},
{
"kind": "choice",
"title": "Check yourself",
"text": "",
"question": "Your ammo counter shows 30. What is \"30\" called?",
"options": [
"An address",
"A value",
"A byte pattern"
],
"answer": 1,
"explain": "The value is what is stored; the address is where. 30 is the value. Something like `0x2A51C0F8` would be its address."
},
{
"kind": "practice",
"title": "See how crowded memory is",
"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": {
"check": "narrowed",
"label": "Search for the number of lives",
"field": "player.lives",
"atMost": 2000
},
"hints": [
"The game shows Lives: 3. Type 3 into the Value box in the Find tab.",
"Press the yellow Search button under the Value box."
],
"done": "Dozens of places hold a 3, and only one of them is your lives. Lesson 2 is about telling which. First, one more thing to see."
},
{
"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** 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": {
"check": "all",
"of": [
{
"check": "restarted",
"label": "Press \"Restart game\" on the practice game"
},
{
"check": "narrowed",
"label": "Search for the number of lives again",
"field": "player.lives",
"atMost": 2000
}
]
},
"hints": [
"\"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."
},
{
"kind": "choice",
"title": "Check yourself",
"text": "",
"question": "You found your health at an address yesterday. Today you start the game and write to the same address. What happens?",
"options": [
"Your health changes, as it did yesterday.",
"You change whatever happens to be there today, which could be anything.",
"The console refuses, because the address is locked."
],
"answer": 1,
"explain": "Nothing stops you, and that is the danger. The address now belongs to something else, and writing over it can do nothing, do something odd, or crash the game. Always find the value again after a restart."
}
]
}
+234
View File
@@ -0,0 +1,234 @@
{
"format": "cheatspecter-lesson",
"version": 1,
"id": "no-number",
"title": "Values you cannot see as a number",
"summary": "A health bar has no number to type. Find it by what it did: went down, went up, stayed the same.",
"minutes": 8,
"games": {
"arena": {
"name": "Specter Arena",
"objects": [
{
"id": "hero",
"fields": [
{
"id": "health",
"type": "u32",
"value": 100,
"offset": 48
},
{
"id": "hits",
"type": "u32",
"value": 0,
"offset": 56
}
]
}
],
"actions": [
{
"id": "hit",
"label": "Take a hit",
"effects": [
{
"field": "hero.health",
"op": "sub",
"by": 9,
"min": 0,
"refill": 100
},
{
"field": "hero.hits",
"op": "add",
"by": 1
}
]
},
{
"id": "heal",
"label": "Drink a potion",
"effects": [
{
"field": "hero.health",
"op": "add",
"by": 15,
"max": 100
}
]
}
],
"hud": [
{
"label": "Health",
"field": "hero.health",
"show": "bar",
"max": 100
}
],
"drifting": 24
},
"shield": {
"name": "Specter Keep",
"objects": [
{
"id": "hero",
"fields": [
{
"id": "health",
"type": "u32",
"value": 250,
"offset": 48
},
{
"id": "hits",
"type": "u32",
"value": 0,
"offset": 56
}
]
}
],
"actions": [
{
"id": "hit",
"label": "Take a hit",
"effects": [
{
"field": "hero.health",
"op": "sub",
"by": 23,
"min": 0,
"refill": 250
},
{
"field": "hero.hits",
"op": "add",
"by": 1
}
]
},
{
"id": "heal",
"label": "Drink a potion",
"effects": [
{
"field": "hero.health",
"op": "add",
"by": 40,
"max": 250
}
]
}
],
"hud": [
{
"label": "Health",
"field": "hero.health",
"show": "bar",
"max": 250
}
],
"drifting": 24
}
},
"steps": [
{
"kind": "read",
"title": "Nothing to type",
"text": "Plenty of games show health as a bar, a ring, or a row of hearts. There is a number behind it, but you cannot see it. Is a full bar 100? 1000? 1? You would be guessing.\n\nYou do not need the number. You know something just as useful: **how it changed**. When you take a hit, it went down. When you heal, it went up. When you stand still, it stayed the same.\n\nSo the search runs like this:\n\n1. Tell the tool to remember **everything**: every value in the game's memory, whatever it is.\n2. Take a hit. Keep only the values that **went down**.\n3. Stand still. Keep only the values that **stayed the same**.\n4. Heal. Keep only the values that **went up**.\n\nEach round throws out most of what is left. \"Stayed the same\" is a real step, not a pause: it removes all the timers and counters that change on their own."
},
{
"kind": "practice",
"title": "Remember everything",
"text": "The practice game shows a **Health** bar and no number.\n\nIn the **Find** tab, open **Value is** and choose **Unknown (I cannot see a number)**. The Value box grays out, because there is nothing to type. Press **Search**.\n\nDo not take a hit yet.",
"game": "arena",
"tool": "find",
"goal": {
"check": "narrowed",
"label": "Start a search with \"Unknown\"",
"field": "hero.health",
"atMost": 100000
},
"hints": [
"\"Value is\" is the second dropdown in the Find tab. It normally says Exactly.",
"Choose \"Unknown (I cannot see a number)\" from that list, then press Search."
],
"done": "Thousands of matches: the tool has taken note of every value there is. On a real game this first step takes a minute or two, and the count is in the hundreds of millions."
},
{
"kind": "practice",
"title": "Went down, stayed the same, went up",
"text": "Now tell the tool what the bar did, one thing at a time. After each thing you do in the game, pick the matching choice under **Value now is** and press **Narrow down**:\n\n- **Take a hit**, then choose **Went down**.\n- Do nothing, then choose **Stayed the same**.\n- **Drink a potion**, then choose **Went up**.\n\nMix them in any order. Keep going until three matches or fewer are left.\n\n> warning: Only ever press Narrow down with the choice that matches what really happened since the last time you pressed it. One wrong answer throws away the value you are looking for, and you have to start over.",
"game": "arena",
"tool": "find",
"goal": {
"check": "narrowed",
"label": "Narrow the list down to 3 matches or fewer",
"field": "hero.health",
"atMost": 3
},
"hints": [
"Press \"Take a hit\" once. In the Find tab set \"Value now is\" to \"Went down\", then press Narrow down.",
"Now, without touching the game, set it to \"Stayed the same\" and press Narrow down. This removes values that drift by themselves.",
"A potion only helps when the bar is not full. Take a hit first, then drink, then choose \"Went up\".",
"If the list becomes empty, one of the answers did not match. Press New search and begin again with Unknown."
],
"done": "There it is, and now you can read the number the game never showed you."
},
{
"kind": "practice",
"title": "Fill it and freeze it",
"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": {
"check": "held",
"label": "Take three hits without losing health",
"field": "hero.health",
"action": "hit",
"times": 3
},
"hints": [
"The padlock is at the end of the row. It freezes the value at what it is right now.",
"If the bar is not full, type the full amount (100 in this game) into the box on the row and press Set. A frozen value can still be set."
],
"done": "A bar with no number, found and frozen. The same steps find anything that visibly changes: a stamina ring, a cooldown, how far a door has opened."
},
{
"kind": "choice",
"title": "Check yourself",
"text": "",
"question": "You took a hit and pressed Narrow down with \"Went up\" by mistake. What now?",
"options": [
"Press Narrow down again with \"Went down\" to correct it.",
"Start a new search. The health value has already been thrown out.",
"Nothing. The tool notices and ignores wrong answers."
],
"answer": 1,
"explain": "Each round keeps only what matches, and what is thrown out is gone. The health did not go up, so it was dropped. Starting again is quick; being careful with each answer is quicker."
},
{
"kind": "practice",
"title": "On your own: the shield",
"text": "Another bar, in another game, with different numbers behind it. Make yourself unhittable.",
"game": "shield",
"tool": "find",
"goal": {
"check": "held",
"label": "Take three hits without the bar going down",
"field": "hero.health",
"action": "hit",
"times": 3
},
"hints": [
"Start with \"Unknown\", then use hits (\"Went down\"), waiting (\"Stayed the same\") and potions (\"Went up\").",
"When a few rows are left, freeze the one whose value drops when you take a hit."
],
"done": "You can now find a value without ever knowing what it is. One thing can still trip you up: a number that is not stored the way you would expect. That is next.",
"fresh": true,
"challenge": true
}
]
}
+298
View File
@@ -0,0 +1,298 @@
{
"format": "cheatspecter-lesson",
"version": 1,
"id": "pointers",
"title": "Pointers: the way back to a value that moves",
"summary": "The value moves on every start, but the game always knows where it is. Follow the same trail.",
"minutes": 10,
"games": {
"vault": {
"name": "Specter Vault",
"objects": [
{
"id": "world",
"static": true,
"fields": [
{
"id": "level",
"type": "u32",
"value": 4,
"offset": 8
},
{
"id": "player",
"type": "pointer",
"to": "player",
"offset": 24
}
]
},
{
"id": "player",
"fields": [
{
"id": "gold",
"type": "u32",
"value": 120,
"offset": 64
},
{
"id": "keys",
"type": "u32",
"value": 2,
"offset": 68
}
]
}
],
"actions": [
{
"id": "chest",
"label": "Open a chest (+25 gold)",
"effects": [
{
"field": "player.gold",
"op": "add",
"by": 25
}
]
}
],
"hud": [
{
"label": "Gold",
"field": "player.gold",
"show": "number"
},
{
"label": "Keys",
"field": "player.keys",
"show": "number"
}
],
"decoys": [
{
"type": "u32",
"value": 120,
"count": 8
}
],
"copies": [
{
"of": "player.gold",
"count": 1,
"follow": "fade"
}
]
},
"tower": {
"name": "Specter Tower",
"objects": [
{
"id": "game",
"static": true,
"fields": [
{
"id": "session",
"type": "pointer",
"to": "session",
"offset": 16
}
]
},
{
"id": "session",
"fields": [
{
"id": "floor",
"type": "u32",
"value": 1,
"offset": 8
},
{
"id": "hero",
"type": "pointer",
"to": "hero",
"offset": 32
}
]
},
{
"id": "hero",
"fields": [
{
"id": "lives",
"type": "u32",
"value": 5,
"offset": 48
},
{
"id": "falls",
"type": "u32",
"value": 0,
"offset": 52
}
]
}
],
"actions": [
{
"id": "fall",
"label": "Fall off",
"effects": [
{
"field": "hero.lives",
"op": "sub",
"by": 1,
"min": 0,
"refill": 5
},
{
"field": "hero.falls",
"op": "add",
"by": 1
}
]
}
],
"hud": [
{
"label": "Lives",
"field": "hero.lives",
"show": "number"
}
],
"decoys": [
{
"type": "u32",
"value": 5,
"count": 30
},
{
"type": "u32",
"value": 4,
"count": 12
}
],
"copies": [
{
"of": "hero.lives",
"count": 1,
"follow": "fade"
}
]
}
},
"steps": [
{
"kind": "read",
"title": "The game has the same problem you do",
"text": "Your gold is somewhere new on every start. You have to search for it. But the game does not search: every time it draws the gold counter, it goes straight there. How?\n\nIt keeps a note. When the game creates the player, it writes down the player's address in a place it can always find. A value that holds the address of something else is called a **pointer**.\n\nUsually it is a short chain:\n\n1. At a **fixed spot** in the game's own data, always the same distance from the start of the game, there is a pointer to \"the world\".\n2. Inside the world, a fixed distance from its start, there is a pointer to \"the player\".\n3. Inside the player, a fixed distance from its start, there is the gold.\n\nThe addresses change on every start. The fixed spot and the distances do not, because they are part of how the game was written. Follow the chain and you arrive at the gold, wherever it is today.\n\nWritten down, a chain looks like `[[game+0x4040]+0x18]+0x40`. Read it from the inside out: take the pointer stored at game+0x4040, add 0x18, take the pointer stored there, add 0x40, and you are at the value."
},
{
"kind": "choice",
"title": "Check yourself",
"text": "",
"question": "In a pointer chain, which parts stay the same every time the game starts?",
"options": [
"The addresses of the player and the world.",
"The fixed starting spot in the game, and the distances added at each step.",
"Nothing. That is why you search again."
],
"answer": 1,
"explain": "The objects land somewhere new each time, so their addresses change. Where the game keeps its first note, and how far into each object the next thing sits, is baked into the game."
},
{
"kind": "practice",
"title": "Find a way back",
"text": "Finding a chain by hand means searching memory for anything that holds your value's address, then for anything that holds *that* address, and so on. The tool does it for you.\n\n1. Find your **Gold** until one match is left. Opening a chest changes it.\n2. On that row, open **…** and choose **Make it survive restarts…**\n3. Leave the depth as it is and press **Search**.\n4. The tool lists the chains it found, shortest first. Press **Use** on the first one.\n5. A form opens with the cheat. Give it a name, such as **Lots of gold**, set the value to **9999**, and press **Save**.",
"game": "vault",
"tool": "find",
"goal": {
"check": "saved",
"label": "Save your gold as a cheat that follows a pointer path",
"type": "value",
"field": "player.gold",
"location": "pointer"
},
"hints": [
"Find tab: search for the Gold number, open a chest, type the new number, Narrow down.",
"The … menu at the end of the row has \"Make it survive restarts…\". In the window that opens, press Search.",
"Press Use next to the first path in the list. In the form, the value box decides what number is held. Press Save at the bottom."
],
"done": "Your cheat no longer says \"this address\". It says \"start here in the game, follow this note, then that one\"."
},
{
"kind": "practice",
"title": "Restart and see it hold",
"text": "Press **Restart game**. The gold is back to what it was, at an address you have never seen.\n\nOpen **Your cheats** and switch your cheat on.",
"game": "vault",
"tool": "cheats",
"goal": {
"check": "all",
"of": [
{
"check": "restarted",
"label": "Restart the practice game"
},
{
"check": "value",
"label": "Have 9999 gold or more in the restarted game",
"field": "player.gold",
"is": "atLeast",
"value": 9999
}
]
},
"hints": [
"After the restart your cheat is still under Your cheats. Its switch is off; turn it on.",
"If the gold does not change, open … and Edit on the cheat and check that its value is 9999 and \"Hold it at this value\" is on."
],
"done": "No searching. The cheat followed the chain to wherever the gold lives in this run of the game."
},
{
"kind": "read",
"title": "Not every chain is real",
"text": "The tool finds chains by looking at memory as it is right now. Some of what it finds are coincidences: a leftover address in a spot the game has stopped using, that happens to lead to your value today and will not tomorrow.\n\nThere is one honest test: **restart the game and see whether the cheat still shows the right value.** Do it two or three times on a real game, ideally after loading a different level or save. A chain that keeps working is real.\n\n- **Shorter chains are more trustworthy.** One or two steps from the game's fixed data is typical. Five steps through unrelated objects is suspicious.\n- **An update can move the starting spot**, just as it moves code. A pointer cheat is tied to a game version in the way a pattern cheat is not.\n\n> tip: For \"infinite\" cheats, switching off the instruction (lesson 6) is usually the better tool. Pointers earn their place when you want to set a value to something: this much gold, that level, a position."
},
{
"kind": "practice",
"title": "On your own: a longer way round",
"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": {
"check": "all",
"of": [
{
"check": "saved",
"label": "Save your lives as a cheat that follows a pointer path",
"type": "value",
"field": "hero.lives",
"location": "pointer"
},
{
"check": "restarted",
"label": "Restart the practice game"
},
{
"check": "value",
"label": "Have 99 lives or more after the restart",
"field": "hero.lives",
"is": "atLeast",
"value": 99
}
]
},
"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…\" 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,
"challenge": true
}
]
}
+201
View File
@@ -0,0 +1,201 @@
{
"format": "cheatspecter-lesson",
"version": 1,
"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": 9,
"games": {
"range": {
"name": "Specter Range",
"objects": [
{
"id": "player",
"fields": [
{
"id": "ammo",
"type": "u32",
"value": 40,
"offset": 40
},
{
"id": "shots",
"type": "u32",
"value": 0,
"offset": 64
}
]
}
],
"actions": [
{
"id": "fire",
"label": "Fire",
"effects": [
{
"field": "player.ammo",
"op": "sub",
"by": 1,
"min": 0,
"refill": 40
},
{
"field": "player.shots",
"op": "add",
"by": 1
}
]
}
],
"hud": [
{
"label": "Ammo",
"field": "player.ammo",
"show": "number"
}
],
"decoys": [
{
"type": "u32",
"value": 40,
"count": 12
},
{
"type": "u32",
"value": 39,
"count": 5
}
],
"copies": [
{
"of": "player.ammo",
"count": 2,
"follow": "fade"
}
]
}
},
"steps": [
{
"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- **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",
"title": "Save a value as a cheat",
"text": "Find your ammo until one match is left. Then open the **…** menu on the row and choose **Save to your cheats**.\n\nA small form opens. It is already filled in: the address, the type, the value to hold. Press **Save**.",
"game": "range",
"tool": "find",
"goal": {
"check": "saved",
"label": "Save your ammo to Your cheats",
"type": "value",
"field": "player.ammo"
},
"hints": [
"Find the ammo first: search, fire, narrow down.",
"The … menu is at the end of the row. \"Save to your cheats\" is in the middle of it.",
"In the form that opens, the Save button is at the bottom."
],
"done": "It is in Your cheats now, as a value that is held at the number you saved, with a switch."
},
{
"kind": "practice",
"title": "Give it a proper name",
"text": "The cheat was saved with a name like \"Value at 0x2A51C0F8\". That will mean nothing next week.\n\nIn **Your cheats**, open the **…** menu on the cheat and choose **Edit**. Change the name to what it does, such as **Infinite ammo**, and save.",
"game": "range",
"tool": "cheats",
"goal": {
"check": "saved",
"label": "Rename the cheat to say what it does",
"type": "value",
"field": "player.ammo",
"named": true
},
"hints": [
"The … button is at the right end of the cheat's row in Your cheats.",
"In the form, the name is the first box. Replace it and press Save."
],
"done": "Named. While you were in that form you may have seen the rest: the value to hold, a hotkey, and where the cheat points."
},
{
"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**, and press **Create**.",
"game": "range",
"tool": "cheats",
"goal": {
"check": "saved",
"label": "Put the cheat in a group",
"type": "value",
"field": "player.ammo",
"named": true,
"grouped": true
},
"hints": [
"Your cheats tab, … on the cheat, \"Put in a group…\".",
"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 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",
"title": "Check yourself",
"text": "",
"question": "You save your list to a file. It has \"Infinite ammo\" made by freezing an address, and \"Free potions\" made by disabling an instruction. What ends up in the file?",
"options": [
"Both.",
"Only \"Free potions\".",
"Only \"Infinite ammo\"."
],
"answer": 1,
"explain": "The frozen address means nothing in the next run of the game, so it is left out. The disabled instruction can be found again, so it is kept. To make \"Infinite ammo\" shareable, make it the lesson 6 way."
},
{
"kind": "practice",
"title": "On your own: one worth sharing",
"text": "Make an \"Infinite ammo\" that would survive being saved to a file: one that switches off the instruction, properly named and in a group.",
"game": "range",
"tool": "find",
"goal": {
"check": "saved",
"label": "Have a named cheat that stops firing from using ammo, in a group",
"type": "patch",
"action": "fire",
"named": true,
"grouped": true
},
"hints": [
"Find the ammo, then … and \"Find what writes to this\". Fire, then \"Disable it\".",
"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.",
"challenge": true
}
]
}
+221
View File
@@ -0,0 +1,221 @@
{
"format": "cheatspecter-lesson",
"version": 1,
"id": "structures",
"title": "Structures: the things next to your value",
"summary": "Health, ammo and position live side by side, and enemies are laid out the same way as you.",
"minutes": 10,
"games": {
"duel": {
"name": "Specter Duel",
"objects": [
{
"id": "you",
"fields": [
{
"id": "health",
"type": "u32",
"value": 100,
"offset": 16
},
{
"id": "armor",
"type": "u32",
"value": 35,
"offset": 20
},
{
"id": "team",
"type": "u32",
"value": 1,
"offset": 24
}
]
},
{
"id": "guard",
"fields": [
{
"id": "health",
"type": "u32",
"value": 60,
"offset": 16
},
{
"id": "armor",
"type": "u32",
"value": 10,
"offset": 20
},
{
"id": "team",
"type": "u32",
"value": 2,
"offset": 24
}
]
}
],
"actions": [
{
"id": "hurt",
"label": "Guard hits you",
"effects": [
{
"field": "you.health",
"op": "sub",
"by": 12,
"min": 0,
"refill": 100,
"site": "damage"
}
]
},
{
"id": "strike",
"label": "You hit the guard",
"effects": [
{
"field": "guard.health",
"op": "sub",
"by": 12,
"min": 0,
"site": "damage"
}
]
}
],
"hud": [
{
"label": "Your health",
"field": "you.health",
"show": "number"
},
{
"label": "Your armor",
"field": "you.armor",
"show": "number"
},
{
"label": "Guard health",
"field": "guard.health",
"show": "number"
}
],
"decoys": [
{
"type": "u32",
"value": 100,
"count": 8
},
{
"type": "u32",
"value": 88,
"count": 3
}
]
}
},
"steps": [
{
"kind": "read",
"title": "Values come in bundles",
"text": "A game does not scatter your health, armor and ammo across memory. It keeps everything about one thing together, in a block called a **structure** (or an object): the player's block, an enemy's block, a car's block.\n\nInside a block, each value has its place, always the same distance from the start: health 16 bytes in, armor 20 bytes in, team 24 bytes in. That is what the `+0x28` meant in \"sub dword ptr [rbx+0x28], 1\": rbx points at the start of the block, and 0x28 is how far in the ammo sits.\n\nTwo useful things follow.\n\n# Find one value and you have found its neighbors\n\nOnce you know where your health is, your armor is very likely a few bytes away. No searching needed. You look around.\n\n# Things of the same kind are built the same\n\nThe game has one blueprint for \"a fighter\" and uses it for you and for every enemy. Different blocks, same layout. And usually the **same code** works on all of them: one \"take damage\" for everybody, told each time whose block to work on.\n\nThat second point is about to matter."
},
{
"kind": "practice",
"title": "Look around your health",
"text": "1. Find **Your health**. Getting hit by the guard changes it.\n2. On the row, open **…** and choose **Show in Memory**.\n\nThe Memory tab shows the bytes of the game around your health, with your health marked. Click the bytes just after it: the panel below says what they mean as a number. Four bytes after your health starts, you will find a number you recognize from the game's screen.\n\nClick its first byte. Under **Change it as a number**, leave the type on 4 bytes, type **500** and press **Write**.",
"game": "duel",
"tool": "find",
"goal": {
"check": "value",
"label": "Give yourself 500 armor or more, without searching for armor",
"field": "you.armor",
"is": "atLeast",
"value": 500
},
"hints": [
"Find tab: search for Your health, let the guard hit you, type the new number, Narrow down.",
"In the Memory tab each pair of characters is one byte, and your health takes four of them. The armor is the next four.",
"Your health is marked in the grid. Click the byte four places after its first byte. The panel underneath then shows \"4 bytes\" as 35, which is your armor. Type 500 in the box under \"Change it as a number\" and press Write."
],
"done": "You changed your armor without ever searching for it. On a real game, the bytes around a value you found are the first place to look for the next one."
},
{
"kind": "practice",
"title": "One instruction for everyone",
"text": "Make yourself invincible the lesson 6 way: on your health row, **…** and **Find what writes to this**, get hit, and **Disable it**.\n\nThen hit the guard a couple of times and watch **Guard health**.",
"game": "duel",
"tool": "find",
"goal": {
"check": "all",
"of": [
{
"check": "off",
"label": "Switch off the code that lowers your health",
"action": "hurt"
},
{
"check": "pressed",
"label": "Now hit the guard, twice",
"action": "strike",
"times": 2
}
]
},
"hints": [
"If your health is no longer in the Find tab, find it again: search, get hit, narrow down.",
"While the Code tab is watching, press \"Guard hits you\". Then press \"Disable it\" on the instruction that appears.",
"Now press \"You hit the guard\" twice and look at the Guard health number."
],
"done": "The guard is invincible too. There is one \"take damage\" instruction, and it serves you and him alike. Switching it off switched off damage for everyone. This is the most common surprise in cheat making: \"god mode\" that also makes every enemy immortal."
},
{
"kind": "choice",
"title": "Check yourself",
"text": "",
"question": "Why did disabling one instruction protect both you and the guard?",
"options": [
"The tool disabled two instructions.",
"Both fighters are damaged by the same code, which is told whose block to work on each time.",
"The guard copies your health."
],
"answer": 1,
"explain": "One blueprint, one piece of code, many blocks. The instruction does not know or care whose health it is lowering."
},
{
"kind": "practice",
"title": "On your own: only you",
"text": "Fix it. You should take no damage, and the guard should go down when you hit him.\n\nYou have two tools that can tell you and the guard apart, because they work on **your** block rather than on the shared code.",
"game": "duel",
"tool": "cheats",
"goal": {
"check": "all",
"of": [
{
"check": "held",
"label": "Take three hits without losing health",
"field": "you.health",
"action": "hurt",
"times": 3
},
{
"check": "value",
"label": "Bring the guard down to 0 health",
"field": "guard.health",
"is": "atMost",
"value": 0
}
]
},
"hints": [
"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."
],
"done": "Freezing your own health leaves the shared code alone, so the guard still takes damage. The cleaner fix is to change the code so it checks who is being hit, and that is the next lesson.",
"challenge": true
}
]
}
+373
View File
@@ -0,0 +1,373 @@
// Live check of the engine helpers (any engine) against a real console.
//
// It only READS the game's memory: plain reads, pattern searches and the
// memory map. It never writes, never attaches the debugger, never launches
// anything and never touches files on the console. It does not start, stop
// or switch games: it works on whatever is running and stops at once when
// `--title` names a different game.
//
// NOT YET RUN ON HARDWARE. Written while no game could be started on the
// console; everything it prints for a real game is new information. Keep the
// output: the engine helpers' report has a table waiting for it.
//
// Bundle it and run the bundle with plain Node on a machine that can reach
// the console (see the brief, "The real PS5"):
//
// node scripts/dev-bundle.mjs live/engine-check.ts engine-check.mjs
// node engine-check.mjs [ws://CONSOLE:8744/ws] [options] 2>&1 | grep -v Warning
//
// Options (all optional):
// --title PPSA12345 stop unless this is the running game (recommended)
// --class A,B class-name searches to run (default: Weapon,Character,Player,Money,Wallet,Health)
// --objects NAME list live objects of classes containing NAME (default: the first --class with objects)
// --known 0x1038,0x16D0 offsets inside the listed object's class to name. For Ready or Not's weapon
// (PPSA18605) the known ones are used without asking:
// 0x148 and 0xAC0 owning character, 0x1038 fire interval (float seconds),
// 0x16D0 magazine array, 0x16D8 magazine count, 0x16E0 current magazine index
// --method TEXT function search to run (default "CheatManager.")
// --settings TEXT hidden-settings search to run besides the well-known ones (default "r.Shadow")
// --skip a,b leave steps out: identify, classes, objects, methods, settings, hidden, uses, bulk
// --budget SECONDS start no further long step after this long (default 150; the shell on Tiago's
// computer ends a command at 180). Run again with --skip for what was left out.
//
// What to look for in the output:
// * "detectEngine" names the right engine, and how long it took.
// * Unreal: the layout lines. For Ready or Not v01.001.008 the name pool should be at image offset
// 0x7310848 (or 8 after it) and the object array at 0x7352E30 (or 0x10 after it), with 0x14-byte
// slots and object offsets index 0xC, class 0x10, name 0x18, outer 0x20.
// * "known offset" lines name the fields at offsets found by hand earlier. That is the proof the
// reflection data is read correctly.
// * Hidden settings: t.MaxFPS should be a float, usually 0, 30, 60 or 120. r.* values should look
// sane (r.ScreenPercentage 0..200, r.VSync 0 or 1). "by kind" should be mostly int and float,
// with a few hundred "unknown" (the engine's console commands).
// * "bulk read" says whether the console's read-many command works and how much faster it is.
import { pathToFileURL } from 'node:url';
import { Session } from '../src/lib/session.ts';
import { createGameContext, describeAddress, imageBase, loadedModules } from '../src/lib/locations.ts';
import type { GameContext } from '../src/lib/locations.ts';
import { PROT } from '../src/lib/ps5debug.ts';
import { fmtAddr, fmtBytes } from '../src/lib/bytes.ts';
import type { ClassInfo, EngineHelper, FieldInfo, ObjectInfo } from '../src/lib/engine.ts';
import { identifyEngine } from '../src/lib/engine-detect.ts';
import { SETTING_SHORTCUTS, detectEngine, hasSettings, helperNotes } from '../src/lib/engines.ts';
import { decodeField, objectSpan } from '../src/lib/engine-values.ts';
import { HIDDEN_TOPICS, findHiddenSwitches, whatUses } from '../src/lib/hidden.ts';
import { MemoryReader } from '../src/lib/rtti.ts';
import type { ConsoleVariable } from '../src/lib/unreal-cvars.ts';
export interface EngineCheckOptions {
classes: string[];
objects?: string;
known: number[];
method: string;
settings: string;
skip: Set<string>;
/** Seconds after which no further long step is started. */
budget: number;
/** Uses the demo console's made-up helper. Only for trying this script against the mock. */
demo?: boolean;
}
type Print = (line?: string) => void;
const RON = 'PPSA18605';
const RON_KNOWN: Record<number, string> = {
0x148: 'owning character',
0xac0: 'owning character (second)',
0x1038: 'fire interval (float seconds)',
0x16d0: 'magazine array pointer',
0x16d8: 'magazine count',
0x16e0: 'current magazine index',
};
const hex = (n: number): string => '0x' + n.toString(16).toUpperCase();
const fieldLine = (f: FieldInfo): string => `+${hex(f.offset).padEnd(7)} ${f.name}: ${f.type}${f.size ? ` (${f.size} bytes)` : ''}${f.declaredIn ? ` [from ${f.declaredIn}]` : ''}`;
const objectLine = (o: ObjectInfo): string => `${fmtAddr(o.address)} ${o.className} ${o.name ?? ''}`;
export function defaultOptions(): EngineCheckOptions {
return { classes: ['Weapon', 'Character', 'Player', 'Money', 'Wallet', 'Health'], known: [], method: 'CheatManager.', settings: 'r.Shadow', skip: new Set(), budget: 150 };
}
/**
* Runs every check against the game behind `ctx` and prints what it finds.
* Each step is timed and stands alone: one that fails is reported and the
* rest still run. Returns the helper, for a caller that wants to go on.
*/
export async function runEngineCheck(ctx: GameContext, titleId: string, opts: EngineCheckOptions, print: Print): Promise<EngineHelper | null> {
const started = Date.now();
const left = (): number => opts.budget * 1000 - (Date.now() - started);
const step = async <T>(id: string, label: string, fn: () => Promise<T>): Promise<T | undefined> => {
if (opts.skip.has(id)) {
print(`\n[${label}: skipped]`);
return undefined;
}
if (left() <= 0) {
print(`\n[${label}: not started, out of time. Run again with the earlier steps in --skip.]`);
return undefined;
}
const t = Date.now();
try {
const result = await fn();
print(`[${label}: ${Date.now() - t} ms]`);
return result;
} catch (e) {
print(`[${label}: FAILED after ${Date.now() - t} ms: ${e instanceof Error ? (e.stack ?? e.message) : String(e)}]`);
return undefined;
}
};
// ---- the memory map ----
const maps = await ctx.maps(true);
const base = await imageBase(ctx);
const modules = await loadedModules(ctx);
const writable = maps.filter((m) => m.prot & PROT.WRITE && m.prot & PROT.READ).reduce((n, m) => n + Number(m.end - m.start), 0);
print(`memory map: ${maps.length} entries, ${modules.length} modules, ${fmtBytes(writable)} writable, image base ${fmtAddr(base)}`);
const own = modules.find((m) => m.module === undefined);
if (own) {
for (const part of maps.filter((x) => x.start >= own.start && x.end <= own.end)) {
print(` ${fmtAddr(part.start)}-${fmtAddr(part.end)} ${part.prot & PROT.READ ? 'r' : '-'}${part.prot & PROT.WRITE ? 'w' : '-'}${part.prot & PROT.EXEC ? 'x' : '-'} ${fmtBytes(Number(part.end - part.start)).padStart(9)} ${part.name}`);
}
}
print(` other modules: ${modules.filter((m) => m.module !== undefined).slice(0, 40).map((m) => m.module).join(', ')}`);
// ---- which engine ----
print('\n== identifyEngine ==');
const guess = await step('identify', 'identifyEngine', () => identifyEngine(ctx, { budgetMs: 8000 }));
if (guess) {
print(`engine: ${guess.engine}`);
for (const line of guess.evidence) print(` ${line}`);
}
print('\n== detectEngine ==');
const report = await step('detect', 'detectEngine', () => detectEngine(ctx, { demo: opts.demo, onProgress: (text) => print(` ... ${text}`) }));
if (!report) return null;
for (const line of report.steps) print(` ${line}`);
const helper = report.helper;
if (!helper) {
print('No helper: nothing by name can be read from this game. The hidden-switch search below still applies.');
} else {
print(`helper: ${helper.engine}: ${helper.label}`);
const layout = (helper as { describeLayout?: () => string | string[] }).describeLayout?.();
if (layout) for (const line of Array.isArray(layout) ? layout : layout.split('\n')) print(` ${line}`);
const L = (helper as { layout?: { names?: { pool?: bigint }; objects?: { array?: bigint } } }).layout;
if (L?.names?.pool !== undefined && L.objects?.array !== undefined) {
print(` as image offsets: name pool counter ${fmtAddr(L.names.pool - base)}, object array ${fmtAddr(L.objects.array - base)}`);
if (titleId === RON) print(' expected for Ready or Not v01.001.008: name pool 0x7310848 (the counter is at that address or 8 after), object array 0x7352E30 (or 0x10 after)');
}
for (const line of helperNotes(helper)) print(` note: ${line}`);
}
// ---- classes ----
let listed: ObjectInfo[] = [];
let listedClass: ClassInfo | null = null;
if (helper) {
print('\n== classes ==');
const withHits: string[] = [];
for (const q of opts.classes) {
const found = await step('classes', `findClasses('${q}')`, () => helper.findClasses(q, 25));
if (!found) continue;
if (found.length) withHits.push(q);
print(` ${found.length} classes: ${found.slice(0, 25).map((c) => `${c.name}${c.parent ? ` : ${c.parent}` : ''}${c.size ? ` (${hex(c.size)})` : ''}`).join(', ')}`);
}
print('\n== objects ==');
for (const q of opts.objects ? [opts.objects] : withHits) {
const found = await step('objects', `findObjects('${q}')`, () => helper.findObjects(q, 20));
if (!found) continue;
print(` ${found.length} objects`);
for (const o of found.slice(0, 12)) print(` ${objectLine(o)}`);
const live = found.filter((o) => !(o.name ?? '').startsWith('Default__'));
if (!listed.length && live.length) listed = live;
if (listed.length && !opts.objects) break;
}
const sample = listed[0];
if (sample) {
const described = await step('objects', `describeObject(${fmtAddr(sample.address)})`, () => helper.describeObject(sample.address));
print(` -> ${described ? objectLine(described) : 'null (WRONG: this address was just listed as an object)'}`);
const inside = await step('objects', 'describeObject(8 bytes into it; null is right)', () => helper.describeObject(sample.address + 8n));
print(` -> ${inside ? `${objectLine(inside)} (WRONG: that is the middle of an object)` : 'null'}`);
listedClass = (await step('objects', `classInfo(${sample.className}), first time`, () => helper.classInfo(sample.address))) ?? null;
await step('objects', `classInfo(${sample.className}), again`, () => helper.classInfo(sample.address));
if (listedClass) {
const info = listedClass;
print(` ${info.fullName}: parent ${info.parent ?? '-'}, size ${info.size ? hex(info.size) : '?'}, ${info.fields.length} fields`);
const bytes = await ctx.dbg().read(ctx.pid, sample.address, objectSpan(info.fields, info.size));
const withValue = (f: FieldInfo): string => {
const v = decodeField(f, bytes);
return `${fieldLine(f)}${v ? ` = ${v.text}` : ''}`;
};
for (const f of info.fields.slice(0, 30)) print(` ${withValue(f)}`);
if (info.fields.length > 30) print(` ... and ${info.fields.length - 30} more`);
const known = opts.known.length ? Object.fromEntries(opts.known.map((o) => [o, 'given with --known'])) : titleId === RON && /weapon|gun|rifle|pistol/i.test(info.name + (info.parent ?? '')) ? RON_KNOWN : {};
for (const [off, what] of Object.entries(known)) {
const at = Number(off);
const exact = info.fields.find((f) => !f.isStatic && f.offset === at);
const holder = info.fields.filter((f) => !f.isStatic && f.offset <= at && at < f.offset + (f.size ?? 1)).pop();
print(` known offset ${hex(at)} (${what}): ${exact ? withValue(exact) : holder ? `inside ${withValue(holder)}` : 'no field covers it'}`);
}
}
} else print(' No live objects of those classes right now. Load into a level and run again, or pass --objects NAME.');
for (const name of ['Actor', 'Pawn', 'Character', 'PlayerController', 'Vector', 'Guid', 'GameObject', 'Transform', 'MonoBehavior']) {
const c = await step('classes', `classInfo('${name}')`, () => helper.classInfo(name));
if (c) print(` ${c.fullName}: parent ${c.parent ?? '-'}, size ${c.size ? hex(c.size) : '?'}, ${c.fields.length} fields; first: ${c.fields.slice(0, 5).map((f) => `${f.name}@${hex(f.offset)}:${f.type}`).join(' | ')}`);
else if (c === null) print(` ${name}: not in this game`);
}
// ---- functions ----
if (helper.findMethods) {
print('\n== functions ==');
const methods = await step('methods', `findMethods('${opts.method}')`, () => helper.findMethods!(opts.method, 60));
if (methods) {
print(` ${methods.length} functions`);
for (const m of methods.slice(0, 60)) print(` ${(await describeAddress(ctx, m.address)).padEnd(18)} ${m.name} ${m.signature ?? ''}`);
}
const cheat = (helper as { cheatManager?: () => Promise<{ classes: ClassInfo[]; objects: ObjectInfo[]; functions: unknown[] }> }).cheatManager;
if (cheat) {
const c = await step('methods', 'cheatManager()', () => cheat.call(helper));
if (c) print(` CheatManager classes: ${c.classes.map((x) => x.fullName).join(', ') || 'none'}; live objects: ${c.objects.map(objectLine).join('; ') || 'none'}; native functions: ${c.functions.length}`);
}
} else print('\n== functions == not offered by this helper');
// ---- hidden settings ----
if (hasSettings(helper)) {
print('\n== hidden settings (read only; nothing is written) ==');
const show = (v: ConsoleVariable): string =>
`${v.name} = ${v.value === null ? '(no value read)' : JSON.stringify(v.value)} [${v.kind}] value at ${v.address === null ? '-' : fmtAddr(v.address)}${v.mirrors.length ? ` + ${v.mirrors.map(fmtAddr).join(', ')}` : ''} object ${fmtAddr(v.object)}`;
const well = await step('settings', 'well-known settings (first call finds the table)', () =>
helper.consoleVariablesNamed(
SETTING_SHORTCUTS.map((s) => s.name),
{ onProgress: (text) => print(` ... ${text}`) },
),
);
for (const v of well ?? []) print(` ${show(v)}${v.help ? `\n ${v.help.slice(0, 110)}` : ''}`);
if (well && well.length === 0) print(' NONE FOUND: the table of settings was not located, or holds none of the well-known names.');
const some = await step('settings', `consoleVariables('${opts.settings}')`, () => helper.consoleVariables(opts.settings, 15));
for (const v of some ?? []) print(` ${show(v)}`);
const all = await step('settings', 'every setting', () => helper.consoleVariables('', 200000));
if (all) {
const kinds: Record<string, number> = {};
for (const v of all) kinds[v.kind] = (kinds[v.kind] ?? 0) + 1;
print(` ${all.length} in all; by kind: ${Object.entries(kinds).map(([k, n]) => `${k} ${n}`).join(', ')}`);
const inImage = all.filter((v) => v.address !== null && v.address >= base && v.address < base + 0x40000000n).length;
print(` ${inImage} keep their value in the game's own data (a lasting "game+offset" place for a cheat)`);
}
}
}
// ---- hidden switches ----
print('\n== hidden switches ==');
const stop = new AbortController();
const timer = setTimeout(() => stop.abort(), Math.max(1000, Math.min(60_000, left())));
const hidden = await step('hidden', 'findHiddenSwitches', () => findHiddenSwitches(ctx, { signal: stop.signal, perTopic: 60 }));
clearTimeout(timer);
if (hidden) {
print(` ${hidden.hits.length} texts in ${fmtBytes(hidden.bytesSearched)} of ${fmtBytes(hidden.bytesTotal)}${hidden.canceled ? ' (stopped for time)' : ''}${hidden.capped ? ' (some topics cut short)' : ''}`);
for (const topic of Object.keys(HIDDEN_TOPICS) as (keyof typeof HIDDEN_TOPICS)[]) {
const of = hidden.hits.filter((h) => h.topic === topic);
if (!of.length) continue;
print(` ${HIDDEN_TOPICS[topic]} (${of.length}):`);
for (const h of of.slice(0, 12)) print(` ${fmtAddr(h.address)} ${h.encoding === 'utf16' ? 'wide ' : ''}${JSON.stringify(h.text)}${h.repeats ? ` x${h.repeats + 1}` : ''}`);
}
const lead = hidden.hits.find((h) => h.topic === 'cheats') ?? hidden.hits[0];
if (lead) {
const uses = await step('uses', `whatUses(${JSON.stringify(lead.text)})`, () => whatUses(ctx, lead.address));
if (uses) {
print(` code: ${uses.codeProblem ? `could not be searched (${uses.codeProblem})` : (await Promise.all(uses.code.slice(0, 8).map((a) => describeAddress(ctx, a)))).join(', ') || 'none'}`);
print(` data pointers: ${uses.data.map(fmtAddr).join(', ') || 'none'}`);
}
}
}
// ---- does the bulk read help ----
print('\n== bulk read ==');
await step('bulk', 'bulk read comparison', async () => {
// A few thousand small pieces spread over the first large writable region, as following pointers does.
const big = maps.filter((m) => m.prot & PROT.READ && m.prot & PROT.WRITE && m.end - m.start >= 0x400000n).sort((a, b) => Number(b.end - b.start - (a.end - a.start)))[0];
if (!big) {
print(' no writable region of 4 MB or more to try it on');
return;
}
const span = Number(big.end - big.start > 0x10000000n ? 0x10000000n : big.end - big.start);
const pieces = Array.from({ length: 2000 }, (_, i) => ({ address: big.start + BigInt(Math.floor(((i * 7919) % 2000) * (span / 2000)) & ~0xf), length: 32 }));
const withBulk = new MemoryReader(ctx);
let t = Date.now();
const a = await withBulk.readMany(pieces);
const bulkMs = Date.now() - t;
const without = new MemoryReader(ctx);
without.bulk = 'broken';
t = Date.now();
const b = await without.readMany(pieces);
const plainMs = Date.now() - t;
let same = 0;
for (let i = 0; i < pieces.length; i++) if (a[i] && b[i] && a[i]!.every((x, k) => x === b[i]![k])) same++;
print(` 2000 pieces of 32 bytes over ${fmtBytes(span)}: read-many command ${withBulk.bulk} in ${bulkMs} ms (${withBulk.roundTrips} exchanges); plain reads ${plainMs} ms (${without.roundTrips} exchanges, ${fmtBytes(without.bytesFetched)})`);
print(` ${same} of ${pieces.length} pieces identical both ways (a few may differ: the game is running)`);
});
print(`\n[total ${Date.now() - started} ms]`);
return helper;
}
function parseArgs(argv: string[]): { url?: string; title?: string; opts: EngineCheckOptions } {
const opts = defaultOptions();
let url: string | undefined;
let title: string | undefined;
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
const value = (): string => {
const v = argv[++i];
if (v === undefined) throw new Error(`${a} needs a value`);
return v;
};
if (a === '--title') title = value();
else if (a === '--class') opts.classes = value().split(',').map((s) => s.trim()).filter(Boolean);
else if (a === '--objects') opts.objects = value();
else if (a === '--known') opts.known = value().split(',').map((s) => Number(s.trim())).filter((n) => Number.isInteger(n) && n >= 0);
else if (a === '--method') opts.method = value();
else if (a === '--settings') opts.settings = value();
else if (a === '--skip') opts.skip = new Set(value().split(',').map((s) => s.trim()));
else if (a === '--budget') opts.budget = Number(value()) || opts.budget;
else if (a === '--demo') opts.demo = true;
else if (/^wss?:\/\//.test(a)) url = a;
else throw new Error(`Unknown option "${a}"`);
}
return { url, title, opts };
}
async function main(): Promise<void> {
const { url, title, opts } = parseArgs(process.argv.slice(2));
const s = await Session.connect(null, url ?? 'ws://192.168.1.50:8744/ws');
try {
const game = await s.currentGame();
if (!game) {
console.log('STOP: no game is running. Nothing was read. (This script never starts one.)');
process.exitCode = 2;
return;
}
if (title && game.titleId !== title) {
console.log(`STOP: the running game is ${game.titleId}, not ${title}. Nothing was read.`);
process.exitCode = 2;
return;
}
console.log(`game ${game.titleId} v${game.version} pid ${game.pid}`);
const ctx = createGameContext({ pid: game.pid, dbg: () => s.main, scanDbg: () => s.scan, x86: null as never });
await runEngineCheck(ctx, game.titleId, opts, (line = '') => console.log(line));
} finally {
s.close();
}
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
main().then(
() => process.exit(process.exitCode ?? 0),
(e) => {
console.error(e);
process.exit(1);
},
);
}
+440
View File
@@ -0,0 +1,440 @@
// Live check of the Unity (IL2CPP) helper against a real console.
//
// It only READS the game's memory. It never writes, attaches the debugger,
// launches anything or touches files on the console.
//
// Bundle it and run the bundle with plain Node on a machine that can reach
// the console (see the brief, "The real PS5"):
//
// node scripts/dev-bundle.mjs live/unity-check.ts unity-check.mjs
// node unity-check.mjs [ws://CONSOLE:8744/ws] [options] 2>&1 | grep -v Warning
//
// Options (all optional; with none it reports detection, layout and metadata):
// --object 0xADDR describe the object at this address: class, parents, fields with values
// --address 0xADDR an address inside an object (where a value search found the money, say):
// names the object and field it belongs to, then describes that object
// --hint LIST find the object first. LIST is offset=value pairs, e.g. for Chef Life's
// save state (money 9999 in a nullable int, level 20, 99 tickets):
// --hint 0x48=9999,0x4C=1:u8,0x70=20,0xC0=99
// Types: i32 (default), u8, i64, float.
// --method Class.method list matching methods with their code addresses (comma-separated for several;
// "Class." lists every method of the class)
// --near 0xOFF,0xOFF offsets into the Unity module; for each, name the method whose code starts
// closest before it, among every class the game has loaded (can take a while)
// --class NAME find classes whose name contains NAME, and describe an exact match
// --objects NAME list live objects of the class NAME (one pass over writable memory)
// --state FILE remember the layout, and the object found, in FILE between runs, so a later
// run can skip the discovery and the hint search
// --limit N how many hint matches / objects / methods to print (default 10)
// --budget SECONDS stop starting long steps after this long (default 150; the shell on Tiago's
// computer ends a command at 180)
import fs from 'node:fs';
import { pathToFileURL } from 'node:url';
import { Session } from '../src/lib/session.ts';
import { createGameContext } from '../src/lib/locations.ts';
import type { GameContext } from '../src/lib/locations.ts';
import { COMPARE, PROT, VALUE_TYPE } from '../src/lib/ps5debug.ts';
import { scannableRegions } from '../src/lib/scanner.ts';
import { bytesToHex, fmtAddr, fmtBytes, view } from '../src/lib/bytes.ts';
import { createUnityHelper, isUnityModule } from '../src/lib/il2cpp.ts';
import type { UnityClassInfo, UnityField, UnityHelper, UnityMethod } from '../src/lib/il2cpp.ts';
export interface CheckOptions {
object?: bigint;
address?: bigint;
hint?: string;
methods: string[];
near: bigint[];
className?: string;
objects?: string;
state?: string;
limit: number;
/** Seconds after which no further long step is started. */
budget: number;
}
type Print = (line?: string) => void;
interface HintEntry {
offset: number;
bytes: Uint8Array;
text: string;
}
function parseHint(text: string): HintEntry[] {
const out: HintEntry[] = [];
for (const part of text.split(',')) {
const m = /^\s*(0x[0-9a-f]+|\d+)\s*=\s*(-?[0-9.x a-f]+?)\s*(?::\s*(u8|i32|i64|float))?\s*$/i.exec(part);
if (!m) throw new Error(`Cannot read the hint "${part}". Write it as offset=value, for example 0x48=9999 or 0x4C=1:u8.`);
const type = (m[3] ?? 'i32').toLowerCase();
const size = type === 'u8' ? 1 : type === 'i64' ? 8 : 4;
const bytes = new Uint8Array(size);
const dv = view(bytes);
if (type === 'float') dv.setFloat32(0, Number(m[2]), true);
else if (type === 'u8') dv.setUint8(0, Number(m[2]));
else if (type === 'i64') dv.setBigInt64(0, BigInt(m[2]), true);
else dv.setInt32(0, Number(m[2]), true);
out.push({ offset: Number(m[1]), bytes, text: part.trim() });
}
return out;
}
/**
* Finds an 8-byte-aligned place where every hinted offset holds its value and
* which `isObject` accepts: a pass over writable memory for one 4-byte value,
* with a small read at each hit to check the rest. Stops at the first.
*/
async function findByHint(ctx: GameContext, hint: HintEntry[], limit: number, deadline: number, isObject: (address: bigint) => Promise<string | null>, print: Print): Promise<bigint | undefined> {
// Search for the 4-byte value least likely to be common: the largest number.
const anchor = [...hint].filter((h) => h.bytes.length === 4).sort((a, b) => view(b.bytes).getUint32(0, true) - view(a.bytes).getUint32(0, true))[0];
if (!anchor) throw new Error('The hint needs at least one 4-byte value to search for.');
const span = Math.max(...hint.map((h) => h.offset + h.bytes.length));
const regions = scannableRegions(await ctx.maps(true));
const total = regions.reduce((n, r) => n + r.length, 0);
print(` Searching ${fmtBytes(total)} of writable memory for the value at +0x${anchor.offset.toString(16)} (${anchor.text}) ...`);
let found: bigint | undefined;
let matches = 0;
let hits = 0;
let checked = 0;
let scanned = 0;
for (const region of regions) {
if (Date.now() > deadline) {
print(` OUT OF TIME after ${fmtBytes(scanned)} of ${fmtBytes(total)}. Run again with a larger --budget, or a rarer value.`);
break;
}
scanned += region.length;
const dbg = ctx.scanDbg();
if (!dbg.authed) await dbg.auth();
const here: bigint[] = [];
await dbg.scanStart(
{ pid: ctx.pid, address: region.start, length: region.length, valueType: VALUE_TYPE.U32, compareType: COMPARE.EXACT, alignment: 4, value: anchor.bytes },
(block) => {
const dv = view(block.records);
for (let i = 0; i < block.count; i++) here.push(region.start + BigInt(dv.getUint32(i * 8, true)));
},
);
hits += here.length;
for (const h of here) {
const base = h - BigInt(anchor.offset);
if (base < 0x10000n || (base & 7n) !== 0n) continue;
if (checked++ >= 20000) break;
const raw = await ctx.dbg().read(ctx.pid, base, span);
if (!hint.every((e) => e.bytes.every((b, i) => raw[e.offset + i] === b))) continue;
matches++;
const className = await isObject(base);
print(` match at ${fmtAddr(base)}: ${className ? `a ${className}` : 'not a Unity object'}`);
if (className) found = base;
if (found !== undefined || matches >= limit) break;
}
if (found !== undefined || matches >= limit || checked >= 20000) break;
}
print(` Scanned ${fmtBytes(scanned)}. Places holding that value: ${hits}. Of those, 8-byte aligned and checked: ${checked}. Matching every hint: ${matches}.`);
if (checked >= 20000) print(' Stopped after 20000 checks. Use a rarer value in the hint (change the money in the game to an unusual number).');
return found;
}
function hexBytes(bytes: Uint8Array): string {
return bytesToHex(bytes, ' ');
}
/** A field's current value as text, from the bytes at its offset. */
async function showValue(ctx: GameContext, helper: UnityHelper, f: UnityField, bytes: Uint8Array): Promise<string> {
const dv = view(bytes);
const need = (n: number): boolean => bytes.length >= n;
switch (f.type) {
case 'bool':
return need(1) ? (bytes[0] === 0 ? 'false' : bytes[0] === 1 ? 'true' : `not a bool (byte ${bytes[0]})`) : '?';
case 'byte':
return String(dv.getUint8(0));
case 'sbyte':
return String(dv.getInt8(0));
case 'short':
return String(dv.getInt16(0, true));
case 'ushort':
case 'char':
return String(dv.getUint16(0, true));
case 'int':
return need(4) ? String(dv.getInt32(0, true)) : '?';
case 'uint':
return need(4) ? String(dv.getUint32(0, true)) : '?';
case 'long':
return need(8) ? String(dv.getBigInt64(0, true)) : '?';
case 'ulong':
return need(8) ? String(dv.getBigUint64(0, true)) : '?';
case 'float':
return need(4) ? String(Number(dv.getFloat32(0, true).toPrecision(7))) : '?';
case 'double':
return need(8) ? String(dv.getFloat64(0, true)) : '?';
case 'IntPtr':
case 'UIntPtr':
return need(8) ? fmtAddr(dv.getBigUint64(0, true)) : '?';
}
if (f.reference && need(8)) {
const at = dv.getBigUint64(0, true);
if (at === 0n) return 'null';
if (f.type === 'string') {
// A C# string object: header, 32-bit length at +0x10, UTF-16 text from +0x14.
const head = await ctx.dbg().read(ctx.pid, at + 0x10n, 4);
const length = view(head).getInt32(0, true);
if (length >= 0 && length <= 4096) {
const shown = Math.min(length, 80);
const raw = await ctx.dbg().read(ctx.pid, at + 0x14n, shown * 2);
return `${JSON.stringify(new TextDecoder('utf-16le').decode(raw))}${length > shown ? ` ... (${length} characters)` : ''} [at ${fmtAddr(at)}]`;
}
}
const target = await helper.describeObject(at);
return `-> ${fmtAddr(at)}${target ? ` (a ${target.className})` : ' (not recognized as an object)'}`;
}
// A struct, enum or nullable: show the bytes and the obvious readings.
const ints: string[] = [];
for (let o = 0; o + 4 <= Math.min(bytes.length, 16); o += 4) ints.push(String(dv.getInt32(o, true)));
return `bytes ${hexBytes(bytes)}${ints.length ? ` (as 32-bit numbers: ${ints.join(', ')})` : ''}`;
}
async function showObject(ctx: GameContext, helper: UnityHelper, address: bigint, print: Print): Promise<UnityClassInfo | null> {
const object = await helper.describeObject(address);
if (!object) {
const raw = await ctx.dbg().read(ctx.pid, address, 16);
print(` NOT RECOGNIZED as a Unity object. Its first 16 bytes are: ${hexBytes(raw)}`);
print(' (An object starts with a pointer to its class. If these bytes are a sensible pointer, the layout discovery is what failed.)');
return null;
}
const classAddress = view(await ctx.dbg().read(ctx.pid, address, 8)).getBigUint64(0, true);
const info = await helper.classInfo(classAddress);
if (!info) {
print(` Class ${object.className} at ${fmtAddr(classAddress)} was recognized but could not be described.`);
return null;
}
print(` Class: ${info.fullName} (class description at ${fmtAddr(classAddress)}, instance size 0x${(info.size ?? 0).toString(16)}${info.isValueType ? ', a struct' : ''})`);
const parents = await helper.parentsOf(classAddress);
print(` Parents: ${parents.slice(1).map((p) => p.fullName).join(' -> ') || '(none)'}`);
const instance = info.fields.filter((f) => !f.isStatic);
const body = await ctx.dbg().read(ctx.pid, address, Math.max(info.size ?? 0, 0x10));
print(` Fields (${instance.length} in the object, ${info.fields.length - instance.length} static):`);
print(' offset type name value now');
const row = async (f: UnityField, bytes: Uint8Array, prefix: string): Promise<void> => {
const from = f.declaredIn ? ` [from ${f.declaredIn}]` : '';
print(` ${prefix}${('+0x' + f.offset.toString(16).toUpperCase()).padEnd(7)} ${f.type.padEnd(30)} ${f.name.padEnd(36)} ${await showValue(ctx, helper, f, bytes)}${from}`);
};
for (let i = 0; i < instance.length; i++) {
const f = instance[i];
// Where the size is not known, show up to the next field.
const end = f.size !== undefined ? f.offset + f.size : Math.min(instance[i + 1]?.offset ?? body.length, f.offset + 32, body.length);
await row(f, body.subarray(f.offset, Math.max(end, f.offset)), '');
}
const statics = info.fields.filter((f) => f.isStatic);
if (statics.length) {
if (info.staticFields === undefined) print(' (static fields: where they are stored was not found, so no values)');
for (const f of statics) {
const bytes = info.staticFields === undefined ? new Uint8Array(0) : await ctx.dbg().read(ctx.pid, info.staticFields + BigInt(f.offset), f.size ?? 8);
await row(f, bytes, 'static ');
}
}
return info;
}
function showMethods(methods: UnityMethod[], moduleBase: bigint, moduleEnd: bigint, moduleName: string, print: Print): void {
for (const m of methods) {
const inModule = m.address >= moduleBase && m.address < moduleEnd ? ` = ${moduleName}+0x${(m.address - moduleBase).toString(16).toUpperCase()}` : ' (outside the Unity module)';
print(` ${fmtAddr(m.address)}${inModule} ${m.name} ${m.signature ?? ''}`);
}
}
/** Runs the check over an already-connected game and prints a self-explanatory report. */
export async function runCheck(ctx: GameContext, options: CheckOptions, print: Print): Promise<void> {
const started = Date.now();
const took = (since: number): string => `${((Date.now() - since) / 1000).toFixed(1)} s`;
const deadline = started + options.budget * 1000;
const maps = await ctx.maps(true);
print('[1] Is this a Unity IL2CPP game?');
const unity = maps.filter((m) => isUnityModule(m.name));
if (unity.length === 0) {
print(' NO: the memory map has no Il2CppUserAssemblies / GameAssembly module. Named modules seen:');
print(` ${[...new Set(maps.map((m) => m.name).filter((n) => n && n !== '(aux)'))].slice(0, 60).join(', ')}`);
print(' (A Unity game built with Mono instead of IL2CPP looks like this too, and is not supported.)');
return;
}
const moduleBase = unity.reduce((lo, m) => (m.start < lo ? m.start : lo), unity[0].start);
const moduleEnd = unity.reduce((hi, m) => (m.end > hi ? m.end : hi), moduleBase);
const moduleName = unity[0].name;
for (const m of unity) print(` ${m.name.padEnd(28)} ${fmtAddr(m.start)} - ${fmtAddr(m.end)} ${fmtBytes(Number(m.end - m.start)).padStart(8)} ${m.prot & PROT.READ ? 'r' : '-'}${m.prot & PROT.WRITE ? 'w' : '-'}${m.prot & PROT.EXEC ? 'x' : '-'}`);
const writable = scannableRegions(maps).reduce((n, r) => n + r.length, 0);
print(` YES. Module base ${fmtAddr(moduleBase)}. Writable memory to scan when a scan is needed: ${fmtBytes(writable)} in ${maps.length} map entries.`);
// What an earlier run learned. The layout holds for this version of the game; the object only until the game restarts.
let saved: { layout?: unknown; pid?: number; object?: string } = {};
if (options.state && fs.existsSync(options.state)) {
saved = JSON.parse(fs.readFileSync(options.state, 'utf8'));
print(` Using the layout saved in ${options.state}.`);
}
const helper = createUnityHelper(ctx, maps, saved.layout ? { layout: saved.layout as never } : {});
let object = options.object;
if (options.address !== undefined) {
print();
print(`[2] What is at ${fmtAddr(options.address)}?`);
const inside = await helper.objectContaining(options.address);
if (!inside) {
print(' No Unity object starts within 0x1000 bytes before it.');
} else {
print(` It is +0x${inside.offset.toString(16).toUpperCase()} into the ${inside.object.className} at ${fmtAddr(inside.object.address)}${inside.field ? `: the field "${inside.field.name}" (${inside.field.type}, at +0x${inside.field.offset.toString(16).toUpperCase()})` : ', past its named fields'}.`);
if (object === undefined) object = inside.object.address;
}
}
if (object === undefined && saved.object && saved.pid === ctx.pid) {
object = BigInt(saved.object);
print(` Using the object found by an earlier run of the same game session: ${fmtAddr(object)}.`);
}
if (object === undefined && options.hint) {
print();
print(`[2] Finding the object from the hint ${options.hint}`);
const t = Date.now();
object = await findByHint(ctx, parseHint(options.hint), options.limit, deadline, async (a) => (await helper.describeObject(a))?.className ?? null, print);
print(` (${took(t)})`);
if (object === undefined) print(' No Unity object matches the hint. Check the values are what the game shows right now.');
else print(` Using ${fmtAddr(object)}. Pass --object ${fmtAddr(object)} next time to skip this search (until the game restarts).`);
}
let info: UnityClassInfo | null = null;
if (object !== undefined) {
print();
print(`[3] The object at ${fmtAddr(object)}`);
const t = Date.now();
info = await showObject(ctx, helper, object, print);
print(` (${took(t)})`);
}
print();
print('[4] What the helper worked out about this game');
const t4 = Date.now();
const version = await helper.locateMetadata();
for (const line of helper.describeLayout()) print(` ${line}`);
print(` Label for the app: "${helper.label}"${version === null ? ' (metadata version unknown)' : ''} (${took(t4)})`);
if (!helper.layout) print(' NOTHING WAS WORKED OUT. With no --object or --hint the first class comes from the module\'s data; that found none.');
if (options.className) {
print();
print(`[5] Classes matching "${options.className}"`);
const t = Date.now();
const classes = await helper.findClasses(options.className, options.limit);
for (const c of classes) print(` ${c.loaded ? fmtAddr(c.address!) : '(not loaded yet)'.padEnd(12)} ${c.fullName}${c.parent ? ` : ${c.parent}` : ''}${c.size ? ` (0x${c.size.toString(16)} bytes)` : ''}`);
if (classes.length === 0) print(' none');
const exact = await helper.classInfo(options.className);
if (exact) {
print(` Fields of ${exact.fullName}:`);
for (const f of exact.fields) print(` ${f.isStatic ? 'static ' : ''}+0x${f.offset.toString(16).toUpperCase().padEnd(5)} ${f.type.padEnd(30)} ${f.name}${f.declaredIn ? ` [from ${f.declaredIn}]` : ''}`);
}
print(` (${took(t)})`);
}
for (const query of options.methods) {
print();
print(`[6] Methods matching "${query}" (address = module offset, name, signature)`);
const t = Date.now();
const methods = await helper.findMethods(query, Math.max(options.limit, 60));
showMethods(methods, moduleBase, moduleEnd, moduleName, print);
if (methods.length === 0) print(' none (the class is not loaded, the name is wrong, or the method has no code of its own)');
print(` (${took(t)})`);
}
if (options.near.length) {
print();
print(`[7] Which method contains each of these ${moduleName} offsets?`);
const t = Date.now();
const left = Math.max(5000, deadline - Date.now());
let note = '';
for (const offset of options.near) {
// The first lookup walks every loaded class; the rest reuse what it gathered.
const at = await helper.methodAt(moduleBase + offset, left);
const m = at.method;
print(
m
? ` +0x${offset.toString(16).toUpperCase()}: ${m.name} starts 0x${at.distance.toString(16)} bytes before it, at +0x${(m.address - moduleBase).toString(16).toUpperCase()} ${m.signature ?? ''}`
: ` +0x${offset.toString(16).toUpperCase()}: no known method starts before it`,
);
note = `Looked through ${at.methods} methods. ${at.complete ? 'Every loaded class was covered.' : 'NOT every loaded class was covered (out of time, or the table of classes was not found), so a closer method may exist.'}`;
}
print(` ${note}`);
print(' A distance of a few hundred bytes or less means "inside that method"; thousands means the real one is not a C# method the helper knows.');
print(` (${took(t)})`);
}
if (options.objects) {
print();
print(`[8] Live objects of "${options.objects}"`);
const t = Date.now();
const objects = await helper.findObjects(options.objects, options.limit);
for (const o of objects) print(` ${fmtAddr(o.address)} ${o.className}`);
if (objects.length === 0) print(' none');
print(` (${took(t)})`);
}
if (options.state && helper.layout) {
fs.writeFileSync(options.state, JSON.stringify({ layout: helper.layout, pid: ctx.pid, object: info && object !== undefined ? fmtAddr(object) : undefined }, null, 2));
print();
print(`Layout${info ? ' and object address' : ''} saved to ${options.state}.`);
}
print();
print(`Done in ${took(started)}. Nothing was written to the game.`);
}
function parseArgs(argv: string[]): { url: string; options: CheckOptions } {
const options: CheckOptions = { methods: [], near: [], limit: 10, budget: 150 };
let url = 'ws://192.168.1.50:8744/ws';
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
const value = (): string => {
const v = argv[++i];
if (v === undefined) throw new Error(`${a} needs a value`);
return v;
};
if (a === '--object') options.object = BigInt(value());
else if (a === '--address') options.address = BigInt(value());
else if (a === '--hint') options.hint = value();
else if (a === '--method') options.methods.push(...value().split(',').map((s) => s.trim()).filter(Boolean));
else if (a === '--near') options.near.push(...value().split(',').map((s) => BigInt(s.trim())));
else if (a === '--class') options.className = value();
else if (a === '--objects') options.objects = value();
else if (a === '--state') options.state = value();
else if (a === '--limit') options.limit = Number(value());
else if (a === '--budget') options.budget = Number(value());
else if (/^wss?:\/\//.test(a)) url = a;
else throw new Error(`Unknown option "${a}". See the top of live/unity-check.ts for the list.`);
}
return { url, options };
}
async function main(): Promise<void> {
const { url, options } = parseArgs(process.argv.slice(2));
const print: Print = (line = '') => console.log(line);
print('CheatSpecter Unity (IL2CPP) check. Read-only.');
const session = await Session.connect(null, url);
try {
print(`Console: ${url} debugger: ${session.branding.brand || 'unknown'}`);
const game = await session.currentGame();
if (!game) {
print('No game is on screen. Start the Unity game, get into a save, and run this again.');
return;
}
print(`Game on screen: ${game.titleId} ${game.version} (process ${game.pid})`);
print();
const ctx = createGameContext({ pid: game.pid, dbg: () => session.main, scanDbg: () => session.scan, x86: null as never });
await runCheck(ctx, options, print);
} finally {
session.close();
}
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
main().then(
() => process.exit(0),
(e) => {
console.error(`FAILED: ${e instanceof Error ? (e.stack ?? e.message) : String(e)}`);
process.exit(1);
},
);
}
+482 -44
View File
@@ -2,8 +2,10 @@
//
// Runs on a computer, talks to the CheatSpecter payload on a PS5 over the
// local network, and offers the workbench as tools: see what is running,
// read and write memory, search for values, find what code touches a value,
// disassemble, and keep the user's cheat file up to date.
// read and write memory, search for values and text, find what code touches a
// value or refers to an address, name objects and their fields where the
// game's engine keeps names, disassemble, and keep the user's cheat file up
// to date.
//
// It is the same library the web app uses, so anything found here ends up in
// the same .cspector file the app loads.
@@ -11,23 +13,40 @@
// 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';
import { bytesToHex, hexToBytes } from '../src/lib/bytes.ts';
import type { ClassInfo, EngineDetector, EngineHelper, EngineId } from '../src/lib/engine.ts';
import { identifyEngine } from '../src/lib/engine-detect.ts';
import { detectGameMaker } from '../src/lib/gamemaker.ts';
import { detectGodot } from '../src/lib/godot.ts';
import { detectUnity } from '../src/lib/il2cpp.ts';
import { parseEngineStatus } from '../src/lib/keep.ts';
import { parseParamJson, parseParamSfo, formatSize } from '../src/lib/library.ts';
import { createGameContext, findPattern, resolveLocation } from '../src/lib/locations.ts';
import { addressOwner, createGameContext, describeAddress, findPattern, findReferences, moduleLocation, parseAddress } from '../src/lib/locations.ts';
import type { GameContext } from '../src/lib/locations.ts';
import type { MapEntry } from '../src/lib/ps5debug.ts';
import { PROT } from '../src/lib/ps5debug.ts';
import { detectCpp } from '../src/lib/rtti.ts';
import { AutoScan, COMPARE_KINDS, ScanSession, VALUE_KINDS, compareKind, decodeValue, encodeValue, scannableRegions, valueKind } from '../src/lib/scanner.ts';
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 { TableRunner, describeEntry, emptyTable, isAction, newId, parseOwnTable, saveFileText } from '../src/lib/table.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';
import { MAX_TEXT_CHARS, searchText } from '../src/lib/textsearch.ts';
import type { TextEncoding } from '../src/lib/textsearch.ts';
import { Tracer } from '../src/lib/tracer.ts';
import { detectUnreal } from '../src/lib/unreal.ts';
import { asAsync, createX86, signatureFromCode } from '../src/lib/x86.ts';
declare const __VERSION__: string;
@@ -60,6 +79,8 @@ interface GameState {
runner: TableRunner;
scan: Scan | null;
table: OwnTable | null;
/** What the game's engine can tell about its memory. Undefined until asked for; null when it is not an engine that keeps names. */
helper?: { helper: EngineHelper | null; notes: string[] };
}
let state: GameState | null = null;
@@ -138,7 +159,7 @@ async function current(): Promise<GameState> {
}
},
});
state = { game, name: await titleName(game.titleId), ctx, runner: new TableRunner(ctx), scan: null, table: null };
state = { game, name: await titleName(game.titleId), ctx, runner: new TableRunner(ctx, { entries: () => state?.table?.entries ?? [] }), scan: null, table: null };
return state;
}
@@ -166,30 +187,98 @@ function modules(maps: MapEntry[]): Module[] {
return [...byName.values()].sort((a, b) => (a.start < b.start ? -1 : 1));
}
/** "0x1234", "1234", or "module+0x1234" ("game" is the game's own executable). */
/**
* "0x1234", "1234", or "module+0x1234". "game" (or "eboot.bin") is the game's
* own executable; any other name is a loaded module as list_modules shows it,
* in any case, and the start of a name will do when only one module has it.
*/
async function address(st: GameState, text: unknown): Promise<bigint> {
if (typeof text !== 'string' || !text.trim()) throw new ToolError('An address is needed, e.g. "0x880012340" or "game+0x1A2B".');
const t = text.trim();
const plus = t.lastIndexOf('+');
const num = (h: string) => {
const clean = h.trim().replace(/^0x/i, '');
if (!/^[0-9a-f]+$/i.test(clean)) throw new ToolError(`"${h}" is not a hex number`);
return BigInt('0x' + clean);
};
if (plus < 0) return num(t);
const name = t.slice(0, plus).trim().toLowerCase();
const offset = num(t.slice(plus + 1));
if (name === 'game' || name === 'eboot' || name === 'eboot.bin') return (await resolveLocation(st.ctx, { kind: 'module', offset: '0' })) + offset;
const all = modules(await st.ctx.maps());
const found = all.find((m) => m.name.toLowerCase() === name) ?? all.find((m) => m.name.toLowerCase().startsWith(name));
if (!found) throw new ToolError(`No module called "${name}" is loaded. list_modules shows the names.`);
return found.start + offset;
try {
return await parseAddress(st.ctx, t);
} catch (e) {
const plus = t.lastIndexOf('+');
const offset = t.slice(plus + 1).trim().replace(/^0x/i, '');
if (plus > 0 && /^[0-9a-f]+$/i.test(offset)) {
// Not a module by that exact name: the start of one will do, when it can only mean one.
const name = t.slice(0, plus).trim().toLowerCase();
const starts = modules(await st.ctx.maps()).filter((m) => m.name.toLowerCase().startsWith(name));
if (starts.length === 1) return starts[0].start + BigInt('0x' + offset);
if (starts.length > 1) throw new ToolError(`"${name}" could be ${starts.map((m) => m.name).join(' or ')}. Use the whole name.`);
throw new ToolError(`No module called "${name}" is loaded. list_modules shows the names; "game+..." is the game itself.`);
}
throw new ToolError(e instanceof Error ? e.message : String(e));
}
}
/** An address the way a reader wants it: raw, plus which module it is in. */
/**
* An address the way a reader wants it: raw, plus where that is in a form
* that is still right after the game restarts ("game+0x1A2B",
* "libSomething.prx+0x40"). The second form can be passed straight back in.
*/
async function where(st: GameState, a: bigint): Promise<string> {
const m = modules(await st.ctx.maps()).find((x) => a >= x.start && a < x.end);
return m ? `${hexAddr(a)} (${m.name}+0x${(a - m.start).toString(16).toUpperCase()})` : hexAddr(a);
const lasting = await describeAddress(st.ctx, a);
return lasting === hexAddr(a) ? lasting : `${hexAddr(a)} (${lasting})`;
}
// ---- the game's engine -------------------------------------------------------------
//
// Unreal, Unity (IL2CPP), Godot and GameMaker games keep the names of their
// classes and fields in memory, and most other C++ games keep at least class
// names. A helper reads those, so "the object at this address" becomes "a
// PlayerCharacter, and offset 0x1038 is FireRate".
const ENGINE_DETECTORS: Record<EngineId, EngineDetector> = {
unreal: detectUnreal,
unity: detectUnity,
godot: detectGodot,
gamemaker: detectGameMaker,
cpp: detectCpp,
};
/**
* Finds the helper for the running game, once per launch. The quick guess
* (identifyEngine) names the engine; that engine's detector is tried first,
* and plain C++ class names are the fallback for everything.
*/
async function engineHelper(st: GameState): Promise<{ helper: EngineHelper | null; notes: string[] }> {
if (st.helper) return st.helper;
const notes: string[] = [];
let helper: EngineHelper | null = null;
let guess: EngineId | 'unknown' = 'unknown';
try {
const found = await identifyEngine(st.ctx, { budgetMs: 8000 });
guess = found.engine;
notes.push(...found.evidence);
} catch (e) {
notes.push(`Could not look for signs of an engine: ${e instanceof Error ? e.message : String(e)}`);
}
const order: EngineId[] = guess === 'unknown' || guess === 'cpp' ? ['cpp'] : [guess, 'cpp'];
for (const id of order) {
try {
helper = await ENGINE_DETECTORS[id](st.ctx);
} catch (e) {
notes.push(`Reading this game as ${id} failed: ${e instanceof Error ? e.message : String(e)}`);
}
if (helper) break;
}
st.helper = { helper, notes };
return st.helper;
}
async function needHelper(st: GameState): Promise<EngineHelper> {
const { helper } = await engineHelper(st);
if (!helper) {
throw new ToolError(
'This game does not keep class names CheatSpecter can read (engine_info says what was looked for), so objects cannot be named. Searching for values and watching what writes them still works.',
);
}
return helper;
}
function classLine(c: ClassInfo): string {
return `${c.fullName || c.name}${c.parent ? ` : ${c.parent}` : ''}${c.size ? ` (${c.size} bytes)` : ''}${c.address !== undefined ? ` at ${hexAddr(c.address)}` : ''}`;
}
function int(value: unknown, fallback: number, min: number, max: number): number {
@@ -244,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> {
@@ -289,18 +434,30 @@ function findEntry(table: OwnTable, name: unknown): Entry {
const CHEAT_FORMAT = `A cheat is a JSON object with "name", "type" and, optionally, "note".
Locations ("at") are one of:
{"kind":"signature","pattern":"45 8B 65 48 ?? 8D","offset":8} a byte pattern in the code plus a distance past it; survives restarts and most updates. Do not include the bytes the patch changes in the pattern.
{"kind":"module","offset":"0x3E5AB37"} an offset into the game's own executable (not other modules)
{"kind":"pointer","base":"0x2F4A10","offsets":["0x8","0x340"]} a pointer path starting in the game's executable
{"kind":"module","offset":"0x3E5AB37"} an offset into the game's own executable; add "module":"libSomething.prx" for an offset into that module instead
{"kind":"pointer","base":"0x2F4A10","offsets":["0x8","0x340"]} a pointer path starting in the game's executable (or, with "module", in that module)
{"kind":"address","address":"0x880012340"} a raw address, valid only until the game restarts
Types:
patch {"at":…, "on":"909090", "off":"FF4B28", "more":[{"at":…,"on":"…","off":"…"}]} bytes to write and the originals; "more" adds further places to the same switch
value {"at":…, "valueType":"u32", "value":"9999", "freeze":true} set a value, or hold it there
inject {"at":…, "code":"mov dword ptr [rbx+0x390], 100", "placement":"before"} run assembly at a point in the code ("before", "after" or "replace" the instructions there)
inject {"at":…, "code":"cmp eax, 50\njge keep\nmov eax, dword ptr [floor]\nkeep:\noriginal", "placement":"code", "variables":[{"name":"floor","type":"i32","value":"100"}]}
a code cheat: the instruction at "at" is replaced by a jump to this assembly (Intel syntax, labels, ";" comments). "original" on its own line is the replaced instruction; "jmp return" goes back to the game and is added after the last line. Variables are read as [name]. Older "placement" values "before", "after" and "replace" put all replaced instructions after or before the code, or drop them.
shared {"symbol":"healPlayer", "code":"mov dword ptr [rdi+0x390], 100\nret"} code other code cheats run with "call healPlayer"; it is put in the game when one of them is switched on
call {"fn":…, "args":["0x1","2"]} call a function in the game
script {"code":"await cs.write(await cs.pointer('game+10100', 0x340), 'u32', 100);"} a small program`;
script {"code":"await cs.write(await cs.pointer('game+10100', 0x340), 'u32', 100);"} a small program
A patch, or a value with "freeze":true, can also carry "keep":true: the PS5 then switches it on by itself whenever the game runs, with no app open (not at a raw address). kept_status says how that is going.`;
// ---- 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;
@@ -312,16 +469,21 @@ interface Tool {
const S = (description: string, extra: Record<string, unknown> = {}) => ({ type: 'string', description, ...extra });
const N = (description: string) => ({ type: 'number', description });
const B = (description: string) => ({ type: 'boolean', description });
const ADDR = 'Hex address, or "module+offset" such as "game+0x1A2B" or "Il2CppUserAssemblies.prx+0x76769D".';
const ADDR = 'Hex address, or "module+offset" such as "game+0x1A2B" or "Il2CppUserAssemblies.prx+0x76769D" ("game" is the game\'s own executable; other names as list_modules shows them).';
function describeHits(st: GameState, scan: Scan, hits: { address: bigint; value: Uint8Array; kind?: { id: string } }[]): string {
if (hits.length === 0) return '';
return hits
.map((h) => {
const kind = h.kind ? valueKind(h.kind.id) : scan.kind;
return `${hexAddr(h.address)} ${decodeValue(kind, h.value)}${scan instanceof AutoScan ? ` (${kind.id})` : ''}`;
})
.join('\n');
/** " (game+0x1A2B)" for an address inside a module, which is worth knowing: it stays put between launches. "" for the heap. */
async function lasting(st: GameState, a: bigint): Promise<string> {
const owner = await addressOwner(st.ctx, a);
return owner ? ` (${owner.module ?? 'game'}+${hexAddr(owner.offset)})` : '';
}
async function describeHits(st: GameState, scan: Scan, hits: { address: bigint; value: Uint8Array; kind?: { id: string } }[]): Promise<string> {
const rows = [];
for (const h of hits) {
const kind = h.kind ? valueKind(h.kind.id) : scan.kind;
rows.push(`${hexAddr(h.address)} ${decodeValue(kind, h.value)}${scan instanceof AutoScan ? ` (${kind.id})` : ''}${await lasting(st, h.address)}`);
}
return rows.join('\n');
}
async function scanSummary(st: GameState, limit = 20): Promise<string> {
@@ -330,7 +492,7 @@ async function scanSummary(st: GameState, limit = 20): Promise<string> {
const n = scan.matches;
const dropped = scan instanceof AutoScan && scan.dropped.length ? `\nLeft out: ${scan.dropped.map((d) => `${d.kind.id} (${d.reason})`).join(', ')}` : '';
if (n === 0) return `0 matches after pass ${scan.passes}. The search is over; start a new one.${dropped}`;
const shown = n <= 500 ? describeHits(st, scan, await scan.hits(limit)) : '';
const shown = n <= 500 ? await describeHits(st, scan, await scan.hits(limit)) : '';
return `${n.toLocaleString('en-US')} matches after pass ${scan.passes}.${dropped}${shown ? `\nFirst ${Math.min(n, limit)} (address, value at the last pass):\n${shown}` : n > 500 ? '\nToo many to list; change the value in the game and narrow with scan_next.' : ''}`;
}
@@ -408,7 +570,7 @@ const tools: Tool[] = [
// Code modules only: the map also names hundreds of stacks and shared-memory blocks nobody patches.
const code = modules(maps).filter((m) => /^executable$|\.(s?prx|elf|self|bin)$/i.test(m.name));
const rows = code.map((m) => `${hexAddr(m.start).padEnd(14)} ${formatSize(Number(m.end - m.start)).padStart(9)} ${m.name}`);
return `${rows.join('\n')}\n\nWritable memory a search covers: ${formatSize(writable)}`;
return `${rows.join('\n')}\n\nAddresses can be given as "name+offset" with any of these names; "game+offset" is the game's own executable.\nWritable memory a search covers: ${formatSize(writable)}`;
},
},
{
@@ -559,7 +721,7 @@ const tools: Tool[] = [
for (const h of hits) {
const kind = h.kind ?? scan.kind;
const now = await st.ctx.dbg().read(st.game.pid, h.address, kind.size);
rows.push(`${hexAddr(h.address)} was ${decodeValue(kind, h.value)} now ${decodeValue(kind, now)}${scan instanceof AutoScan ? ` (${kind.id})` : ''}`);
rows.push(`${hexAddr(h.address)} was ${decodeValue(kind, h.value)} now ${decodeValue(kind, now)}${scan instanceof AutoScan ? ` (${kind.id})` : ''}${await lasting(st, h.address)}`);
}
return `${scan.matches.toLocaleString('en-US')} matches after pass ${scan.passes}.\n${rows.join('\n')}`;
},
@@ -661,7 +823,7 @@ const tools: Tool[] = [
},
{
name: 'add_cheat',
description: `Saves a cheat for the running game to its .cspector file on the PS5, replacing any cheat with the same name. It then shows up as a switch in the CheatSpecter web app. Test with set_cheat afterwards.\n\n${CHEAT_FORMAT}`,
description: `Saves a cheat for the running game to its .cspector file on the PS5, replacing any cheat with the same name. It then shows up as a switch in the CheatSpecter web app. Test with set_cheat afterward.\n\n${CHEAT_FORMAT}`,
inputSchema: { type: 'object', properties: { cheat: { type: 'object', description: 'The cheat, in the format described above.' } }, required: ['cheat'] },
run: async (a) => {
const st = await current();
@@ -671,13 +833,34 @@ 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);
entry.id = old?.id ?? newId();
const entries = old ? table.entries.map((e) => (e === old ? entry : e)) : [...table.entries, entry];
await saveTable(st, { ...table, entries });
return `Saved "${entry.name}" (${describeEntry(entry)}) to ${tableFileName(table.titleId, table.version)}. ${old ? 'It replaced the cheat of the same name. ' : ''}It is off; set_cheat switches it on.`;
// "keep" is dropped by the checks above when the cheat is not one the console can keep on.
const keepNote =
raw.keep === true && !entry.keep ? ` It is not kept on by the PS5: ${keepProblem(entry) ?? 'only "keep": true marks a cheat to keep.'}` : '';
return (
`Saved "${entry.name}" (${describeEntry(entry)}) to ${tableFileName(table.titleId, table.version)}. ${old ? 'It replaced the cheat of the same name. ' : ''}` +
(entry.keep
? 'The PS5 switches it on by itself within a few seconds, and again whenever the game starts; kept_status says how that went.'
: `It is off; set_cheat switches it on.${keepNote}`)
);
},
},
{
@@ -695,16 +878,25 @@ const tools: Tool[] = [
},
{
name: 'set_cheat',
description: 'Switches a saved cheat on or off in the running game (or runs it, if it is a one-off action). A cheat that does not match the game\'s code is refused unless "force" is set.',
description: 'Switches a saved cheat on or off in the running game (or runs it, if it is a one-time action). A cheat that does not match the game\'s code is refused unless "force" is set.',
inputSchema: { type: 'object', properties: { name: S('The cheat\'s name.'), on: B('true to switch on or run, false to switch off.'), force: B('Apply even if the original bytes do not match.') }, required: ['name', 'on'] },
run: async (a) => {
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(
`"${entry.name}" is kept on by the PS5 itself, which would switch it straight back on. Save it again with add_cheat without "keep" (list_cheats with "json" shows it); the console then puts the original bytes back.`,
);
}
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 {
@@ -716,9 +908,255 @@ 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.`;
},
},
{
name: 'describe_address',
description:
'Says what an address is: which module it is in and at what offset (the form that stays right after the game restarts), what kind of memory it is, and, when the game\'s engine keeps names, what object lives there. Use it to turn an address from a search into something a cheat can use.',
inputSchema: { type: 'object', properties: { address: S(ADDR) }, required: ['address'] },
run: async (a) => {
const st = await current();
const at = await address(st, a.address);
const maps = await st.ctx.maps();
const map = maps.find((m) => at >= m.start && at < m.end && m.name !== '(aux)') ?? maps.find((m) => at >= m.start && at < m.end);
if (!map) return `${hexAddr(at)} is not in the game's memory right now.`;
const kind = map.prot & PROT.EXEC ? 'code' : map.prot & PROT.WRITE ? 'data the game can change' : 'fixed data';
const out = [`${await where(st, at)}: ${kind}, in the memory area "${map.name || 'unnamed'}" (${hexAddr(map.start)} to ${hexAddr(map.end)})`];
const loc = await moduleLocation(st.ctx, at);
if (loc) out.push(`As a cheat location: ${JSON.stringify(loc)}`);
else out.push('It is not inside a module, so the address changes every time the game starts. A cheat needs a pointer path to it, or a patch of the code that writes it (watch finds that code).');
const known = st.helper?.helper;
if (known && !(map.prot & PROT.EXEC)) {
const object = await known.describeObject(at).catch(() => null);
if (object) out.push(`An object lives here: ${object.className}${object.name ? ` "${object.name}"` : ''} (class_info shows its fields).`);
}
return out.join('\n');
},
},
{
name: 'find_references',
description:
'Finds the code that refers to an address: every instruction in a module whose memory operand points at it (loads, stores and address-takings of a global variable or static pointer). Good for finding what uses a static value, or where a pointer to an object is kept. Calls and jumps to the address are not found. Takes several seconds on a big game.',
inputSchema: {
type: 'object',
properties: { address: S(ADDR), module: S('Whose code to search: a module name as list_modules shows it. Default: the game\'s own executable.'), limit: N('How many to list (default 40, at most 200).') },
required: ['address'],
},
run: async (a) => {
const st = await current();
if (!session!.extended) throw new ToolError('Finding references needs ps5debug-NG on the PS5; the debugger that is loaded cannot do it.');
const at = await address(st, a.address);
const module = str(a.module).trim() || undefined;
const limit = int(a.limit, 40, 1, 200);
return runJob(`looking for code that refers to ${hexAddr(at)}`, async () => {
const refs = await findReferences(st.ctx, at, module);
if (refs.length === 0) return `Nothing in ${module ?? 'the game\'s own code'} refers to ${await where(st, at)}. If it is a value on the heap, look for a pointer path instead.`;
const rows = [];
for (const ref of refs.slice(0, limit)) {
const insn = x86.disassemble(await st.ctx.dbg().read(st.game.pid, ref, 16), ref, 1)[0];
rows.push(`${(await where(st, ref)).padEnd(34)} ${insn && !insn.invalid ? insn.text : '(could not be decoded)'}`);
}
return `${refs.length} ${refs.length === 1 ? 'instruction refers' : 'instructions refer'} to ${await where(st, at)}${refs.length > limit ? ` (first ${limit} shown)` : ''}:\n${rows.join('\n')}`;
});
},
},
{
name: 'engine_info',
description:
'Says which game engine the running game is built with (Unreal, Unity, Godot, GameMaker or plain C++) and whether it keeps class and field names in memory. When it does, find_classes, class_info and find_objects work, and read_structure names what it shows. The first call can take a while on a big game.',
inputSchema: { type: 'object', properties: {} },
run: async () => {
const st = await current();
return runJob('working out which engine the game uses', async () => {
const { helper, notes } = await engineHelper(st);
const lines = helper
? [`${helper.label}. Class names can be read: find_classes, class_info and find_objects work${helper.findMethods ? ', and function names are known too' : ''}.`]
: ['No class names could be read from this game, so objects cannot be named. Value searches, watch and patterns still work.'];
if (notes.length) lines.push('', 'What was found:', ...notes.map((n) => ` ${n}`));
return lines.join('\n');
});
},
},
{
name: 'find_classes',
description: 'Lists the game\'s classes whose name contains some text (needs an engine that keeps names; see engine_info). Follow with class_info for the fields of one.',
inputSchema: { type: 'object', properties: { query: S('Part of a class name, e.g. "Player" or "Health". Case does not matter.'), limit: N('How many to list (default 40, at most 200).') }, required: ['query'] },
run: async (a) => {
const st = await current();
const query = str(a.query).trim();
if (!query) throw new ToolError('Give part of a class name to look for.');
const helper = await needHelper(st);
const limit = int(a.limit, 40, 1, 200);
const found = await helper.findClasses(query, limit);
if (found.length === 0) return `No class has "${query}" in its name.`;
return `${found.length}${found.length >= limit ? '+' : ''} ${found.length === 1 ? 'class' : 'classes'} with "${query}" in the name:\n${found.map(classLine).join('\n')}`;
},
},
{
name: 'class_info',
description: 'Shows one class of the game: its parent, size, and every field with offset and type (needs an engine that keeps names; see engine_info). The offsets are what a pointer path or read_structure needs.',
inputSchema: { type: 'object', properties: { name: S('The class name as find_classes shows it, or the address of its description.') }, required: ['name'] },
run: async (a) => {
const st = await current();
const wanted = str(a.name).trim();
if (!wanted) throw new ToolError('Give a class name.');
const helper = await needHelper(st);
const info = (await helper.classInfo(wanted)) ?? (/^(0x)?[0-9a-f]{6,16}$/i.test(wanted) ? await helper.classInfo(BigInt(wanted.startsWith('0x') || wanted.startsWith('0X') ? wanted : '0x' + wanted)) : null);
if (!info) return `There is no class called "${wanted}". find_classes lists the names.`;
const rows = info.fields.map(
(f) => ` +0x${f.offset.toString(16).toUpperCase().padEnd(5)} ${(f.type || '?').padEnd(24)} ${f.name}${f.isStatic ? ' (static)' : ''}${f.declaredIn && f.declaredIn !== info.name ? ` (from ${f.declaredIn})` : ''}`,
);
return `${classLine(info)}\n${rows.length ? rows.join('\n') : ' (no fields are known for this class)'}`;
},
},
{
name: 'find_objects',
description: 'Finds live objects of a class in the running game by part of the class name (needs an engine that keeps names; see engine_info). Returns their addresses, which read_structure and read_memory take. Objects come and go as the game runs, so use the addresses soon.',
inputSchema: { type: 'object', properties: { query: S('Part of a class name, e.g. "PlayerCharacter". Case does not matter.'), limit: N('How many to list (default 20, at most 200).') }, required: ['query'] },
run: async (a) => {
const st = await current();
const query = str(a.query).trim();
if (!query) throw new ToolError('Give part of a class name to look for.');
const helper = await needHelper(st);
const limit = int(a.limit, 20, 1, 200);
return runJob(`looking for live ${query} objects`, async () => {
const found = await helper.findObjects(query, limit);
if (found.length === 0) return `No live object of a class with "${query}" in its name was found. It may only exist while playing (not in a menu).`;
return `${found.length}${found.length >= limit ? '+' : ''} live ${found.length === 1 ? 'object' : 'objects'}:\n${found.map((o) => `${hexAddr(o.address)} ${o.className}${o.name ? ` "${o.name}"` : ''}`).join('\n')}`;
});
},
},
{
name: 'read_structure',
description:
'Reads an object in the game\'s memory and lays it out field by field: each slot with its best reading (whole number, decimal, pointer and what it points at, text). When the game\'s engine keeps names (engine_info), fields carry their names. Give the address of the start of the object.',
inputSchema: {
type: 'object',
properties: {
address: S(ADDR),
size: N(`Bytes to read (default 256, at most ${MAX_STRUCTURE_SIZE}).`),
class: S('Name the fields as this class\'s, instead of the class the engine says the object is.'),
},
required: ['address'],
},
run: async (a) => {
const st = await current();
const at = await address(st, a.address);
const size = int(a.size, 256, 4, MAX_STRUCTURE_SIZE);
// Names are used when a helper is already there; working one out is engine_info's job (it can take a while).
const helper = st.helper?.helper ?? null;
let info: ClassInfo | null = null;
let object = '';
if (helper) {
const described = await helper.describeObject(at).catch(() => null);
if (described) object = `${described.className}${described.name ? ` "${described.name}"` : ''}`;
const className = str(a.class).trim() || described?.className || '';
if (className) info = await helper.classInfo(className).catch(() => null);
} else if (str(a.class).trim()) {
throw new ToolError('Class names are not available yet for this game. Call engine_info first.');
}
const structure = await readStructure(st.ctx, at, size, {
fields: info?.fields.filter((f) => !f.isStatic).map((f) => ({ offset: f.offset, name: f.name, type: f.type })),
nameOf: helper ? async (p) => (await helper.describeObject(p).catch(() => null))?.className ?? null : undefined,
});
const rows = structure.rows.map((r) => {
const g = r.guesses[0];
const kind = g.kind === 'int' ? (g.size === 8 ? 'int64' : 'int32') : g.kind;
return `+0x${r.offset.toString(16).toUpperCase().padEnd(5)} ${bytesToHex(r.bytes, ' ').padEnd(24)} ${kind.padEnd(8)} ${g.text}${r.field ? ` <- ${r.field.name}${r.field.type ? ` (${r.field.type})` : ''}` : ''}`;
});
const head = `${await where(st, at)}, ${size} bytes${object ? `: ${object}` : ''}${info && info.name !== object.split(' ')[0] ? ` (fields named as ${info.name})` : ''}`;
return `${head}\n${rows.join('\n')}${helper ? '' : '\n\nNo field names: call engine_info once to see whether this game keeps them.'}`;
},
},
{
name: 'text_search',
description:
'Searches the game\'s memory for text, such as a character or item name shown on screen, in both plain (UTF-8) and wide (UTF-16) form. Returns each place with the text around it. Useful for finding an object by its name, or a string table. A big game takes a while; if the answer is "still running", call scan_status.',
inputSchema: {
type: 'object',
properties: {
text: S(`The text to look for (at most ${MAX_TEXT_CHARS} characters).`),
ignore_case: B('Also match other capitalizations (default false).'),
where: S('"writable" (default) searches memory the game can change, where live names are; "all" also searches code and fixed data, where built-in text is.', { enum: ['writable', 'all'] }),
limit: N('Stop after this many places (default 40, at most 200).'),
},
required: ['text'],
},
run: async (a) => {
const st = await current();
const text = str(a.text);
if (!text.trim()) throw new ToolError('Give the text to look for.');
if ([...text].length > MAX_TEXT_CHARS) throw new ToolError(`That is too long: at most ${MAX_TEXT_CHARS} characters. A distinctive part of it is enough.`);
const everything = str(a.where) === 'all';
const ranges = (await st.ctx.maps(true))
.filter((m) => m.prot & PROT.READ && m.end > m.start && (everything || (m.prot & PROT.WRITE && !(m.prot & PROT.EXEC))))
.map((m) => ({ start: m.start, end: m.end }));
if (ranges.length === 0) throw new ToolError('There is no memory to search. Is the game still running?');
const limit = int(a.limit, 40, 1, 200);
const encodings: TextEncoding[] = ['utf8', 'utf16'];
return runJob(`searching for the text "${text}"`, async (progress) => {
const found = await searchText({
dbg: () => session!.scan,
pid: st.game.pid,
ranges,
text,
encodings,
ignoreCase: a.ignore_case === true || a.ignore_case === 'true',
maxHits: limit,
onProgress: (p) => progress(`${Math.round(p.fraction * 100)}%, ${p.hits} found so far`),
});
if (found.hits.length === 0) return `"${text}" was not found in ${everything ? 'the game\'s memory' : 'the memory the game can change (try "where": "all" for built-in text)'}.`;
const rows = [];
for (const h of found.hits) {
const clean = (t: string) => t.replace(/[\u0000-\u001f\u007f]/g, '.');
rows.push(`${hexAddr(h.address)} ${h.encoding === 'utf16' ? 'wide ' : 'plain'} ${clean(h.before)}[${clean(h.match)}]${clean(h.after)}${await lasting(st, h.address)}`);
}
return `${found.hits.length} ${found.hits.length === 1 ? 'place' : 'places'}${found.capped ? ' (stopped at the limit; there may be more)' : ''}${found.allCases ? '' : ' (only the common capitalizations were tried)'}:\n${rows.join('\n')}`;
});
},
},
{
name: 'kept_status',
description:
'Says how the cheats the PS5 keeps on by itself are doing. A cheat saved with "keep": true is switched on by the console whenever its game runs, with no app or computer involved; this reports, for each, whether it is on, waiting, does not fit this version of the game, or has a problem, and why.',
inputSchema: { type: 'object', properties: {} },
run: async () => {
const res = await http('/api/engine');
const status = res.ok ? parseEngineStatus(await res.json().catch(() => null)) : null;
if (!status) return 'This CheatSpecter payload does not keep cheats on by itself (it is an older one). Send the current cheatspecter.elf to the PS5.';
const game = status.game;
// The cheat file as this server knows it, when the engine and the debugger agree on which game is running.
const st = await current().catch(() => null);
const table = st && game && st.game.titleId === game.titleId ? await loadTable(st, true).catch(() => null) : null;
if (!status.running) return 'The CheatSpecter payload on the PS5 cannot reach the debugger right now, so nothing is being kept on. Is ps5debug-NG loaded?';
if (!game) return 'The PS5 does not see a game running, so nothing is being kept on. Kept cheats are switched on by the console when their game starts.';
const lines = [`The PS5 sees ${game.titleId} running (version ${game.version || 'unknown'}, pid ${game.pid}).`];
// A file saved through this server a moment ago may not have been read by the console yet.
const kept = table ? keptEntries(table).length : status.cheats.length;
const behind = !!table && kept !== status.cheats.filter((c) => c.state !== 'refused').length;
if (!status.file && behind) lines.push('The cheat file was only just saved.');
else if (!status.file) lines.push('There is no cheat file for this game on the PS5, so it has nothing to keep on. add_cheat with "keep": true makes one.');
else {
lines.push(
`It is using ${status.file}${status.exact ? '' : ', which was saved for another version of the game: cheats it cannot check against this version (no original bytes, held values) are left off. Saving any cheat with add_cheat makes this version a file of its own'}.`,
);
}
if (status.error) lines.push(status.error);
if (status.busy) lines.push('It is holding back searches of its own while a search from a browser or this server is running.');
const words: Record<string, string> = { on: 'ON', waiting: 'waiting', mismatch: 'does not fit', error: 'problem', refused: 'not allowed' };
if (status.cheats.length === 0 && status.file) lines.push('No cheat in that file is marked to keep on.');
for (const c of status.cheats) lines.push(`[${words[c.state]}] ${c.name}${c.detail ? `: ${c.detail}` : ''}`);
if (behind) lines.push('The console has not read the latest save yet; it does within a couple of seconds. Call kept_status again.');
if (table) {
const could = table.entries.filter((e) => !e.keep && keepProblem(e) === null);
if (could.length) lines.push('', `Not kept, but could be (add_cheat with "keep": true): ${could.map((e) => e.name).join(', ')}`);
}
return lines.join('\n');
},
},
];
// ---- protocol ------------------------------------------------------------------
@@ -739,7 +1177,7 @@ async function handle(message: { id?: number | string; method?: string; params?:
capabilities: { tools: {} },
serverInfo: { name: 'cheatspecter', version: VERSION },
instructions:
'Tools for a jailbroken PS5 running the CheatSpecter payload, for single-player games the user owns. Start with console_status. The usual way to make a cheat: scan_start for a value the user can see, have them change it in the game, scan_next until few matches remain, watch the address while they change it again to find the code, disassemble around it, try a change with write_memory, then add_cheat so it becomes a switch in the CheatSpecter app. Prefer cheats located by a unique byte pattern.',
'Tools for a jailbroken PS5 running the CheatSpecter payload. Start with console_status. The usual way to make a cheat: scan_start for a value the user can see, have them change it in the game, scan_next until few matches remain, watch the address while they change it again to find the code, disassemble around it, try a change with write_memory, then add_cheat so it becomes a switch in the CheatSpecter app. Prefer cheats located by a unique byte pattern. Addresses are printed with their module and offset where they have one ("game+0x1A2B"), and can be given that way. For games whose engine keeps names (engine_info), find_objects, class_info and read_structure get to the right object and field without a search.',
});
return;
case 'ping':
+408
View File
@@ -0,0 +1,408 @@
// The pretend console's assembler.
//
// The real console assembles with Keystone (ps5debug-NG, assemble.c). When
// Keystone's Python module is installed on this computer the mock hands the
// text to the very same assembler, so what tests and the demo console
// produce is what the PS5 would produce:
//
// pip install keystone-engine
//
// Without it, a small assembler written here stands in (see jsAssemble). It
// knows the everyday instructions, which is enough to try code cheats and the
// rule builder on the demo console, and says so when asked for anything more.
import { spawnSync } from 'node:child_process';
import { createRequire } from 'node:module';
const KS_SCRIPT = `
import sys, json
import keystone as K
req = json.load(sys.stdin)
ks = K.Ks(K.KS_ARCH_X86, K.KS_MODE_64)
try:
enc, count = ks.asm(req["text"], int(req["base"]))
print(json.dumps({"hex": bytes(enc or []).hex(), "count": count}))
except K.KsError as e:
print(json.dumps({"error": str(e), "errno": e.errno}))
`;
let keystone = null;
/** True when Keystone can be used from here. Asked once; the answer is kept. */
export function hasKeystone() {
if (keystone === null) {
if (process.env.CS_NO_KEYSTONE) keystone = false;
else {
const r = spawnSync('python3', ['-c', 'import keystone'], { timeout: 20000 });
keystone = r.status === 0;
}
}
return keystone;
}
function keystoneAssemble(text, base) {
const r = spawnSync('python3', ['-c', KS_SCRIPT], { input: JSON.stringify({ text, base: base.toString() }), timeout: 20000, encoding: 'utf8' });
if (r.status !== 0) return { error: 'the assembler could not be started' };
let out;
try {
out = JSON.parse(r.stdout);
} catch {
return { error: 'the assembler gave no answer' };
}
if (out.error) return { error: out.error, errno: out.errno };
return { bytes: Buffer.from(out.hex, 'hex'), count: out.count };
}
const number = (tok) => {
const t = tok.trim();
if (/^-?0x[0-9a-f]+$/i.test(t) || /^-?\d+$/.test(t)) return BigInt(t);
if (/^[0-9][0-9a-f]*h$/i.test(t)) return BigInt('0x' + t.slice(0, -1));
return null;
};
/** The stand-in: two passes, every instruction a fixed size. */
function miniAssemble(text, base) {
const items = [];
const labels = new Map();
let at = 0;
let count = 0;
for (const raw of text.split(/[\n;]/)) {
let line = raw.replace(/(\/\/|#).*$/, '').trim();
for (;;) {
const m = /^([A-Za-z_.$][A-Za-z0-9_.$]*):/.exec(line);
if (!m) break;
labels.set(m[1], at);
line = line.slice(m[0].length).trim();
count++;
}
if (!line) continue;
const [, op, rest = ''] = /^(\S+)\s*(.*)$/.exec(line);
const word = op.toLowerCase();
const args = rest.split(',').map((s) => s.trim()).filter(Boolean);
let size;
if (word === 'nop' || word === 'ret' || word === 'int3') size = 1;
else if (word === 'jmp' && args.length === 1) size = 5;
else if (word === 'db' || word === '.byte') size = args.length;
else if (word === '.word') size = args.length * 2;
else if (word === '.long' || word === '.int') size = args.length * 4;
else if (word === '.quad') size = args.length * 8;
else if (word === '.fill' || word === '.zero' || word === '.space') {
const n = number(args[0] ?? '');
if (n === null || n < 0n || n > 0x100000n) return { error: `invalid operand (${rest})` };
size = Number(n) * (word === '.fill' ? Number(number(args[1] ?? '1') ?? 1n) : 1);
} else if (word === '.balign' || word === '.align') {
const n = Number(number(args[0] ?? '') ?? 0n);
if (n <= 0 || n & (n - 1)) return { error: `invalid operand (${rest})` };
size = (n - (at % n)) % n;
} else return { error: `invalid mnemonic (${word})` };
items.push({ word, args, at, size });
at += size;
count++;
}
const out = Buffer.alloc(at);
const value = (tok) => {
const n = number(tok);
if (n !== null) return n;
if (labels.has(tok)) return base + BigInt(labels.get(tok));
return null;
};
for (const it of items) {
const { word, args } = it;
if (word === 'nop') out[it.at] = 0x90;
else if (word === 'ret') out[it.at] = 0xc3;
else if (word === 'int3') out[it.at] = 0xcc;
else if (word === 'jmp') {
const to = value(args[0]);
if (to === null) return { error: `invalid operand (${args[0]})` };
const rel = to - (base + BigInt(it.at) + 5n);
if (rel < -0x80000000n || rel > 0x7fffffffn) return { error: 'invalid fixup' };
out[it.at] = 0xe9;
out.writeInt32LE(Number(rel), it.at + 1);
} else if (word === '.fill') out.fill(Number((number(args[2] ?? '0') ?? 0n) & 0xffn), it.at, it.at + it.size);
else if (word === '.balign' || word === '.align') out.fill(0x90, it.at, it.at + it.size);
else if (word === '.zero' || word === '.space') out.fill(0, it.at, it.at + it.size);
else {
const width = word === '.quad' ? 8 : word === '.long' || word === '.int' ? 4 : word === '.word' ? 2 : 1;
for (let i = 0; i < args.length; i++) {
const v = value(args[i]);
if (v === null) return { error: `invalid operand (${args[i]})` };
if (width === 1 && (v < -128n || v > 255n)) return { error: `invalid operand (${args[i]})` };
const o = it.at + i * width;
if (width === 8) out.writeBigUInt64LE(BigInt.asUintN(64, v), o);
else out.writeUIntLE(Number(BigInt.asUintN(width * 8, v)), o, width);
}
}
}
return { bytes: out, count };
}
// ---- a small assembler in JavaScript, for when Keystone is not installed -----------------------
//
// So that code cheats can be tried on the demo console on any computer. It
// encodes with iced-x86 (the library the app already uses to decode) and
// knows the everyday instructions code cheats are made of; anything else is
// refused by name. Jumps are always the long form, so its output is valid
// but not byte-for-byte what Keystone gives.
let icedLib = null;
const SIZE_WORDS = { byte: 1, word: 2, dword: 4, qword: 8, xmmword: 16 };
const JCC = { z: 'e', nz: 'ne', nae: 'b', c: 'b', nb: 'ae', nc: 'ae', na: 'be', nbe: 'a', nge: 'l', nl: 'ge', ng: 'le', nle: 'g' };
const ALU = ['add', 'or', 'adc', 'sbb', 'and', 'sub', 'xor', 'cmp', 'mov', 'test'];
const UNARY = ['inc', 'dec', 'neg', 'not'];
const SCALAR = { movss: 4, movsd: 8, addss: 4, subss: 4, mulss: 4, divss: 4, addsd: 8, subsd: 8, mulsd: 8, divsd: 8, comiss: 4, ucomiss: 4, comisd: 8, ucomisd: 8 };
const VECTOR = ['movdqu', 'movups', 'movaps', 'movdqa', 'xorps', 'pxor'];
const cap = (w) => w[0].toUpperCase() + w.slice(1);
class Refused extends Error {}
function jsAssemble(text, base) {
icedLib ??= createRequire(import.meta.url)('iced-x86');
const { Instruction, MemoryOperand, Register, Code, Encoder } = icedLib;
const registers = new Map();
for (const name of Object.keys(Register)) {
if (/^(R[A-D]X|E[A-D]X|[A-D][XLH]|[RE]?[SD]I|[SD]IL|[RE]?[SB]P|[SB]PL|R(8|9|1[0-5])[DWL]?|XMM([0-9]|1[0-5]))$/.test(name)) {
const size = /^XMM/.test(name) ? 16 : /^(R[A-DS0-9]|R[SD]I|RBP)/.test(name) && !/[DWL]$/.test(name) ? 8 : /^E|D$/.test(name) ? 4 : /^([A-D]X|[SD]I|[SB]P)$|W$/.test(name) ? 2 : 1;
// iced spells the low byte of r8..r15 "R8L"; assemblers write "r8b".
registers.set(name.toLowerCase().replace(/^(r\d+)l$/, '$1b'), { reg: Register[name], size, xmm: /^XMM/.test(name) });
}
}
const code = (name) => {
const c = Code[name];
if (c === undefined) throw new Refused('invalid operand');
return c;
};
// ---- reading ----
const items = [];
const labels = new Map();
let count = 0;
for (const raw of text.split(/[\n;]/)) {
let line = raw.replace(/(\/\/|#).*$/, '').trim();
for (;;) {
const m = /^([A-Za-z_.$][A-Za-z0-9_.$]*):/.exec(line);
if (!m) break;
items.push({ label: m[1] });
line = line.slice(m[0].length).trim();
count++;
}
if (!line) continue;
const [, op, rest = ''] = /^(\S+)\s*(.*)$/.exec(line);
const args = [];
let depth = 0, cur = '';
for (const ch of rest) {
if (ch === '[') depth++;
if (ch === ']') depth--;
if (ch === ',' && depth === 0) {
args.push(cur.trim());
cur = '';
} else cur += ch;
}
if (cur.trim()) args.push(cur.trim());
items.push({ op: op.toLowerCase(), args, rest });
count++;
}
const value = (tok, at) => {
const n = number(tok);
if (n !== null) return n;
if (labels.has(tok)) return base + BigInt(labels.get(tok));
if (at === null && /^[A-Za-z_.$][A-Za-z0-9_.$]*$/.test(tok) && items.some((i) => i.label === tok)) return base; // first pass
throw new Refused(`invalid operand (${tok})`);
};
// Set while an instruction is being built: where the next one starts, and whether an operand needs to know.
let nextIp = 0n;
let usesNext = false;
/** One operand: { reg }, { imm }, or { mem: () => MemoryOperand, size } */
const operand = (text, pass) => {
const t = text.trim();
const r = registers.get(t.toLowerCase());
if (r) return { ...r, kind: 'reg' };
const m = /^(?:(byte|word|dword|qword|xmmword)\s+ptr\s+)?\[(.*)\]$/i.exec(t);
if (!m) return { kind: 'imm', imm: value(t, pass) };
let baseReg = Register.None, index = Register.None, scale = 1, disp = 0n, rip = false, plain = true, named = false;
for (const part of m[2].replace(/\s+/g, '').replace(/-/g, '+-').split('+').filter(Boolean)) {
const neg = part.startsWith('-');
const term = neg ? part.slice(1) : part;
const scaled = /^([a-z0-9]+)\*([1248])$/i.exec(term);
const reg = registers.get((scaled ? scaled[1] : term).toLowerCase());
if (term.toLowerCase() === 'rip') rip = true;
else if (reg && !neg) {
plain = false;
if (scaled || baseReg !== Register.None) {
if (index !== Register.None) throw new Refused('invalid operand');
index = reg.reg;
scale = scaled ? Number(scaled[2]) : 1;
} else baseReg = reg.reg;
} else {
if (number(term) === null) named = true;
disp += (neg ? -1n : 1n) * value(term, pass);
}
}
if (rip && !plain) throw new Refused('invalid operand');
const size = m[1] ? SIZE_WORDS[m[1].toLowerCase()] : 0;
// "[rip + label]" and a bare "[address]" both mean that address, reached relative to the instruction.
// "[rip + 5]", with only a number, is that many bytes past the instruction, as Keystone reads it.
const past = rip && !named;
if (past) usesNext = true;
const mem = () =>
rip || plain
? new MemoryOperand(Register.RIP, Register.None, 1, BigInt.asIntN(64, past ? nextIp + disp : disp), 8, false, Register.None)
: new MemoryOperand(baseReg, index, scale, disp, disp === 0n ? 0 : 1, false, Register.None);
return { kind: 'mem', mem, size };
};
const fits = (n, bits) => n >= -(1n << BigInt(bits - 1)) && n < (1n << BigInt(bits - 1));
const imm32 = (n, size) => Number(BigInt.asIntN(Math.min(size, 4) * 8, n));
const build = (it, pass) => {
const { op } = it;
const o = it.args.map((a) => operand(a, pass));
const [a, b] = o;
const none = o.length === 0;
if (none && op === 'nop') return Instruction.create(code('Nopd'));
if (none && op === 'ret') return Instruction.create(code('Retnq'));
if (none && op === 'int3') return Instruction.create(code('Int3'));
if (none && (op === 'pushfq' || op === 'popfq')) return Instruction.create(code(cap(op)));
if ((op === 'push' || op === 'pop') && o.length === 1 && a.kind === 'reg' && a.size === 8) return Instruction.createReg(code(`${cap(op)}_r64`), a.reg);
const jcc = /^j([a-z]+)$/.exec(op);
if ((op === 'jmp' || op === 'call' || jcc) && o.length === 1) {
if (a.kind === 'imm') return Instruction.createBranch(code(op === 'jmp' || op === 'call' ? `${cap(op)}_rel32_64` : `J${JCC[jcc[1]] ?? jcc[1]}_rel32_64`), BigInt.asUintN(64, a.imm));
if (jcc && op !== 'jmp') throw new Refused('invalid operand');
if (a.kind === 'reg' && a.size === 8) return Instruction.createReg(code(`${cap(op)}_rm64`), a.reg);
if (a.kind === 'mem') return Instruction.createMem(code(`${cap(op)}_rm64`), a.mem());
}
if (op === 'lea' && o.length === 2 && a.kind === 'reg' && b.kind === 'mem' && (a.size === 8 || a.size === 4)) return Instruction.createRegMem(code(`Lea_r${a.size * 8}_m`), a.reg, b.mem());
if (op === 'movabs' && o.length === 2 && a.kind === 'reg' && a.size === 8 && b.kind === 'imm') return Instruction.createRegI64(code('Mov_r64_imm64'), a.reg, BigInt.asIntN(64, b.imm));
if (UNARY.includes(op) && o.length === 1 && a.kind !== 'imm' && !a.xmm) {
const s = a.size;
if (!s) throw new Refused('invalid operand');
return a.kind === 'reg' ? Instruction.createReg(code(`${cap(op)}_rm${s * 8}`), a.reg) : Instruction.createMem(code(`${cap(op)}_rm${s * 8}`), a.mem());
}
if (ALU.includes(op) && o.length === 2 && !a.xmm && !b.xmm && a.kind !== 'imm') {
const M = cap(op);
const s = a.kind === 'reg' ? a.size : b.kind === 'reg' ? b.size : a.size;
if (!s || s > 8) throw new Refused('invalid operand');
if ((a.kind === 'reg' && b.kind === 'reg' && a.size !== b.size) || (a.kind === 'mem' && b.kind === 'mem')) throw new Refused('invalid operand');
if ((a.kind === 'mem' && a.size && b.kind === 'reg' && a.size !== b.size) || (b.kind === 'mem' && b.size && a.size !== b.size)) throw new Refused('invalid operand');
const bits = s * 8;
if (b.kind === 'reg') return a.kind === 'reg' ? Instruction.createRegReg(code(`${M}_rm${bits}_r${bits}`), a.reg, b.reg) : Instruction.createMemReg(code(`${M}_rm${bits}_r${bits}`), a.mem(), b.reg);
if (b.kind === 'mem') {
if (op === 'test') throw new Refused('invalid operand');
return Instruction.createRegMem(code(`${M}_r${bits}_rm${bits}`), a.reg, b.mem());
}
const n = b.imm;
if (op === 'mov' && a.kind === 'reg') {
if (s === 8) return fits(n, 32) ? Instruction.createRegI32(code('Mov_rm64_imm32'), a.reg, Number(n)) : Instruction.createRegI64(code('Mov_r64_imm64'), a.reg, BigInt.asIntN(64, n));
return Instruction.createRegI32(code(`Mov_r${bits}_imm${bits}`), a.reg, imm32(n, s));
}
if (s === 8 && !fits(n, 32)) throw new Refused('invalid operand');
const short = op !== 'mov' && op !== 'test' && s > 1 && fits(n, 8);
const name = `${M}_rm${bits}_imm${short ? 8 : Math.min(bits, 32)}`;
return a.kind === 'reg' ? Instruction.createRegI32(code(name), a.reg, imm32(n, s)) : Instruction.createMemI32(code(name), a.mem(), imm32(n, s));
}
if (op in SCALAR && o.length === 2) {
const bits = SCALAR[op] * 8;
if (a.xmm && (b.xmm || b.kind === 'mem')) {
const c = code(`${cap(op)}_xmm_xmmm${bits}`);
return b.xmm ? Instruction.createRegReg(c, a.reg, b.reg) : Instruction.createRegMem(c, a.reg, b.mem());
}
if (a.kind === 'mem' && b.xmm && (op === 'movss' || op === 'movsd')) return Instruction.createMemReg(code(`${cap(op)}_xmmm${bits}_xmm`), a.mem(), b.reg);
}
if (VECTOR.includes(op) && o.length === 2) {
if (a.xmm && (b.xmm || b.kind === 'mem')) {
const c = code(`${cap(op)}_xmm_xmmm128`);
return b.xmm ? Instruction.createRegReg(c, a.reg, b.reg) : Instruction.createRegMem(c, a.reg, b.mem());
}
if (a.kind === 'mem' && b.xmm && op.startsWith('mov')) return Instruction.createMemReg(code(`${cap(op)}_xmmm128_xmm`), a.mem(), b.reg);
}
throw new Refused(ALU.includes(op) || UNARY.includes(op) || op in SCALAR || VECTOR.includes(op) || ['lea', 'push', 'pop', 'jmp', 'call', 'movabs'].includes(op) || jcc ? 'invalid operand' : `invalid mnemonic (${op})`);
};
const DATA = { '.byte': 1, '.word': 2, '.short': 2, '.long': 4, '.int': 4, '.quad': 8 };
const encoder = new Encoder(64);
const emit = (pass) => {
const chunks = [];
let at = 0;
for (const it of items) {
if (it.label !== undefined) {
if (pass === null) labels.set(it.label, at);
continue;
}
let bytes;
if (it.op in DATA) {
const width = DATA[it.op];
bytes = Buffer.alloc(it.args.length * width);
it.args.forEach((arg, i) => {
const v = value(arg, pass);
if (width === 8) bytes.writeBigUInt64LE(BigInt.asUintN(64, v), i * 8);
else bytes.writeUIntLE(Number(BigInt.asUintN(width * 8, v)), i * width, width);
});
} else if (it.op === '.fill' || it.op === '.zero' || it.op === '.space') {
const n = Number(value(it.args[0] ?? '', pass)) * (it.op === '.fill' ? Number(value(it.args[1] ?? '1', pass)) : 1);
if (!(n >= 0 && n <= 0x100000)) throw new Refused('invalid operand');
bytes = Buffer.alloc(n, it.op === '.fill' ? Number(value(it.args[2] ?? '0', pass) & 0xffn) : 0);
} else if (it.op === '.balign' || it.op === '.align') {
const n = Number(value(it.args[0] ?? '', pass));
if (n <= 0 || n & (n - 1)) throw new Refused('invalid operand');
bytes = Buffer.alloc((n - (at % n)) % n, 0x90);
} else if (it.op.startsWith('.')) throw new Refused(`unknown directive (${it.op})`);
else {
const ip = base + BigInt(at);
const once = () => {
const ins = build(it, pass);
try {
encoder.encode(ins, ip);
return Buffer.from(encoder.takeBuffer());
} catch {
throw new Refused('invalid fixup');
} finally {
ins.free();
}
};
usesNext = false;
nextIp = ip;
bytes = once();
if (usesNext) {
// Its length is the same wherever it points, so now the real end of the instruction is known.
nextIp = ip + BigInt(bytes.length);
bytes = once();
}
}
chunks.push(bytes);
at += bytes.length;
}
return Buffer.concat(chunks);
};
try {
// Every instruction has one size whatever its addresses turn out to be, so
// one pass places the labels and a second fills the addresses in.
emit(null);
return { bytes: emit(1), count };
} catch (e) {
if (e instanceof Refused) return { error: e.message };
return { error: 'invalid operand' };
} finally {
encoder.free();
}
}
export { jsAssemble };
/**
* Assembles `text` as if placed at `base` (a bigint). `opts.keystone: false`
* leaves Keystone out even when it is installed.
* Returns { bytes, count } or { error }.
*/
export function assemble(text, base, opts = {}) {
// The stand-in first: it is instant, and it keeps accepting "db", which older tests use.
const mini = miniAssemble(text, base);
if (!mini.error) return mini;
if (opts.keystone !== false && hasKeystone()) return keystoneAssemble(text, base);
const js = jsAssemble(text, base);
if (js.error && /invalid mnemonic/.test(js.error)) {
return { error: `${js.error}: this pretend console only knows the everyday instructions (mov, cmp, add, jumps, calls, push and pop, and the common decimal-number ones). A PS5 assembles any instruction` };
}
return js;
}
+872 -137
View File
File diff suppressed because it is too large. Load diff
+441
View File
@@ -0,0 +1,441 @@
// A small x86-64 interpreter for the pretend game.
//
// The mock's game loop does not execute machine code: each "instruction" of
// the game is a JavaScript function that runs while its bytes are intact.
// That is enough for patches (NOP it out and it stops), but a code cheat
// replaces an instruction with a jump to new code, and whether that new code
// does the right thing can only be seen by running it. This runs it: from the
// hooked instruction, through the injected code (and anything it calls),
// until control is back in the game.
//
// It covers the instructions code cheats are made of (moves, arithmetic,
// compares, jumps, calls, stack, scalar floats), not the whole instruction
// set. Meeting anything else counts as a crash, with the instruction named,
// so a test never passes by accident.
const MASK = { 1: 0xffn, 2: 0xffffn, 4: 0xffffffffn, 8: 0xffffffffffffffffn };
const FLAG = { CF: 1n << 0n, PF: 1n << 2n, ZF: 1n << 6n, SF: 1n << 7n, OF: 1n << 11n };
const ARITH = FLAG.CF | FLAG.PF | FLAG.ZF | FLAG.SF | FLAG.OF;
const XMM = 160; // XMM0 inside the 832-byte floating point block
const HIGH8 = { ah: 'rax', bh: 'rbx', ch: 'rcx', dh: 'rdx' };
const NAMES64 = ['rax', 'rbx', 'rcx', 'rdx', 'rsi', 'rdi', 'rbp', 'rsp', 'r8', 'r9', 'r10', 'r11', 'r12', 'r13', 'r14', 'r15'];
export function createEmulator(iced) {
const { Decoder, DecoderOptions, Mnemonic, OpKind, Register, RegisterExt, MemorySizeExt, ConditionCode } = iced;
const M = Mnemonic;
/** iced register number -> where it lives. */
const regInfo = new Map();
const info = (reg) => {
let r = regInfo.get(reg);
if (r) return r;
const name = String(Register[reg]).toLowerCase();
if (/^xmm\d+$/.test(name)) r = { xmm: Number(name.slice(3)) };
else if (name in HIGH8) r = { full: HIGH8[name], size: 1, shift: 8n };
else if (RegisterExt.isGPR(reg)) {
const full = String(Register[RegisterExt.fullRegister(reg)]).toLowerCase();
if (!NAMES64.includes(full)) throw new Error(`register ${name}`);
r = { full, size: RegisterExt.size(reg), shift: 0n };
} else throw new Error(`register ${name}`);
regInfo.set(reg, r);
return r;
};
const signed = (v, size) => BigInt.asIntN(size * 8, v);
const parity = (v) => {
let b = Number(v & 0xffn);
b ^= b >> 4;
b ^= b >> 2;
b ^= b >> 1;
return (b & 1) === 0;
};
/**
* Runs from `start` until `leave(rip)` says the game's own code has been
* reached again.
*
* mem { read(addr, len) -> Buffer, write(addr, buf) -> boolean, mapped(addr) -> boolean }
* thread the mock's thread object (get/set by 64-bit name, raw, fp, fsBase)
* native (rip) -> length handled, or 0: lets the caller run a stretch of code its own way
*
* Returns { rip } when control left normally, { ret: true } when a `ret`
* was run outside any call made here, or { crash: "why" }.
*/
function run({ mem, thread, start, leave, native, maxSteps = 20000 }) {
let rip = start;
let depth = 0;
const flags = () => thread.get('rflags');
const setFlags = (f) => thread.set('rflags', f);
const put = (mask, value) => setFlags((flags() & ~mask) | value);
const getReg = (reg) => {
const r = info(reg);
if (r.xmm !== undefined) throw new Error('vector register used as a number');
return (thread.get(r.full) >> r.shift) & MASK[r.size];
};
const setReg = (reg, v) => {
const r = info(reg);
if (r.xmm !== undefined) throw new Error('vector register used as a number');
const old = thread.get(r.full);
// Writing a 32-bit register clears the top half; 8 and 16-bit writes leave the rest alone.
if (r.size === 8) thread.set(r.full, v);
else if (r.size === 4) thread.set(r.full, v & MASK[4]);
else thread.set(r.full, (old & ~(MASK[r.size] << r.shift)) | ((v & MASK[r.size]) << r.shift));
};
const xmmOff = (reg) => XMM + info(reg).xmm * 16;
const readMem = (addr, size) => {
if (!mem.mapped(addr)) throw new Fault(`read of unmapped memory at 0x${addr.toString(16)}`);
const b = mem.read(addr, size);
return size === 8 ? b.readBigUInt64LE(0) : BigInt(b.readUIntLE(0, size));
};
const writeMem = (addr, size, v) => {
const b = Buffer.alloc(size);
if (size === 8) b.writeBigUInt64LE(BigInt.asUintN(64, v));
else b.writeUIntLE(Number(v & MASK[size]), 0, size);
if (!mem.write(addr, b)) throw new Fault(`write to unmapped memory at 0x${addr.toString(16)}`);
};
const push = (v) => {
const sp = thread.get('rsp') - 8n;
writeMem(sp, 8, v);
thread.set('rsp', sp);
};
const pop = () => {
const sp = thread.get('rsp');
const v = readMem(sp, 8);
thread.set('rsp', sp + 8n);
return v;
};
const cond = (cc) => {
const f = flags();
const cf = (f & FLAG.CF) !== 0n, zf = (f & FLAG.ZF) !== 0n, sf = (f & FLAG.SF) !== 0n, of = (f & FLAG.OF) !== 0n, pf = (f & FLAG.PF) !== 0n;
switch (cc) {
case ConditionCode.o: return of;
case ConditionCode.no: return !of;
case ConditionCode.b: return cf;
case ConditionCode.ae: return !cf;
case ConditionCode.e: return zf;
case ConditionCode.ne: return !zf;
case ConditionCode.be: return cf || zf;
case ConditionCode.a: return !cf && !zf;
case ConditionCode.s: return sf;
case ConditionCode.ns: return !sf;
case ConditionCode.p: return pf;
case ConditionCode.np: return !pf;
case ConditionCode.l: return sf !== of;
case ConditionCode.ge: return sf === of;
case ConditionCode.le: return zf || sf !== of;
case ConditionCode.g: return !zf && sf === of;
default: throw new Error('condition');
}
};
for (let steps = 0; steps < maxSteps; steps++) {
if (steps > 0 && depth === 0 && leave(rip)) return { rip };
if (!mem.mapped(rip)) return { crash: `jumped to 0x${rip.toString(16)}, where there is no code` };
const done = native ? native(rip) : 0;
if (done > 0) {
rip += BigInt(done);
continue;
}
const decoder = new Decoder(64, mem.read(rip, 16), DecoderOptions.None);
decoder.ip = rip;
const ins = decoder.decode();
try {
if (ins.isInvalid) return { crash: `ran into bytes that are not an instruction at 0x${rip.toString(16)}` };
const next = ins.nextIP;
const m = ins.mnemonic;
const ea = () => {
if (ins.isIpRelMemoryOperand) return ins.ipRelMemoryAddress;
let a = BigInt.asIntN(64, ins.memoryDisplacement);
if (ins.memoryBase !== Register.None) a += getReg(ins.memoryBase);
if (ins.memoryIndex !== Register.None) a += getReg(ins.memoryIndex) * BigInt(ins.memoryIndexScale);
if (ins.memorySegment === Register.FS && ins.hasSegmentPrefix) a += thread.fsBase;
return BigInt.asUintN(64, a);
};
const memSize = () => MemorySizeExt.size(ins.memorySize);
const isReg = (i) => i < ins.opCount && ins.opKind(i) === OpKind.Register;
const isMem = (i) => i < ins.opCount && ins.opKind(i) === OpKind.Memory;
const isXmm = (i) => isReg(i) && info(ins.opRegister(i)).xmm !== undefined;
const sizeOf = (i) => (isReg(i) ? info(ins.opRegister(i)).size : isMem(i) ? memSize() : 0);
const size = sizeOf(0) || sizeOf(1) || 8;
const get = (i, sz = size) => {
if (isReg(i)) return getReg(ins.opRegister(i));
if (isMem(i)) return readMem(ea(), memSize());
return BigInt.asUintN(sz * 8, ins.immediate(i));
};
const set = (i, v) => {
if (isReg(i)) setReg(ins.opRegister(i), v);
else if (isMem(i)) writeMem(ea(), memSize(), v);
else throw new Error('write to a constant');
};
const result = (v, sz, extra = 0n) => {
const r = v & MASK[sz];
put(ARITH, extra | (r === 0n ? FLAG.ZF : 0n) | (r >> BigInt(sz * 8 - 1) ? FLAG.SF : 0n) | (parity(r) ? FLAG.PF : 0n));
return r;
};
const add = (a, b, sz) => {
const sum = a + b;
const r = sum & MASK[sz];
const of = signed(a, sz) + signed(b, sz) !== signed(r, sz);
return result(r, sz, (sum > MASK[sz] ? FLAG.CF : 0n) | (of ? FLAG.OF : 0n));
};
const sub = (a, b, sz) => {
const r = (a - b) & MASK[sz];
const of = signed(a, sz) - signed(b, sz) !== signed(r, sz);
return result(r, sz, (a < b ? FLAG.CF : 0n) | (of ? FLAG.OF : 0n));
};
// Scalar floats: `wide` is double precision.
const fget = (i, wide) => {
if (isXmm(i)) return wide ? thread.fp.readDoubleLE(xmmOff(ins.opRegister(i))) : thread.fp.readFloatLE(xmmOff(ins.opRegister(i)));
const b = mem.read(ea(), wide ? 8 : 4);
if (!mem.mapped(ea())) throw new Fault(`read of unmapped memory at 0x${ea().toString(16)}`);
return wide ? b.readDoubleLE(0) : b.readFloatLE(0);
};
const fset = (i, v, wide) => {
if (isXmm(i)) {
if (wide) thread.fp.writeDoubleLE(v, xmmOff(ins.opRegister(i)));
else thread.fp.writeFloatLE(v, xmmOff(ins.opRegister(i)));
} else {
const b = Buffer.alloc(wide ? 8 : 4);
if (wide) b.writeDoubleLE(v);
else b.writeFloatLE(v);
if (!mem.write(ea(), b)) throw new Fault(`write to unmapped memory at 0x${ea().toString(16)}`);
}
};
const vecGet = (i, n) => {
if (isXmm(i)) return Buffer.from(thread.fp.subarray(xmmOff(ins.opRegister(i)), xmmOff(ins.opRegister(i)) + n));
if (!mem.mapped(ea())) throw new Fault(`read of unmapped memory at 0x${ea().toString(16)}`);
return mem.read(ea(), n);
};
const vecSet = (i, buf, clear) => {
if (isXmm(i)) {
const o = xmmOff(ins.opRegister(i));
if (clear) thread.fp.fill(0, o, o + 16);
buf.copy(thread.fp, o);
} else if (!mem.write(ea(), buf)) throw new Fault(`write to unmapped memory at 0x${ea().toString(16)}`);
};
let target = next;
switch (m) {
case M.Nop:
break;
case M.Int3:
return { crash: `hit an INT3 at 0x${rip.toString(16)}` };
case M.Mov:
set(0, get(1));
break;
case M.Movzx:
set(0, get(1));
break;
case M.Movsx:
case M.Movsxd:
set(0, BigInt.asUintN(64, signed(get(1), sizeOf(1))));
break;
case M.Lea:
set(0, ea());
break;
case M.Add:
set(0, add(get(0), get(1), size));
break;
case M.Sub:
set(0, sub(get(0), get(1), size));
break;
case M.Cmp:
sub(get(0), get(1), size);
break;
case M.Test:
result(get(0) & get(1), size);
break;
case M.And:
set(0, result(get(0) & get(1), size));
break;
case M.Or:
set(0, result(get(0) | get(1), size));
break;
case M.Xor:
set(0, result(get(0) ^ get(1), size));
break;
case M.Inc: {
// inc and dec leave the carry flag alone.
const cf = flags() & FLAG.CF;
set(0, add(get(0), 1n, size));
put(FLAG.CF, cf);
break;
}
case M.Dec: {
const cf = flags() & FLAG.CF;
set(0, sub(get(0), 1n, size));
put(FLAG.CF, cf);
break;
}
case M.Neg:
set(0, sub(0n, get(0), size));
break;
case M.Not:
set(0, ~get(0) & MASK[size]);
break;
case M.Imul: {
if (ins.opCount === 1) throw new Error('one-operand imul');
const a = signed(get(ins.opCount === 3 ? 1 : 0), size);
const b = signed(get(ins.opCount === 3 ? 2 : 1), size);
const p = a * b;
set(0, result(BigInt.asUintN(size * 8, p), size, signed(BigInt.asUintN(size * 8, p), size) !== p ? FLAG.CF | FLAG.OF : 0n));
break;
}
case M.Shl:
case M.Shr:
case M.Sar: {
const n = get(1, 1) & (size === 8 ? 63n : 31n);
if (n === 0n) break;
const a = get(0);
let r, cf;
if (m === M.Shl) {
r = (a << n) & MASK[size];
cf = (a >> (BigInt(size * 8) - n)) & 1n;
} else if (m === M.Shr) {
r = a >> n;
cf = (a >> (n - 1n)) & 1n;
} else {
r = BigInt.asUintN(size * 8, signed(a, size) >> n);
cf = (BigInt.asUintN(size * 8, signed(a, size) >> (n - 1n))) & 1n;
}
set(0, result(r, size, cf ? FLAG.CF : 0n));
break;
}
case M.Xchg: {
const a = get(0), b = get(1);
set(0, b);
set(1, a);
break;
}
case M.Push:
push(isReg(0) || isMem(0) ? get(0) : BigInt.asUintN(64, ins.immediate(0)));
break;
case M.Pop:
set(0, pop());
break;
case M.Pushfq:
push(flags());
break;
case M.Popfq:
setFlags(pop());
break;
case M.Call:
push(next);
depth++;
target = isReg(0) || isMem(0) ? get(0) : ins.nearBranchTarget;
break;
case M.Ret:
if (depth === 0) return { ret: true };
depth--;
target = pop();
break;
case M.Jmp:
target = isReg(0) || isMem(0) ? get(0) : ins.nearBranchTarget;
break;
case M.Movss:
case M.Movsd: {
// The string instruction of the same name has no operands in this form.
if (ins.opCount !== 2) throw new Error('string move');
const n = m === M.Movsd ? 8 : 4;
// Loading from memory clears the rest of the register; register to register does not.
vecSet(0, vecGet(1, n), isMem(1));
break;
}
case M.Movd:
case M.Movq: {
const n = m === M.Movq ? 8 : 4;
if (isXmm(0)) {
const b = Buffer.alloc(n);
if (isXmm(1)) vecGet(1, n).copy(b);
else if (n === 8) b.writeBigUInt64LE(get(1));
else b.writeUInt32LE(Number(get(1) & MASK[4]));
vecSet(0, b, true);
} else {
const b = vecGet(1, n);
set(0, n === 8 ? b.readBigUInt64LE(0) : BigInt(b.readUInt32LE(0)));
}
break;
}
case M.Movaps:
case M.Movups:
case M.Movdqa:
case M.Movdqu:
case M.Movapd:
case M.Movupd:
vecSet(0, vecGet(1, 16), false);
break;
case M.Xorps:
case M.Pxor:
case M.Xorpd: {
const a = vecGet(0, 16), b = vecGet(1, 16);
for (let i = 0; i < 16; i++) a[i] ^= b[i];
vecSet(0, a, false);
break;
}
case M.Addss: fset(0, Math.fround(fget(0, false) + fget(1, false)), false); break;
case M.Subss: fset(0, Math.fround(fget(0, false) - fget(1, false)), false); break;
case M.Mulss: fset(0, Math.fround(fget(0, false) * fget(1, false)), false); break;
case M.Divss: fset(0, Math.fround(fget(0, false) / fget(1, false)), false); break;
case M.Addsd: fset(0, fget(0, true) + fget(1, true), true); break;
case M.Subsd: fset(0, fget(0, true) - fget(1, true), true); break;
case M.Mulsd: fset(0, fget(0, true) * fget(1, true), true); break;
case M.Divsd: fset(0, fget(0, true) / fget(1, true), true); break;
case M.Comiss:
case M.Ucomiss:
case M.Comisd:
case M.Ucomisd: {
const wide = m === M.Comisd || m === M.Ucomisd;
const a = fget(0, wide), b = fget(1, wide);
// Unordered sets all three; otherwise as an unsigned compare.
const f = Number.isNaN(a) || Number.isNaN(b) ? FLAG.ZF | FLAG.PF | FLAG.CF : a < b ? FLAG.CF : a === b ? FLAG.ZF : 0n;
put(ARITH, f);
break;
}
case M.Cvtsi2ss:
fset(0, Math.fround(Number(signed(get(1), sizeOf(1)))), false);
break;
case M.Cvtsi2sd:
fset(0, Number(signed(get(1), sizeOf(1))), true);
break;
case M.Cvttss2si:
case M.Cvttsd2si:
set(0, BigInt.asUintN(64, BigInt(Math.trunc(fget(1, m === M.Cvttsd2si)))));
break;
case M.Cvtss2sd:
fset(0, fget(1, false), true);
break;
case M.Cvtsd2ss:
fset(0, Math.fround(fget(1, true)), false);
break;
default:
if (ins.isJccShortOrNear) {
if (cond(ins.conditionCode)) target = ins.nearBranchTarget;
} else if (/^Set/.test(M[m]) && ins.conditionCode !== ConditionCode.None) {
set(0, cond(ins.conditionCode) ? 1n : 0n);
} else if (/^Cmov/.test(M[m]) && ins.conditionCode !== ConditionCode.None) {
if (cond(ins.conditionCode)) set(0, get(1));
else if (isReg(0) && info(ins.opRegister(0)).size === 4) set(0, get(0));
} else {
return { crash: `ran "${M[m].toLowerCase()}" at 0x${rip.toString(16)}, which the pretend console cannot run` };
}
}
rip = target;
} catch (e) {
if (e instanceof Fault) return { crash: `${e.message} (instruction at 0x${rip.toString(16)})` };
return { crash: `could not run the instruction at 0x${rip.toString(16)} (${e.message})` };
} finally {
ins.free();
decoder.free();
}
}
return { crash: `ran ${maxSteps} instructions of injected code without getting back to the game (an endless loop?)` };
}
return { run };
}
class Fault extends Error {}
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "cheatspecter",
"version": "0.3.0",
"version": "0.4.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "cheatspecter",
"version": "0.3.0",
"version": "0.4.3",
"license": "GPL-3.0-only",
"dependencies": {
"@radix-ui/react-use-controllable-state": "^1.2.6",
+4 -1
View File
@@ -1,10 +1,13 @@
{
"name": "cheatspecter",
"version": "0.3.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",
"type": "module",
"cheatspecter": {
"learnUrl": "https://raw.githubusercontent.com/TiagoVal97/cheatspecter/main/learn/"
},
"engines": {
"node": ">=20.19"
},
+278 -15
View File
@@ -4,12 +4,13 @@
It serves the web app and bridges it to the debugger, so any phone or laptop
on the network just opens `http://<PS5 IP>:8744`.
**Status: builds and passes its tests on a computer; not yet run on a real
PS5.** The same source is compiled natively and exercised against the mock
console (`test/payload.test.mjs`): serving the app, the WebSocket handshake,
large transfers both ways, several connections at once, a missing debugger,
and cross-site rejection. What that cannot show is how it behaves under the
console's own loader and network stack.
**Status.** A 0.3.0 build has been used on a real PS5 for the debugger relay
(`/ws`), the game library and saved cheat files. The cheat engine, the
settings files and the rules for sharing the debugger with a browser have only
run on a computer: the same source is compiled natively and exercised against
the mock console (`test/payload.test.mjs`, `test/engine.test.mjs`,
`test/prefs.test.mjs`). The PS5 build of the current source compiles and
links; it has not been loaded on a console.
## Use it
@@ -32,7 +33,7 @@ is harmless: the second copy sees the port is taken, says so, and exits.
## What it does
| Request | Behaviour |
| Request | Behavior |
| --- | --- |
| `GET /`, `/assets/*`, `/ghost.svg` | The web app, embedded in the ELF and served gzip-compressed. |
| `GET /api/info` | `{"mode":"console","version":"..."}`. The app sees this and skips the "find your PS5" screen. |
@@ -42,15 +43,34 @@ 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. |
| `GET /api/prefs/<name>` | A small settings file the web app saved: 200 with the JSON, or 404 when there is none. |
| `PUT /api/prefs/<name>` | Saves one (a JSON body of at most 64 KB) as `/data/cheatspecter/prefs/<name>.json`. Answers 204. |
It is deliberately thin. All protocol knowledge and file parsing stay in the
web app, and ps5debug-NG keeps doing the kernel work.
Apart from the cheat engine it is deliberately thin. All other protocol
knowledge and file parsing stay in the web app, and ps5debug-NG keeps doing
the kernel work.
WebSocket upgrades, launches and file saves are refused when the `Origin` host
differs from `Host`, so another web page cannot drive the console through a
visitor's browser. Title IDs and versions are checked against a strict pattern
before they are used in a path.
WebSocket upgrades, launches, file saves, settings saves and engine reloads
are refused when the `Origin` host differs from `Host`, so another web page
cannot drive the console through a visitor's browser. Title IDs, versions and
settings names are checked against a strict pattern before they are used in a
path.
Settings exist because what a browser stores belongs to the page's address,
and a console's address can change with every restart. A name is 1 to 32
lower-case letters, digits and dashes; anything else (capitals, dots, slashes,
escapes) is answered 400 and touches no file. The body must be JSON (nested at
most 32 deep), or it is answered 400; more than 64 KB is answered 413. The web
app's side is `src/lib/prefs.ts`.
A connection that has not sent its request within 15 seconds is closed. An
open WebSocket may stay quiet for as long as it likes.
The library is built from the system's metadata folders
(`/system_data/priv/appmeta`, `/user/appmeta`). Sizes are the files found under
@@ -59,6 +79,231 @@ The library is built from the system's metadata folders
none of those is listed without a size; one with more than 200,000 files is
marked `partial`.
## Cheats that stay on by themselves
**Status: passes its tests against the mock console on a computer
(`test/engine.test.mjs`); not yet run on a real PS5.**
A cheat in a `.cspector` file can carry `"keep": true`. The payload keeps
those applied with no browser open: it has its own thread and its own
connection to the debugger on `127.0.0.1:744`.
This section is the contract between the engine (`src/engine.c`) and the web
app (`src/lib/table.ts`, `keep.ts`, `proof.ts`). Both follow it, and
`test/engine.test.mjs` checks each rule against both.
### Which file
Every 2 seconds the engine asks the debugger which game is running. For a new
game, or the same game under a new process id, it forgets everything and reads
`/data/cheatspecter/cheats/<title id>-<version>.cspector`, the version being
the running game's as it appears in file names (`fileVersion()` in
`tablestore.ts`). With no file for the running version it takes the
highest-versioned file for that title, as the web app does when it opens one.
The file counts as **this version's own** only when its name matches the
running game *and* what it says inside agrees: `game.titleId` and
`game.version`, where present, must be the running game's. A file that was
renamed or copied from another version, or from another game, is used with the
same care as an older version's file. `GET /api/engine` reports this as
`"exact"`.
### What is run
Only entries with `"keep": true` (exactly `true`) are looked at.
| Entry | Run? |
| --- | --- |
| `patch`, every place (`more` included) at a module offset, a byte pattern or a pointer path | Yes |
| `value` with `"freeze": true`, at a module offset, a byte pattern or a pointer path | Yes: written again every 250 ms |
| `value` without `"freeze": true` | Refused |
| `inject`, `call`, `script` | Refused |
| Anything with a place at a raw address (`"kind": "address"`) | Refused |
| Anything with `"reviewed": false` | Refused |
| Anything with `"pageOnly": true` (a community cheat only the page can place) | Refused |
| No `name`, or a `type` the web app does not know | Passed over, as the web app passes over it on loading |
These are the rules of `keepProblem()` in `table.ts`, in the same order, so
what the engine refuses is exactly what the web app does not let be kept. A
refused cheat is listed with state `refused` and the reason; it is never
tried.
**Places.** A module offset counts from the named module (found by its name
in the memory map, ignoring case), or from the game's main executable when no
module is named or the name is one the web app takes to mean the game (`game`,
`base`, `eboot`, `eboot.bin`, `executable`, or the process's own name). A byte
pattern must match exactly once in the game's executable memory. A pointer
path is followed afresh on every write.
**`fallback`.** A pattern made by the web app carries the fixed position it
was made at. The engine first looks right there (one small read); if the
pattern is there, that settles it without a search, as in the web app. If not,
it searches the game's code once. If the pattern is then not found, or found
more than once, the fixed position is used, but only for a place whose
original bytes are known (below): a place with nothing to compare against is
not written through a fallback. Only a fallback of kind `module` is used.
**What must be there before anything is written.** Every place of a cheat is
read first. Bytes equal to `on` mean that place is already on. Otherwise:
- With `off` in the file, the bytes must equal `off` (and `off` may not be
longer than `on`).
- Without `off` but with `around`, the stretch of the saved code that the
patch covers is used as `off`; bytes marked `?` in its mask (they depend on
where the game is loaded) are not compared. The code before and after the
patch is not compared, because another kept cheat may have changed it.
- With neither, the place is written unchecked, but only in a file that is
this version's own, and never through a `fallback`.
If any place fails, nothing of that cheat is written and it is reported as
`mismatch`. Code that reads as all zeros is given 20 seconds to load first.
The engine never writes when the bytes found are neither `on` nor the
expected original.
**Another version's file.** When the file in use is not this version's own, a
cheat is only run if it can be checked here: every place needs `off` bytes,
`around`, or a byte pattern. Held values and unchecked patches are reported as
`mismatch` instead of being written blindly into a different build.
**Held values and `choices`.** A held value is encoded exactly as the web app
encodes it (`encodeValue()` in `scanner.ts`); one it would refuse is an
`error`. The engine holds whatever `value` says. `choices` is the web app's
list to pick from: picking one saves the file with the new `value`, which the
engine picks up without undoing or announcing anything.
**Not there yet.** A module still loading, a pointer still null, code not
loaded: tried again later, waiting longer each time (`waiting`). A pattern
search is tried five times over about 22 seconds, then given up (`error`).
### The start set
A file can also have a `startSet`: the cheats to switch on whenever the game
starts, by name, each `{"name": "..."}` for one of the person's own cheats
or `{"name": "...", "from": "<source id>"}` for one saved from a cheat table
(the file's `sources`, each with an `id` and its own `cheats`). The first
cheat of that name in that list is meant. Unlike `keep`, the start set is not
held against the person: they can switch any of it off as usual.
The engine treats a start set cheat like a kept one, with three differences:
- It runs only what it could keep on: anything it would refuse is left to
the web app, which switches it on when it opens on the game (code cheats,
scripts, cheats only the page can place), and it is not listed.
- A held value is written once, when it can be, and then left alone
(`detail` says so); holding it is the page's job while it is open.
- When the start set changes, a cheat that leaves it stays as it is in the
game: the start set only says what to switch on at the start.
Start set cheats are listed with `"start": true`. `startsOnPs5()` in
`table.ts` makes the same choice on the web app's side. A cheat that is both
kept and in the start set is simply kept.
### Changes while the game runs
When the file changes (saved through `PUT /api/tables/...`, changed some other
way and noticed within 2 seconds, or `POST /api/engine/reload`) it is read
again without restarting anything. A cheat that is the same as before keeps
its state, whatever its name. A cheat that is no longer kept has its original
bytes put back, if the engine wrote them and they are still as the engine left
them; a held value simply stops being written. A file that cannot be read
changes nothing: what is on stays on.
A new launch of the game (a new process id) starts from nothing and applies
the file again. When the game closes, nothing more is written.
### Sharing the debugger with a browser
The engine uses only the debugger's plain memory commands (read, write, memory
map, pattern search, "which game is running"). It never attaches, so the
console's one debugging session is always free for the browser, and the engine
goes on working while the browser has the game paused at a breakpoint.
The payload relays every browser connection, so it can tell when a browser is
in the middle of something long with the debugger: a request unanswered for
more than 400 ms, or a long answer still arriving (a memory search). While
that is so the engine starts no pattern search of its own (those cheats say
"Waiting for a search in CheatSpecter to finish"), and does not take a slow
answer to mean the debugger has gone. Held values are still written: that is
one small exchange. `GET /api/engine` reports `"busy": true` meanwhile.
### Crash protection
A cheat switched on less than a minute before the game ended may be why it
ended. The engine notices every game ending (the game gone, or a new launch
of it), page open or not, and marks such cheats in
`/data/cheatspecter/prefs/crash-<title id, lower case>.json`, a settings file
the web app reads and writes through `/api/prefs` too:
```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, could not be switched on (refused ones count there) or were held back
by crash protection. It waits up to
30 seconds for every cheat to settle; cheats that only become possible later
(a pointer path that needs a level loaded) get one more.
### `GET /api/engine`
```json
{"running":true,
"crashGuard":true,
"game":{"titleId":"PPSA18605","version":"01.001.008","pid":137},
"file":"PPSA18605-01.001.008.cspector",
"exact":true,
"cheats":[{"name":"Infinite ammo","state":"on","detail":""}]}
```
- `running` is false when the debugger does not answer (not loaded, or not
ps5debug-NG); then `game` is null and `cheats` is empty.
- `game` is null when no game is running.
- `file` is the cheat file in use, `""` when there is none.
- `exact` is present whenever a file is in use: whether it is this version's
own (see "Which file").
- `busy` is present (and `true`) only while the engine is holding back for a
browser.
- `crashGuard` is always `true`: this engine does crash protection, so the
web app leaves the marking to it. (The bridge's demo stand-in leaves it out,
and the web app marks by itself there.)
- `error` is present, with a sentence for the user, when the file could not
be used or holds more kept cheats than the engine takes (256).
- `cheats` lists every entry marked to keep and every start set cheat the
engine runs (see "The start set"; those carry `"start": true`): the
person's own in the file's order, then those from `sources`. `state` is `on`,
`waiting`, `mismatch`, `error`, `refused` or `held`, and `detail` says why in
a sentence for anything that is not on.
The web app matches a cheat to its line by name; cheats with the same name are
told apart by their order, not counting refused ones (`keptStatus()` in
`keep.ts`).
The dev bridge passes both engine requests on to the console. Its demo
console has no engine, so the bridge answers `GET /api/engine` itself in the
same shape (`demoEngineStatus()` in `bridge/server.mjs`): the kept cheats of
the file the app saved, refused ones by the same rules, the rest as `waiting`
with a detail saying the demo does not apply them.
Limits: 256 kept cheats per game, 64 places per patch, 4096 bytes per place,
256 bytes per pattern, 16 offsets per pointer path, files up to 4 MB.
## Build it
```sh
@@ -69,6 +314,24 @@ PS5_PAYLOAD_SDK=/opt/ps5-payload-sdk npm run payload # cheatspecter.elf
Both embed whatever `npm run build` produced, so the ELF needs rebuilding
after any change to the web app. The PS5 build needs
[ps5-payload-sdk](https://github.com/ps5-payload-dev/sdk) with clang and lld.
With `CS_PAYLOAD_OUT=<folder>` the PS5 build is written to that folder and the
ELFs here are left alone, which is the way to check that a change still
compiles and links for the console.
`src/main.c` is the whole thing: an HTTP server, a WebSocket relay with one
thread per direction, and SHA-1 and Base64 for the handshake.
`src/main.c` is the server: HTTP, a WebSocket relay with one thread per
direction, and SHA-1 and Base64 for the handshake. The cheat engine is
`src/engine.c`, on top of `src/dbgclient.c` (the few debugger commands it
needs, spoken from C) and `src/json.c` (a small strict JSON reader).
The threads: one accepts connections and starts one per request (two for a
`/ws` relay, one per direction); one is the cheat engine; one listens for
debugger stop reports. The engine wakes every 50 ms, shares nothing with the
others but a status snapshot and a "read the file again" flag behind one lock
held only to copy them, and has its own debugger connection with a time limit
on every exchange. So a stuck engine cannot hold up a request, and a busy
server cannot hold up the engine.
On a computer, `CS_ROOT` stands in for the console's disk,
`CS_ENGINE_SPEED=10` makes every wait of the engine ten times shorter (which
is how the tests get through a "30 second" wait in three), and
`CS_IDLE_SECONDS` sets how long a silent connection is kept.
+336
View File
@@ -0,0 +1,336 @@
/* SPDX-License-Identifier: GPL-3.0-only
*
* See dbgclient.h.
*
* Rules of the wire format that are easy to get wrong (PROTOCOL.md sections
* 1.4, 1.6 and 8):
* - every request starts with a 12-byte header: magic, command, body length;
* - status words arrive with neighboring bits swapped, so success reads as
* 0x80000000; everything else is plain little-endian;
* - writes and searches answer with more than one status word;
* - after an early refusal the debugger closes the connection.
*/
#include "dbgclient.h"
#include <arpa/inet.h>
#include <errno.h>
#include <netinet/in.h>
#include <netinet/tcp.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <sys/time.h>
#include <sys/types.h>
#include <unistd.h>
#ifndef MSG_NOSIGNAL
#define MSG_NOSIGNAL 0
#endif
#define PACKET_MAGIC 0xFFAABBCCu
#define WIRE_SUCCESS 0x80000000u
#define CMD_PROC_READ 0xBDAA0002u
#define CMD_PROC_WRITE 0xBDAA0003u
#define CMD_PROC_MAPS 0xBDAA0004u
#define CMD_PROC_SCAN_AOB 0xBDAA0501u
#define CMD_PROC_WRITE_MULTI 0xBDAACC04u
#define CMD_CONSOLE_FOREGROUND_APP 0xBDDD0006u
/* Long enough for a slow answer, short enough that a debugger that never answers is noticed. */
#define QUICK_SECONDS 5
/* A pattern search reads through up to a gigabyte of the game before it answers. */
#define SEARCH_SECONDS 120
#define MAP_ENTRY_SIZE 58
#define APP_REPLY_SIZE 140
static void put32(uint8_t *p, uint32_t v) {
p[0] = (uint8_t)v;
p[1] = (uint8_t)(v >> 8);
p[2] = (uint8_t)(v >> 16);
p[3] = (uint8_t)(v >> 24);
}
static void put64(uint8_t *p, uint64_t v) {
put32(p, (uint32_t)v);
put32(p + 4, (uint32_t)(v >> 32));
}
static uint32_t get32(const uint8_t *p) {
return (uint32_t)p[0] | ((uint32_t)p[1] << 8) | ((uint32_t)p[2] << 16) | ((uint32_t)p[3] << 24);
}
static uint64_t get64(const uint8_t *p) {
return (uint64_t)get32(p) | ((uint64_t)get32(p + 4) << 32);
}
void dbg_init(struct dbg *d) {
d->fd = -1;
}
int dbg_is_open(const struct dbg *d) {
return d->fd >= 0;
}
void dbg_close(struct dbg *d) {
if (d->fd >= 0) close(d->fd);
d->fd = -1;
}
static int lost(struct dbg *d) {
dbg_close(d);
return DBG_LOST;
}
static void set_time_limit(struct dbg *d, int seconds) {
struct timeval tv = {seconds, 0};
setsockopt(d->fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof tv);
setsockopt(d->fd, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof tv);
}
int dbg_open(struct dbg *d, int port) {
dbg_close(d);
int fd = socket(AF_INET, SOCK_STREAM, 0);
if (fd < 0) return DBG_LOST;
struct sockaddr_in addr;
memset(&addr, 0, sizeof addr);
addr.sin_family = AF_INET;
addr.sin_port = htons((uint16_t)port);
addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
int one = 1;
setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, &one, sizeof one);
#ifdef SO_NOSIGPIPE
setsockopt(fd, SOL_SOCKET, SO_NOSIGPIPE, &one, sizeof one);
#endif
d->fd = fd;
set_time_limit(d, QUICK_SECONDS);
if (connect(fd, (struct sockaddr *)&addr, sizeof addr) != 0) return lost(d);
return DBG_OK;
}
/* Both return 0 or -1; a time limit that runs out counts as failure. */
static int send_all(struct dbg *d, const void *buf, size_t len) {
const uint8_t *p = buf;
while (len > 0) {
ssize_t n = send(d->fd, p, len, MSG_NOSIGNAL);
if (n < 0 && errno == EINTR) continue;
if (n <= 0) return -1;
p += n;
len -= (size_t)n;
}
return 0;
}
static int recv_all(struct dbg *d, void *buf, size_t len) {
uint8_t *p = buf;
while (len > 0) {
ssize_t n = recv(d->fd, p, len, 0);
if (n < 0 && errno == EINTR) continue;
if (n <= 0) return -1;
p += n;
len -= (size_t)n;
}
return 0;
}
/* Sends the header and body as one piece, so the debugger never sees half a request. */
static int send_request(struct dbg *d, uint32_t cmd, const uint8_t *body, uint32_t body_len) {
uint8_t buf[12 + 32];
if (d->fd < 0 || body_len > 32) return -1;
put32(buf, PACKET_MAGIC);
put32(buf + 4, cmd);
put32(buf + 8, body_len);
if (body_len) memcpy(buf + 12, body, body_len);
return send_all(d, buf, 12 + body_len);
}
/*
* Reads one status word. DBG_REFUSED leaves the connection open: the caller
* closes it where the debugger would have (see `refused`).
*/
static int read_status(struct dbg *d) {
uint8_t raw[4];
if (recv_all(d, raw, 4) != 0) return lost(d);
return get32(raw) == WIRE_SUCCESS ? DBG_OK : DBG_REFUSED;
}
/* For a refusal after which the debugger hangs up: hang up too, so nothing is read from a dead stream. */
static int refused(struct dbg *d) {
dbg_close(d);
return DBG_REFUSED;
}
/* Copies a fixed-width text field that may not be NUL-terminated. `cap` includes the NUL. */
static void copy_field(char *out, size_t cap, const uint8_t *field, size_t width) {
size_t n = 0;
while (n < width && n + 1 < cap && field[n]) {
out[n] = (char)field[n];
n++;
}
out[n] = 0;
}
int dbg_foreground(struct dbg *d, struct dbg_app *out) {
uint8_t raw[APP_REPLY_SIZE];
memset(out, 0, sizeof *out);
if (d->fd < 0) return DBG_LOST;
set_time_limit(d, QUICK_SECONDS);
if (send_request(d, CMD_CONSOLE_FOREGROUND_APP, NULL, 0) != 0) return lost(d);
int st = read_status(d);
if (st == DBG_REFUSED) return refused(d);
if (st != DBG_OK) return st;
if (recv_all(d, raw, sizeof raw) != 0) return lost(d);
out->pid = get32(raw);
copy_field(out->title_id, sizeof out->title_id, raw + 4, 16);
copy_field(out->name, sizeof out->name, raw + 84, 40);
copy_field(out->version, sizeof out->version, raw + 124, 16);
return DBG_OK;
}
int dbg_maps(struct dbg *d, uint32_t pid, struct dbg_map **out, uint32_t *count) {
uint8_t body[4], raw[4];
*out = NULL;
*count = 0;
if (d->fd < 0) return DBG_LOST;
set_time_limit(d, QUICK_SECONDS);
put32(body, pid);
if (send_request(d, CMD_PROC_MAPS, body, sizeof body) != 0) return lost(d);
int st = read_status(d);
/* A process that has gone, or has not finished starting. */
if (st == DBG_REFUSED) return refused(d);
if (st != DBG_OK) return st;
if (recv_all(d, raw, 4) != 0) return lost(d);
uint32_t n = get32(raw);
if (n > DBG_MAX_MAPS) return lost(d);
struct dbg_map *maps = calloc(n ? n : 1, sizeof *maps);
uint8_t *wire = malloc((size_t)(n ? n : 1) * MAP_ENTRY_SIZE);
if (!maps || !wire || recv_all(d, wire, (size_t)n * MAP_ENTRY_SIZE) != 0) {
/* The reply was not read to its end, so the stream cannot be used again. */
free(maps);
free(wire);
return lost(d);
}
for (uint32_t i = 0; i < n; i++) {
const uint8_t *e = wire + (size_t)i * MAP_ENTRY_SIZE;
copy_field(maps[i].name, sizeof maps[i].name, e, 32);
maps[i].start = get64(e + 32);
maps[i].end = get64(e + 40);
maps[i].prot = (uint16_t)(e[56] | (e[57] << 8));
}
free(wire);
*out = maps;
*count = n;
return DBG_OK;
}
int dbg_read(struct dbg *d, uint32_t pid, uint64_t address, uint8_t *out, uint32_t length) {
uint8_t body[16];
if (d->fd < 0) return DBG_LOST;
set_time_limit(d, QUICK_SECONDS);
put32(body, pid);
put64(body + 4, address);
put32(body + 12, length);
if (send_request(d, CMD_PROC_READ, body, sizeof body) != 0) return lost(d);
int st = read_status(d);
if (st == DBG_REFUSED) return refused(d);
if (st != DBG_OK) return st;
if (recv_all(d, out, length) != 0) return lost(d);
return DBG_OK;
}
int dbg_write(struct dbg *d, uint32_t pid, uint64_t address, const uint8_t *data, uint32_t length) {
uint8_t body[16];
if (d->fd < 0) return DBG_LOST;
set_time_limit(d, QUICK_SECONDS);
put32(body, pid);
put64(body + 4, address);
put32(body + 12, length);
if (send_request(d, CMD_PROC_WRITE, body, sizeof body) != 0) return lost(d);
int st = read_status(d);
if (st == DBG_REFUSED) return refused(d);
if (st != DBG_OK) return st;
if (send_all(d, data, length) != 0) return lost(d);
/* The second status says whether the bytes went in; the connection stays open either way. */
return read_status(d);
}
int dbg_write_many(struct dbg *d, uint32_t pid, struct dbg_write *entries, uint32_t count) {
uint8_t body[12];
if (d->fd < 0) return DBG_LOST;
if (count == 0) return DBG_OK;
/* Going over either limit makes the debugger drop the connection mid-stream. */
if (count > DBG_MAX_WRITES) return DBG_REFUSED;
size_t total = 0;
for (uint32_t i = 0; i < count; i++) {
if (entries[i].length > DBG_MAX_WRITE_LEN) return DBG_REFUSED;
total += 12 + (size_t)entries[i].length;
}
uint8_t *stream = malloc(total);
uint8_t *status = malloc(count);
if (!stream || !status) {
free(stream);
free(status);
return DBG_REFUSED;
}
size_t off = 0;
for (uint32_t i = 0; i < count; i++) {
put64(stream + off, entries[i].address);
put32(stream + off + 8, entries[i].length);
memcpy(stream + off + 12, entries[i].data, entries[i].length);
off += 12 + (size_t)entries[i].length;
}
set_time_limit(d, QUICK_SECONDS);
put32(body, pid);
put32(body + 4, count);
put32(body + 8, 1); /* ask for one result byte per entry, to know which of them failed */
int st = DBG_LOST;
if (send_request(d, CMD_PROC_WRITE_MULTI, body, sizeof body) != 0) {
lost(d);
} else if ((st = read_status(d)) == DBG_REFUSED) {
refused(d);
} else if (st == DBG_OK) {
if (send_all(d, stream, total) != 0 || recv_all(d, status, count) != 0) {
st = lost(d);
} else {
for (uint32_t i = 0; i < count; i++) entries[i].failed = status[i];
/* With per-entry results the closing status is always success; it still has to be read. */
st = read_status(d) == DBG_LOST ? DBG_LOST : DBG_OK;
}
}
free(stream);
free(status);
return st;
}
int dbg_find_bytes(struct dbg *d, uint32_t pid, uint64_t address, uint32_t length, const uint8_t *pattern,
const uint8_t *mask, uint32_t pattern_length, uint8_t nth, uint64_t *found) {
uint8_t body[22], raw[8];
*found = 0;
if (d->fd < 0) return DBG_LOST;
/* A range shorter than the pattern makes the debugger compare against memory it never read. */
if (pattern_length == 0 || length < pattern_length) return DBG_REFUSED;
set_time_limit(d, SEARCH_SECONDS);
put32(body, pid);
put64(body + 4, address);
put32(body + 12, length);
body[16] = nth;
body[17] = 0; /* do not stop at a second match: uniqueness is checked by asking for the second one */
put32(body + 18, pattern_length);
if (send_request(d, CMD_PROC_SCAN_AOB, body, sizeof body) != 0) return lost(d);
int st = read_status(d);
if (st == DBG_REFUSED) return refused(d);
if (st != DBG_OK) return st;
if (send_all(d, pattern, pattern_length) != 0 || send_all(d, mask, pattern_length) != 0) return lost(d);
st = read_status(d);
if (st == DBG_REFUSED) return refused(d);
if (st != DBG_OK) return st;
if (recv_all(d, raw, 8) != 0) return lost(d);
st = read_status(d);
if (st == DBG_REFUSED) return refused(d);
if (st != DBG_OK) return st;
*found = get64(raw);
return DBG_OK;
}
+92
View File
@@ -0,0 +1,92 @@
/* SPDX-License-Identifier: GPL-3.0-only
*
* A small client for the debugger (ps5debug-NG) that runs on this console,
* covering only what the cheat engine needs. The wire format follows
* PROTOCOL.md in ps5debug-NG (v1.3.2); src/lib/ps5debug.ts is the full client.
*
* One struct dbg is one TCP connection, used by one thread. Every call is a
* whole request/response exchange with a time limit.
*
* Results: DBG_OK; DBG_REFUSED when the debugger answered "no" and the
* connection is still usable; DBG_LOST when the connection is gone (refused,
* timed out, or dropped by the debugger, which is what it does after most
* errors). After DBG_LOST the connection is already closed: open it again.
*/
#ifndef CS_DBGCLIENT_H
#define CS_DBGCLIENT_H
#include <stddef.h>
#include <stdint.h>
#define DBG_OK 0
#define DBG_REFUSED 1
#define DBG_LOST (-1)
/* Memory protection bits in a map entry. */
#define DBG_PROT_READ 1
#define DBG_PROT_WRITE 2
#define DBG_PROT_EXEC 4
/* More maps than any game has; a count above this means the stream is out of step. */
#define DBG_MAX_MAPS 200000
/* The debugger's own limits for one write-many exchange. */
#define DBG_MAX_WRITES 0xFFFF
#define DBG_MAX_WRITE_LEN 0x100000
struct dbg {
int fd; /* -1 when closed */
};
struct dbg_app {
uint32_t pid; /* 0 when no game is in the foreground */
char title_id[17];
char name[41];
char version[17];
};
struct dbg_map {
char name[33];
uint64_t start, end;
uint16_t prot;
};
struct dbg_write {
uint64_t address;
const uint8_t *data;
uint32_t length;
uint8_t failed; /* out: non-zero when the debugger could not write this one */
};
void dbg_init(struct dbg *d);
int dbg_is_open(const struct dbg *d);
/* Connects to 127.0.0.1:port. Returns DBG_OK or DBG_LOST. */
int dbg_open(struct dbg *d, int port);
void dbg_close(struct dbg *d);
/* The game on screen. ps5debug-NG only: other debuggers never answer, which ends as DBG_LOST. */
int dbg_foreground(struct dbg *d, struct dbg_app *out);
/* The memory map of a process. *out is malloc'd; the caller frees it. */
int dbg_maps(struct dbg *d, uint32_t pid, struct dbg_map **out, uint32_t *count);
/* Reads memory. Unreadable memory comes back as zeros; the debugger does not flag it. */
int dbg_read(struct dbg *d, uint32_t pid, uint64_t address, uint8_t *out, uint32_t length);
/* Writes memory. DBG_REFUSED when the debugger could not write all of it. */
int dbg_write(struct dbg *d, uint32_t pid, uint64_t address, const uint8_t *data, uint32_t length);
/*
* Writes several values in one exchange. Each entry's `failed` is filled in;
* DBG_OK means the exchange completed, not that every entry was written.
*/
int dbg_write_many(struct dbg *d, uint32_t pid, struct dbg_write *entries, uint32_t count);
/*
* Searches [address, address + length) for a byte pattern; mask[i] is 1 where
* the byte must match. *found is the address of the nth match (1 = first), or
* 0 when there are fewer. Slow on large ranges, so it has its own long time limit.
*/
int dbg_find_bytes(struct dbg *d, uint32_t pid, uint64_t address, uint32_t length, const uint8_t *pattern,
const uint8_t *mask, uint32_t pattern_length, uint8_t nth, uint64_t *found);
#endif
+2656
View File
File diff suppressed because it is too large. Load diff
+51
View File
@@ -0,0 +1,51 @@
/* SPDX-License-Identifier: GPL-3.0-only
*
* The cheat engine: keeps the cheats marked "keep" in the user's .cspector
* file applied to the running game by itself, with no browser open.
*
* It runs on one thread of its own with one connection of its own to the
* debugger. The HTTP server never waits on it: engine_status_json hands out a
* snapshot the engine thread prepared earlier, and engine_reload only leaves
* a note for that thread.
*/
#ifndef CS_ENGINE_H
#define CS_ENGINE_H
/* Where the user's cheat files live on the console: <title id>-<version>.cspector */
#define CS_TABLES_DIR "/data/cheatspecter/cheats"
struct engine_config {
/* Prefix for every console path: "" on the console, a test folder on a computer. Must stay valid. */
const char *root;
/* The debugger's TCP port on this console (744). */
int debugger_port;
/* Shows a notification on the TV. Called from the engine thread. */
void (*notify)(const char *text);
/* 1 on the console. Tests set it higher to make every wait that many times shorter. */
int speed;
/*
* Non-zero while a browser is in the middle of something long with the
* debugger (it has been waiting on an answer, or is being sent a long one).
* Called from the engine thread every tick, so it must be quick. May be NULL.
*/
int (*browser_busy)(void);
};
/* Starts the engine thread. Returns 0 on success; on failure the rest of the payload carries on without it. */
int engine_start(const struct engine_config *config);
/* Asks the engine to read the cheat file again as soon as it can. Returns at once; safe from any thread. */
void engine_reload(void);
/*
* What the engine is doing, as the JSON body of GET /api/engine:
* {"running":true,"game":{"titleId":"..","version":"..","pid":1}|null,"file":"..",
* "cheats":[{"name":"..","state":"on"|"waiting"|"mismatch"|"error"|"refused","detail":".."}]}
* plus "exact":true|false while a file is in use, "busy":true while the engine
* is holding back for a browser, and "error":".." when the cheat file could
* not be used. payload/README.md describes every field.
* The caller frees the result. NULL when out of memory.
*/
char *engine_status_json(void);
#endif
+365
View File
@@ -0,0 +1,365 @@
/* SPDX-License-Identifier: GPL-3.0-only
*
* See json.h. Strict RFC 8259: no comments, no trailing commas, no bare
* control characters in strings, numbers in the JSON form only.
*/
#include "json.h"
#include <stdlib.h>
#include <string.h>
static int fail(struct json_reader *r) {
r->failed = 1;
return -1;
}
static void skip_ws(struct json_reader *r) {
while (r->p < r->end && (*r->p == ' ' || *r->p == '\t' || *r->p == '\n' || *r->p == '\r')) r->p++;
}
void json_init(struct json_reader *r, const char *text, size_t len) {
r->p = text;
r->end = text + len;
r->depth = 0;
r->failed = 0;
if (len >= 3 && (unsigned char)text[0] == 0xEF && (unsigned char)text[1] == 0xBB && (unsigned char)text[2] == 0xBF) r->p += 3;
}
enum json_type json_peek(struct json_reader *r) {
if (r->failed) return JSON_INVALID;
skip_ws(r);
if (r->p >= r->end) return JSON_INVALID;
switch (*r->p) {
case '{':
return JSON_OBJECT;
case '[':
return JSON_ARRAY;
case '"':
return JSON_STRING;
case 't':
return JSON_TRUE;
case 'f':
return JSON_FALSE;
case 'n':
return JSON_NULL;
default:
return (*r->p == '-' || (*r->p >= '0' && *r->p <= '9')) ? JSON_NUMBER : JSON_INVALID;
}
}
static int open_container(struct json_reader *r, char c) {
if (r->failed) return -1;
skip_ws(r);
if (r->p >= r->end || *r->p != c || r->depth >= JSON_MAX_DEPTH) return fail(r);
r->p++;
r->depth++;
return 0;
}
/* Handles the comma or closing bracket between members. Returns 1 when another member follows. */
static int next_member(struct json_reader *r, int first, char close) {
if (r->failed) return -1;
skip_ws(r);
if (r->p >= r->end) return fail(r);
if (*r->p == close) {
r->p++;
r->depth--;
return 0;
}
if (!first) {
if (*r->p != ',') return fail(r);
r->p++;
skip_ws(r);
/* A closing bracket straight after a comma is a trailing comma. */
if (r->p >= r->end || *r->p == close) return fail(r);
}
return 1;
}
int json_object_begin(struct json_reader *r) {
return open_container(r, '{');
}
int json_array_begin(struct json_reader *r) {
return open_container(r, '[');
}
int json_array_next(struct json_reader *r, int first) {
return next_member(r, first, ']');
}
int json_object_next(struct json_reader *r, int first, char *key, size_t cap) {
int more = next_member(r, first, '}');
if (more <= 0) return more;
long len = json_string(r, key, cap);
if (len < 0) return -1;
if (cap > 0 && (size_t)len >= cap) key[0] = 0;
skip_ws(r);
if (r->p >= r->end || *r->p != ':') return fail(r);
r->p++;
return 1;
}
static int hex4(const char *p, unsigned *out) {
unsigned v = 0;
for (int i = 0; i < 4; i++) {
char c = p[i];
v <<= 4;
if (c >= '0' && c <= '9') v |= (unsigned)(c - '0');
else if (c >= 'a' && c <= 'f') v |= (unsigned)(c - 'a' + 10);
else if (c >= 'A' && c <= 'F') v |= (unsigned)(c - 'A' + 10);
else return -1;
}
*out = v;
return 0;
}
/* Appends one character as UTF-8, or nothing at all when it would not fit whole. */
static void put_utf8(char *out, size_t cap, size_t *stored, long *total, unsigned cp) {
char buf[4];
size_t n;
if (cp == 0) cp = ' ';
if (cp < 0x80) {
buf[0] = (char)cp;
n = 1;
} else if (cp < 0x800) {
buf[0] = (char)(0xC0 | (cp >> 6));
buf[1] = (char)(0x80 | (cp & 0x3F));
n = 2;
} else if (cp < 0x10000) {
buf[0] = (char)(0xE0 | (cp >> 12));
buf[1] = (char)(0x80 | ((cp >> 6) & 0x3F));
buf[2] = (char)(0x80 | (cp & 0x3F));
n = 3;
} else {
buf[0] = (char)(0xF0 | (cp >> 18));
buf[1] = (char)(0x80 | ((cp >> 12) & 0x3F));
buf[2] = (char)(0x80 | ((cp >> 6) & 0x3F));
buf[3] = (char)(0x80 | (cp & 0x3F));
n = 4;
}
/* Once one character has been dropped nothing later is stored, so the copy is a clean prefix. */
if (out && (size_t)*total == *stored && *stored + n < cap) {
memcpy(out + *stored, buf, n);
*stored += n;
}
*total += (long)n;
}
/* Length of the well-formed UTF-8 sequence at p (1-4), or 0 when it is not one. */
static size_t utf8_len(const unsigned char *p, const unsigned char *end, unsigned *cp) {
unsigned c = p[0];
size_t n;
unsigned v, min;
if (c < 0x80) {
*cp = c;
return 1;
}
if (c >= 0xC2 && c <= 0xDF) {
n = 2;
v = c & 0x1F;
min = 0x80;
} else if (c >= 0xE0 && c <= 0xEF) {
n = 3;
v = c & 0x0F;
min = 0x800;
} else if (c >= 0xF0 && c <= 0xF4) {
n = 4;
v = c & 0x07;
min = 0x10000;
} else {
return 0;
}
if ((size_t)(end - p) < n) return 0;
for (size_t i = 1; i < n; i++) {
if ((p[i] & 0xC0) != 0x80) return 0;
v = (v << 6) | (p[i] & 0x3F);
}
if (v < min || v > 0x10FFFF || (v >= 0xD800 && v <= 0xDFFF)) return 0;
*cp = v;
return n;
}
long json_string(struct json_reader *r, char *out, size_t cap) {
size_t stored = 0;
long total = 0;
if (out && cap > 0) out[0] = 0;
if (r->failed) return -1;
skip_ws(r);
if (r->p >= r->end || *r->p != '"') return fail(r);
const char *p = r->p + 1;
for (;;) {
if (p >= r->end) return fail(r);
unsigned char c = (unsigned char)*p;
if (c == '"') {
p++;
break;
}
if (c < 0x20) return fail(r);
if (c == '\\') {
if (p + 1 >= r->end) return fail(r);
char e = p[1];
unsigned cp;
p += 2;
switch (e) {
case '"':
case '\\':
case '/':
cp = (unsigned char)e;
break;
case 'b':
cp = '\b';
break;
case 'f':
cp = '\f';
break;
case 'n':
cp = '\n';
break;
case 'r':
cp = '\r';
break;
case 't':
cp = '\t';
break;
case 'u': {
if (r->end - p < 4 || hex4(p, &cp) != 0) return fail(r);
p += 4;
if (cp >= 0xD800 && cp <= 0xDBFF) {
/* The first half of a pair; the second half has to follow as another \u escape. */
unsigned lo;
if (r->end - p >= 6 && p[0] == '\\' && p[1] == 'u' && hex4(p + 2, &lo) == 0 && lo >= 0xDC00 && lo <= 0xDFFF) {
cp = 0x10000 + ((cp - 0xD800) << 10) + (lo - 0xDC00);
p += 6;
} else {
cp = 0xFFFD;
}
} else if (cp >= 0xDC00 && cp <= 0xDFFF) {
cp = 0xFFFD;
}
break;
}
default:
return fail(r);
}
put_utf8(out, cap, &stored, &total, cp);
continue;
}
unsigned cp;
size_t n = utf8_len((const unsigned char *)p, (const unsigned char *)r->end, &cp);
if (n == 0) {
/* Bytes that are not text: what a browser would show as a replacement character. */
cp = 0xFFFD;
n = 1;
}
put_utf8(out, cap, &stored, &total, cp);
p += n;
}
if (out && cap > 0) out[stored] = 0;
r->p = p;
return total;
}
/* Checks the JSON number grammar and returns where the number ends, or NULL. */
static const char *scan_number(const char *p, const char *end) {
if (p < end && *p == '-') p++;
if (p >= end) return NULL;
if (*p == '0') {
p++;
} else if (*p >= '1' && *p <= '9') {
while (p < end && *p >= '0' && *p <= '9') p++;
} else {
return NULL;
}
if (p < end && *p == '.') {
p++;
if (p >= end || *p < '0' || *p > '9') return NULL;
while (p < end && *p >= '0' && *p <= '9') p++;
}
if (p < end && (*p == 'e' || *p == 'E')) {
p++;
if (p < end && (*p == '+' || *p == '-')) p++;
if (p >= end || *p < '0' || *p > '9') return NULL;
while (p < end && *p >= '0' && *p <= '9') p++;
}
return p;
}
int json_number(struct json_reader *r, double *out) {
char buf[64];
if (r->failed) return -1;
skip_ws(r);
const char *stop = scan_number(r->p, r->end);
if (!stop) return fail(r);
size_t n = (size_t)(stop - r->p);
/* Longer than any number a cheat file has a use for. */
if (n >= sizeof buf) return fail(r);
memcpy(buf, r->p, n);
buf[n] = 0;
if (out) *out = strtod(buf, NULL);
r->p = stop;
return 0;
}
static int literal(struct json_reader *r, const char *word) {
size_t n = strlen(word);
if ((size_t)(r->end - r->p) < n || memcmp(r->p, word, n) != 0) return fail(r);
r->p += n;
return 0;
}
int json_bool(struct json_reader *r, int *out) {
switch (json_peek(r)) {
case JSON_TRUE:
*out = 1;
return literal(r, "true");
case JSON_FALSE:
*out = 0;
return literal(r, "false");
default:
return fail(r);
}
}
int json_skip(struct json_reader *r) {
switch (json_peek(r)) {
case JSON_OBJECT: {
char key[2];
int first = 1, more;
if (json_object_begin(r) != 0) return -1;
while ((more = json_object_next(r, first, key, sizeof key)) > 0) {
first = 0;
if (json_skip(r) != 0) return -1;
}
return more;
}
case JSON_ARRAY: {
int first = 1, more;
if (json_array_begin(r) != 0) return -1;
while ((more = json_array_next(r, first)) > 0) {
first = 0;
if (json_skip(r) != 0) return -1;
}
return more;
}
case JSON_STRING:
return json_string(r, NULL, 0) < 0 ? -1 : 0;
case JSON_NUMBER:
return json_number(r, NULL);
case JSON_TRUE:
return literal(r, "true");
case JSON_FALSE:
return literal(r, "false");
case JSON_NULL:
return literal(r, "null");
default:
return fail(r);
}
}
int json_at_end(struct json_reader *r) {
if (r->failed) return 0;
skip_ws(r);
return r->p >= r->end;
}
+81
View File
@@ -0,0 +1,81 @@
/* SPDX-License-Identifier: GPL-3.0-only
*
* A small, strict JSON reader for the cheat files the engine loads.
*
* It does not build a tree. The caller walks the text front to back: open an
* object, ask for each key in turn, and either read the value or skip it.
* Nothing is allocated, so a hostile or damaged file can cost no memory, and
* everything that is walked over (skipped values included) is checked against
* the JSON grammar. Nesting is limited to JSON_MAX_DEPTH.
*
* Once anything is wrong the reader is "failed": every later call reports
* failure and nothing more is consumed, so callers can check once at the end.
*/
#ifndef CS_JSON_H
#define CS_JSON_H
#include <stddef.h>
#define JSON_MAX_DEPTH 32
enum json_type {
JSON_INVALID = 0,
JSON_OBJECT,
JSON_ARRAY,
JSON_STRING,
JSON_NUMBER,
JSON_TRUE,
JSON_FALSE,
JSON_NULL,
};
struct json_reader {
const char *p;
const char *end;
int depth;
int failed;
};
/* Starts reading `len` bytes of text. A leading byte-order mark is skipped. */
void json_init(struct json_reader *r, const char *text, size_t len);
/* What kind of value comes next, without consuming it. */
enum json_type json_peek(struct json_reader *r);
/*
* Objects: json_object_begin, then json_object_next until it returns 0.
* json_object_next returns 1 with the key copied out and the reader standing
* at that key's value, which the caller must read or skip; 0 after the closing
* brace; -1 on malformed input. `first` must be 1 for the first call on an
* object and 0 after. A key that does not fit in `cap` comes back as "", which
* matches nothing a caller looks for.
*/
int json_object_begin(struct json_reader *r);
int json_object_next(struct json_reader *r, int first, char *key, size_t cap);
/* Arrays: json_array_begin, then json_array_next until it returns 0 (same convention). */
int json_array_begin(struct json_reader *r);
int json_array_next(struct json_reader *r, int first);
/*
* Reads a string value, decoding escapes to UTF-8. At most cap - 1 bytes are
* stored (always NUL-terminated, never cut inside a character) and the full
* decoded length is returned, so a caller can tell that it did not fit.
* An embedded NUL is stored as a space. Returns -1 on malformed input or when
* the next value is not a string.
*/
long json_string(struct json_reader *r, char *out, size_t cap);
/* Reads a number. Returns 0 on success. */
int json_number(struct json_reader *r, double *out);
/* Reads true or false into *out. Returns 0 on success. */
int json_bool(struct json_reader *r, int *out);
/* Steps over one value of any kind, checking it. Returns 0 on success. */
int json_skip(struct json_reader *r);
/* True when only whitespace is left: call after the top-level value. */
int json_at_end(struct json_reader *r);
#endif
+490 -13
View File
@@ -13,10 +13,15 @@
* stored as /data/cheatspecter/cheats/<title id>-<version>.cspector);
* - answers three questions the debugger cannot: which games are installed
* (/api/library), what they are called (/api/appmeta/...), and "start this
* one" (POST /api/launch/<title id>).
* one" (POST /api/launch/<title id>);
* - keeps the cheats marked "keep" in those files applied to the running
* game with no browser open (engine.c; /api/engine reports on it);
* - keeps small settings files for the web app (/api/prefs/<name>, stored
* as /data/cheatspecter/prefs/<name>.json), because what a browser stores
* is lost whenever the console's address changes.
*
* It is deliberately thin. All protocol knowledge and all file parsing live in
* the web app; ps5debug-NG keeps doing the kernel work.
* Apart from that engine it is deliberately thin. All other protocol knowledge
* and file parsing live in the web app; ps5debug-NG keeps doing the kernel work.
*
* The same file builds as a normal program for testing on a computer
* (`npm run payload:host`), where CS_PORT and CS_DEBUGGER_PORT can point it at the mock
@@ -24,6 +29,7 @@
*/
#include <arpa/inet.h>
#include <ctype.h>
#include <dirent.h>
#include <errno.h>
#include <netinet/in.h>
@@ -43,6 +49,9 @@
#include <unistd.h>
#include "assets.h"
#include "engine.h"
#include "json.h"
#include "pad.h"
#define DEFAULT_PORT 8744
#define DEFAULT_DEBUGGER_PORT 744
@@ -50,6 +59,8 @@
#define MAX_EVENT_SUBSCRIBERS 16
#define HEADER_MAX 8192
#define RELAY_CHUNK 65536
/* How long a connection may sit without sending its request. WebSockets are exempt once they are open. */
#define IDLE_SECONDS 15
#ifndef MSG_NOSIGNAL
#define MSG_NOSIGNAL 0
@@ -61,6 +72,9 @@ static int g_event_port = DEFAULT_EVENT_PORT;
static int g_events_ok = 0;
/* Prefix for every console path. Empty on the console; a test folder on a computer. */
static const char *g_root = "";
/* How much faster than real time the cheat engine's waits run. Always 1 on the console. */
static int g_engine_speed = 1;
static int g_idle_seconds = IDLE_SECONDS;
/* ---- platform ------------------------------------------------------------ */
@@ -116,6 +130,12 @@ static int read_full(int fd, void *buf, size_t len) {
return 0;
}
/* Limits how long a read on this socket may wait; 0 lifts the limit. */
static void set_read_limit(int fd, int seconds) {
struct timeval tv = {seconds, 0};
setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof tv);
}
static void set_nodelay(int fd) {
int one = 1;
setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, &one, sizeof one);
@@ -289,8 +309,79 @@ 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 */
int64_t sent_ms; /* when the browser last sent something */
int64_t got_ms; /* when the debugger last sent something */
};
/*
* The /ws relays that are open, so the cheat engine can tell when a browser is
* in the middle of something long with the debugger (a search of the game's
* memory) and hold back; see engine_config.browser_busy. A relay beyond the
* table's size is simply not counted.
*/
#define MAX_RELAYS 16
/* An exchange that takes longer than this is "something long". */
#define BUSY_AFTER_MS 400
static struct relay *g_relays[MAX_RELAYS];
static pthread_mutex_t g_relays_lock = PTHREAD_MUTEX_INITIALIZER;
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 busy_after_ms(void) {
int ms = BUSY_AFTER_MS / (g_engine_speed > 0 ? g_engine_speed : 1);
return ms < 20 ? 20 : ms;
}
static void relay_register(struct relay *r, int add) {
pthread_mutex_lock(&g_relays_lock);
for (int i = 0; i < MAX_RELAYS; i++) {
if (g_relays[i] == (add ? NULL : r)) {
g_relays[i] = add ? r : NULL;
break;
}
}
pthread_mutex_unlock(&g_relays_lock);
}
/* Notes traffic on a relay: `from_browser` for a request going to the debugger, 0 for the debugger's answer. */
static void relay_note(struct relay *r, int from_browser) {
int64_t now = now_ms();
pthread_mutex_lock(&g_relays_lock);
if (from_browser) {
r->awaiting = 1;
r->slow = 0;
r->sent_ms = now;
} else {
if (r->awaiting && now - r->sent_ms > busy_after_ms()) r->slow = 1;
r->awaiting = 0;
r->got_ms = now;
}
pthread_mutex_unlock(&g_relays_lock);
}
/* See engine_config.browser_busy. */
static int browser_busy(void) {
int64_t now = now_ms();
int limit = busy_after_ms(), busy = 0;
pthread_mutex_lock(&g_relays_lock);
for (int i = 0; i < MAX_RELAYS && !busy; i++) {
const struct relay *r = g_relays[i];
if (!r) continue;
/* Waiting on an answer for a while, or in the middle of a long one that is still coming. */
busy = r->awaiting ? now - r->sent_ms > limit : r->slow && now - r->got_ms < limit;
}
pthread_mutex_unlock(&g_relays_lock);
return busy;
}
static int ws_send(struct relay *r, int opcode, const unsigned char *data, size_t len) {
unsigned char hdr[10];
size_t hlen;
@@ -309,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);
@@ -334,6 +432,7 @@ static void *tcp_to_ws(void *arg) {
ssize_t n = recv(r->tcp, buf, RELAY_CHUNK, 0);
if (n < 0 && errno == EINTR) continue;
if (n <= 0) break;
relay_note(r, 0);
if (ws_send(r, 0x2, buf, (size_t)n) != 0) break;
}
free(buf);
@@ -371,6 +470,7 @@ static void ws_to_tcp(struct relay *r) {
int forward = (opcode == 0x2 || opcode == 0x0);
int failed = 0;
if (forward && len > 0 && r->tcp >= 0) relay_note(r, 1);
uint64_t done = 0;
unsigned char first[125];
size_t first_len = 0;
@@ -502,7 +602,10 @@ static void *event_listener_thread(void *arg) {
static void serve_events(int fd, const char *head) {
if (ws_handshake(fd, head) != 0) return;
/* An open WebSocket may stay quiet for as long as it likes. */
set_read_limit(fd, 0);
struct relay r;
memset(&r, 0, sizeof r);
r.ws = fd;
r.tcp = -1;
pthread_mutex_init(&r.wlock, NULL);
@@ -532,8 +635,11 @@ static void serve_events(int fd, const char *head) {
static void serve_websocket(int fd, const char *head) {
if (ws_handshake(fd, head) != 0) return;
/* An open WebSocket may stay quiet for as long as it likes. */
set_read_limit(fd, 0);
struct relay r;
memset(&r, 0, sizeof r);
r.ws = fd;
r.tcp = -1;
pthread_mutex_init(&r.wlock, NULL);
@@ -561,7 +667,9 @@ static void serve_websocket(int fd, const char *head) {
pthread_mutex_destroy(&r.wlock);
return;
}
relay_register(&r, 1);
ws_to_tcp(&r);
relay_register(&r, 0);
/* Wakes the debugger->browser thread, which is blocked reading TCP. */
shutdown(tcp, SHUT_RDWR);
pthread_join(reader, NULL);
@@ -955,14 +1063,19 @@ static void serve_appmeta(int fd, const char *rest, int head_only) {
: NULL;
char path[512];
struct stat st;
if (!mime || !valid_title_id(tid) || !find_meta(tid, file, path, sizeof path) || stat(path, &st) != 0 ||
(uint64_t)st.st_size > META_FILE_MAX) {
if (!mime || !valid_title_id(tid) || !find_meta(tid, file, path, sizeof path)) {
send_simple(fd, "404 Not Found", "text/plain", "Not found");
return;
}
/* Size the file through the handle that reads it, so it cannot change in between. */
FILE *f = fopen(path, "rb");
if (!f || fstat(fileno(f), &st) != 0 || !S_ISREG(st.st_mode) || (uint64_t)st.st_size > META_FILE_MAX) {
if (f) fclose(f);
send_simple(fd, "404 Not Found", "text/plain", "Not found");
return;
}
size_t size = (size_t)st.st_size;
unsigned char *data = f ? malloc(size ? size : 1) : NULL;
unsigned char *data = malloc(size ? size : 1);
if (!data || fread(data, 1, size, f) != size) {
if (f) fclose(f);
free(data);
@@ -1070,8 +1183,10 @@ static void serve_launch(int fd, const char *head, const char *tid) {
/* ---- the user's own cheat files ---------------------------------------------------- */
static const char NO_CONTENT[] = "HTTP/1.1 204 No Content\r\nCache-Control: no-store\r\nConnection: close\r\n\r\n";
/* One file per game and game version: <title id>-<version>.cspector */
#define TABLES_DIR "/data/cheatspecter/cheats"
#define TABLES_DIR CS_TABLES_DIR
#define TABLE_MAX (4u << 20)
/* A game version as it appears in a file name: digits, letters and dots only. */
@@ -1201,13 +1316,339 @@ static void store_table(int fd, const char *head, const char *rest, const char *
if (!ok) unlink(tmp);
free(body);
if (ok) {
static const char done[] = "HTTP/1.1 204 No Content\r\nCache-Control: no-store\r\nConnection: close\r\n\r\n";
write_all(fd, done, sizeof done - 1);
/* The engine may be keeping cheats from this file on: have it look at the new one now. */
engine_reload();
write_all(fd, NO_CONTENT, sizeof NO_CONTENT - 1);
} else {
send_simple(fd, "500 Internal Server Error", "text/plain", "Could not save the file");
}
}
/* 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 */
#define PREFS_DIR "/data/cheatspecter/prefs"
#define PREF_MAX (64u << 10)
/* Names go into file paths: lower-case letters, digits and dashes, 1 to 32 of them, and nothing else. */
static int valid_pref_name(const char *s) {
size_t n = 0;
for (; s[n]; n++) {
char c = s[n];
if (!((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-')) return 0;
}
return n >= 1 && n <= 32;
}
/* GET /api/prefs/<name> */
static void serve_pref(int fd, const char *name, int head_only) {
char path[512];
struct stat st;
if (!valid_pref_name(name)) {
send_simple(fd, "400 Bad Request", "text/plain", "Bad request");
return;
}
snprintf(path, sizeof path, "%s%s/%.32s.json", g_root, PREFS_DIR, name);
FILE *f = file_exists(path, &st) && (uint64_t)st.st_size <= PREF_MAX ? fopen(path, "rb") : NULL;
if (!f) {
send_simple(fd, "404 Not Found", "text/plain", "Not found");
return;
}
char *data = malloc(PREF_MAX);
size_t size = data ? fread(data, 1, PREF_MAX, f) : 0;
fclose(f);
if (!data) {
send_simple(fd, "500 Internal Server Error", "text/plain", "Out of memory");
return;
}
char head[256];
int n = snprintf(head, sizeof head,
"HTTP/1.1 200 OK\r\nContent-Type: application/json; charset=utf-8\r\nContent-Length: %zu\r\n"
"Cache-Control: no-store\r\nX-Content-Type-Options: nosniff\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);
free(data);
}
/* PUT /api/prefs/<name>. `early` is the part of the body that arrived with the head. */
static void store_pref(int fd, const char *head, const char *name, const char *early, size_t early_len) {
char value[32], path[512], tmp[512];
if (!same_origin(head)) {
send_simple(fd, "403 Forbidden", "text/plain", "Forbidden");
return;
}
if (!valid_pref_name(name) || !header_value(head, "Content-Length", value, sizeof value)) {
send_simple(fd, "400 Bad Request", "text/plain", "Bad request");
return;
}
unsigned long long length = strtoull(value, NULL, 10);
if (length > PREF_MAX || early_len > length) {
send_simple(fd, "413 Content Too Large", "text/plain", "That is too large to keep");
return;
}
char *body = malloc((size_t)length + 1);
if (!body) {
send_simple(fd, "500 Internal Server Error", "text/plain", "Out of memory");
return;
}
memcpy(body, early, early_len);
if (read_full(fd, body + early_len, (size_t)length - early_len) != 0) {
free(body);
return;
}
/* Only JSON is kept, so whatever reads the file back can count on that. */
struct json_reader r;
json_init(&r, body, (size_t)length);
if (json_skip(&r) != 0 || !json_at_end(&r)) {
free(body);
send_simple(fd, "400 Bad Request", "text/plain", "The body is not JSON");
return;
}
snprintf(path, sizeof path, "%s/data", g_root);
mkdir(path, 0777);
snprintf(path, sizeof path, "%s/data/cheatspecter", g_root);
mkdir(path, 0777);
snprintf(path, sizeof path, "%s%s", g_root, PREFS_DIR);
mkdir(path, 0777);
/* Written beside the real file and moved into place, so a crash cannot leave half of one. */
snprintf(path, sizeof path, "%s%s/%.32s.json", g_root, PREFS_DIR, name);
snprintf(tmp, sizeof tmp, "%s%s/%.32s.json.saving", g_root, PREFS_DIR, name);
FILE *f = fopen(tmp, "wb");
int ok = f != NULL && fwrite(body, 1, (size_t)length, f) == (size_t)length;
if (f && fclose(f) != 0) ok = 0;
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");
}
/* ---- the cheat engine ------------------------------------------------------------- */
/* GET /api/engine: what the engine is keeping on, and how each cheat is doing. */
static void serve_engine(int fd) {
char *status = engine_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);
}
/* POST /api/engine/reload: read the cheat file again now. */
static void serve_engine_reload(int fd, const char *head) {
/* This can change what is written into the game, so only the app this server served may ask. */
if (!same_origin(head)) {
send_simple(fd, "403 Forbidden", "text/plain", "Forbidden");
return;
}
engine_reload();
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) {
@@ -1231,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");
@@ -1250,6 +1697,12 @@ 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) {
serve_engine_reload(fd, head);
} else if (strcmp(method, "GET") != 0 && !is_head) {
send_simple(fd, "405 Method Not Allowed", "text/plain", "Method not allowed");
} else if (strcmp(target, "/ws") == 0) {
@@ -1264,6 +1717,15 @@ static void handle_connection(int fd) {
} else if (strncmp(target, "/api/tables/", 12) == 0) {
if (strchr(target + 12, '/')) serve_table(fd, target + 12, is_head);
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) {
@@ -1289,6 +1751,11 @@ static void *connection_thread(void *arg) {
/* A browser that vanished without closing must not block a writer forever. */
struct timeval tv = {15, 0};
setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof tv);
/*
* Nor may a connection that never sends its request hold a thread forever:
* each one costs a stack, on a console where memory is the game's.
*/
set_read_limit(fd, g_idle_seconds);
handle_connection(fd);
shutdown(fd, SHUT_RDWR);
close(fd);
@@ -1371,6 +1838,8 @@ int main(void) {
if (getenv("CS_EVENT_PORT")) g_event_port = atoi(getenv("CS_EVENT_PORT"));
if (getenv("CS_ROOT")) g_root = getenv("CS_ROOT");
if (getenv("CS_SHADOWMOUNT_PORT")) g_shadowmount_port = atoi(getenv("CS_SHADOWMOUNT_PORT"));
if (getenv("CS_ENGINE_SPEED")) g_engine_speed = atoi(getenv("CS_ENGINE_SPEED"));
if (getenv("CS_IDLE_SECONDS")) g_idle_seconds = atoi(getenv("CS_IDLE_SECONDS"));
#else
/* The name process lists and payload managers show for this payload. */
syscall(SYS_thr_set_name, -1, "cheatspecter.elf");
@@ -1396,6 +1865,14 @@ int main(void) {
pthread_t events;
if (pthread_create(&events, NULL, event_listener_thread, NULL) == 0) pthread_detach(events);
/* If it cannot start, everything else still works; /api/engine then says it is not running. */
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);
+671
View File
@@ -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;
}
+133
View File
@@ -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
+1 -1
View File
@@ -1,4 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" xmlns:c2pa="http://c2pa.org/manifest"><metadata><c2pa:manifest>AAAWgmp1bWIAAAAeanVtZGMycGEAEQAQgAAAqgA4m3EDYzJwYQAAABZcanVtYgAAAEdqdW1kYzJtYQARABCAAACqADibcQN1cm46YzJwYToyZmQwOTkwYi01ZjU3LTQ2YzktYTE2Zi04YmJlYmJlMTM0NzcAAAADl2p1bWIAAAApanVtZGMyYXMAEQAQgAAAqgA4m3EDYzJwYS5hc3NlcnRpb25zAAAAALxqdW1iAAAARGp1bWRjYm9yABEAEIAAAKoAOJtxE2MycGEuaW5ncmVkaWVudC52MwAAAAAYYzJzaGPOotfX2vlH/nUnNK+REAMAAABwY2JvcqNpZGM6Zm9ybWF0bWltYWdlL3N2Zyt4bWxqaW5zdGFuY2VJRHgseG1wOmlpZDo0NDZkYjcwMC0yNjgwLTQyYjUtOGZkMC0zMGM1MjZhMzlmOWNscmVsYXRpb25zaGlwaHBhcmVudE9mAAAB4mp1bWIAAABBanVtZGNib3IAEQAQgAAAqgA4m3ETYzJwYS5hY3Rpb25zLnYyAAAAABhjMnNoIIoEgWfbNw+17N2s2fSnbAAAAZljYm9yomdhY3Rpb25zgqJmYWN0aW9ua2MycGEub3BlbmVkanBhcmFtZXRlcnOha2luZ3JlZGllbnRzgaJjdXJseC1zZWxmI2p1bWJmPWMycGEuYXNzZXJ0aW9ucy9jMnBhLmluZ3JlZGllbnQudjNkaGFzaFggX7hS0m+3DmO1r9z9E/c9M1fIntZO42L1pXuIT5B7kOekZmFjdGlvbngdY29tLmFudGhyb3BpYy5jbGF1ZGUucHJvdmlkZWRqcGFyYW1ldGVyc6F4H2NvbS5hbnRocm9waWMub3JpZ2luLWNvbmZpZGVuY2VndW5rbm93bmtkZXNjcmlwdGlvbnhmQ2xhdWRlIHByb3ZpZGVkIHRoaXMgZmlsZSBhdCB0aGUgcmVxdWVzdCBvZiBhIHVzZXIgYW5kIG1heSBoYXZlIGNyZWF0ZWQgb3IgbW9kaWZpZWQgdGhlIGZpbGUgY29udGVudHMubXNvZnR3YXJlQWdlbnShZG5hbWVmQ2xhdWRlcmFsbEFjdGlvbnNJbmNsdWRlZPUAAADIanVtYgAAAEBqdW1kY2JvcgARABCAAACqADibcRNjMnBhLmhhc2guZGF0YQAAAAAYYzJzaD/CZH3twzjXh1+Y7+ILwtwAAACAY2JvcqVjYWxnZnNoYTI1NmNwYWRNAAAAAAAAAAAAAAAAAGRoYXNoWCBSAIF//tQPqpyqSM0NKyINo/HxArL/hqve95S5Yq5YAWRuYW1lbmp1bWJmIG1hbmlmZXN0amV4Y2x1c2lvbnOBomVzdGFydBh7Zmxlbmd0aBkeBAAAAj5qdW1iAAAAJ2p1bWRjMmNsABEAEIAAAKoAOJtxA2MycGEuY2xhaW0udjIAAAACD2Nib3KlY2FsZ2ZzaGEyNTZpc2lnbmF0dXJleE1zZWxmI2p1bWJmPS9jMnBhL3VybjpjMnBhOjJmZDA5OTBiLTVmNTctNDZjOS1hMTZmLThiYmViYmUxMzQ3Ny9jMnBhLnNpZ25hdHVyZWppbnN0YW5jZUlEeCx4bXA6aWlkOjgxYmMwMjRlLTcxOGYtNDEyMi04MjE3LThmNzM4NzAyYzkzMXJjcmVhdGVkX2Fzc2VydGlvbnODomN1cmx4LXNlbGYjanVtYmY9YzJwYS5hc3NlcnRpb25zL2MycGEuaW5ncmVkaWVudC52M2RoYXNoWCBfuFLSb7cOY7Wv3P0T9z0zV8ie1k7jYvWle4hPkHuQ56JjdXJseCpzZWxmI2p1bWJmPWMycGEuYXNzZXJ0aW9ucy9jMnBhLmFjdGlvbnMudjJkaGFzaFggyXVp/CMt70i5LlUePfBP82HSdbKl58WTbn+Sv/nMVmWiY3VybHgpc2VsZiNqdW1iZj1jMnBhLmFzc2VydGlvbnMvYzJwYS5oYXNoLmRhdGFkaGFzaFggnO8/tbYzNjnJaA8MyBu3ixlswMgij0R8IPz73UpX9vh0Y2xhaW1fZ2VuZXJhdG9yX2luZm+jZG5hbWVvQW50aHJvcGljIEZpbGVzZ3ZlcnNpb25lMS4wLjBrc3BlY1ZlcnNpb25lMi40LjAAABA4anVtYgAAAChqdW1kYzJjcwARABCAAACqADibcQNjMnBhLnNpZ25hdHVyZQAAABAIY2JvctKEWQISogEmGCFZAgowggIGMIIBjaADAgECAhRA5aAK7sI50L64g/oGQgU9Z1UTADAKBggqhkjOPQQDAzBJMRcwFQYDVQQKEw5BbnRocm9waWMsIFBCQzEuMCwGA1UEAxMlQW50aHJvcGljIENvbnRlbnQgQ3JlZGVudGlhbHMgUm9vdCBDQTAeFw0yNjA4MDcxODQzNTZaFw0yODA4MDYxOTQzNTZaMEQxFzAVBgNVBAoTDkFudGhyb3BpYywgUEJDMSkwJwYDVQQDEyBBbnRocm9waWMgQ2xhdWRlIENvbnRlbnQgU2lnbmluZzBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABJh6CmvLUBgFFNU0vUKlOVtE6djd17L5SuwX0LemFisBM3dkd/3cyjxFA3Qo5S46fX0/ihY0VZ7mfb9KF703t5OjWDBWMA4GA1UdDwEB/wQEAwIHgDAVBgNVHSUEDjAMBgorBgEEAYPoXgIBMAwGA1UdEwEB/wQCMAAwHwYDVR0jBBgwFoAUzlHiBIFOZFsj+OPEz5o+nMHXXMIwCgYIKoZIzj0EAwMDZwAwZAIwMXMdFJ4BetLLVY7ORuE9noqbbAZOZn/aArXyTwFAZfKrPzxF2vPoJNf1+UCdg1XGAjBwX1zd9WGqYkqmL5SFqw1QySjr1zJfpJM9+1rdDwSPLMOPOjKuiXjoU/pUUeG9RwmhY3BhZFkNngAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAALine truncated
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<rect width="64" height="64" rx="14" fill="#070707"/>
<path d="M10 34C10 19 19.5 8 32 8s22 11 22 26v22l-7.3-6.5L39.3 56 32 49.5 24.7 56l-7.4-6.5L10 56Z" fill="#ffd400"/>
<ellipse cx="24" cy="30" rx="5.5" ry="6.5" fill="#fff"/>

Before

Width:  |  Height:  |  Size: 8.0 KiB

After

Width:  |  Height:  |  Size: 462 B

+1 -1
View File
@@ -38,7 +38,7 @@ const manifest = {
name: 'cheatspecter',
display_name: 'CheatSpecter',
version,
description: 'Find and make cheats for single-player games on a jailbroken PS5 running the CheatSpecter payload.',
description: 'Find and make game cheats on a jailbroken PS5 running the CheatSpecter payload.',
long_description:
'Lets Claude work with a PS5 that has ps5debug-NG and the CheatSpecter payload loaded: see what game is running, search its memory for values, find the code that changes them, and save the result as a switch in the CheatSpecter app. For single-player games you own.',
author: { name: 'CheatSpecter' },
+21 -10
View File
@@ -3,6 +3,11 @@
// node scripts/build-payload.mjs host for this computer (testing against the mock)
// node scripts/build-payload.mjs ps5 payload/cheatspecter.elf (needs PS5_PAYLOAD_SDK)
//
// With CS_PAYLOAD_OUT set to a folder, the PS5 build is written there instead
// and the ELFs in payload/ are left as they are: a way to check that the
// payload still compiles and links for the console without replacing the
// build that ships.
//
// Both embed the built web app from dist/, so run `npm run build` first; the
// npm scripts `payload:host` and `payload` do that for you.
@@ -15,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 = [path.join(payload, 'src/main.c'), 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' });
@@ -36,17 +41,23 @@ if (target === 'host') {
console.error('Set PS5_PAYLOAD_SDK to your ps5-payload-sdk install, e.g. /opt/ps5-payload-sdk');
process.exit(1);
}
const out = path.join(payload, 'cheatspecter.elf');
const elsewhere = process.env.CS_PAYLOAD_OUT;
if (elsewhere) fs.mkdirSync(elsewhere, { recursive: true });
const out = path.join(elsewhere || payload, 'cheatspecter.elf');
run(path.join(sdk, 'bin/prospero-clang'), [...flags, '-o', out, ...sources, '-lkernel_sys', '-lSceSystemService', '-lSceUserService']);
// Payload managers read the version from the file name ("name-v1.2.3.elf"),
// so the build is also written under one. Older versioned copies are removed.
const { version } = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
const versioned = path.join(payload, `cheatspecter-v${version}.elf`);
for (const f of fs.readdirSync(payload)) {
if (/^cheatspecter-v[\d.]+\.elf$/.test(f)) fs.rmSync(path.join(payload, f));
if (elsewhere) {
console.log(`Built ${out} (a check build; payload/ was not touched)`);
} else {
// Payload managers read the version from the file name ("name-v1.2.3.elf"),
// so the build is also written under one. Older versioned copies are removed.
const { version } = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
const versioned = path.join(payload, `cheatspecter-v${version}.elf`);
for (const f of fs.readdirSync(payload)) {
if (/^cheatspecter-v[\d.]+\.elf$/.test(f)) fs.rmSync(path.join(payload, f));
}
fs.copyFileSync(out, versioned);
console.log(`Built ${path.relative(root, out)} and ${path.relative(root, versioned)}`);
}
fs.copyFileSync(out, versioned);
console.log(`Built ${path.relative(root, out)} and ${path.relative(root, versioned)}`);
} else {
console.error('Usage: build-payload.mjs host|ps5');
process.exit(1);
+150
View File
@@ -0,0 +1,150 @@
// Checks every lesson in learn/ the way the app checks a download, plus the
// things only the whole folder can show: that the index and the files agree.
//
// node scripts/check-lessons.mjs [folder]
//
// Prints what is wrong and exits with 1, or prints a summary and exits with 0.
// The tests run the same check (test/learn.test.mjs).
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { checkIndex, checkLesson } from '../src/lib/learn.ts';
import { PracticeGame } from '../src/lib/practice/game.ts';
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
* @returns {{ problems: string[], lessons: import('../src/lib/learn.ts').Lesson[], index: import('../src/lib/learn.ts').LessonIndex | null }}
*/
export function checkLessons(dir = DEFAULT_DIR) {
const problems = [];
const lessons = [];
const read = (file) => JSON.parse(fs.readFileSync(path.join(dir, file), 'utf8'));
let index = null;
try {
index = checkIndex(read('index.json'));
} catch (e) {
problems.push(`index.json: ${e.message}`);
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`;
listed.add(`${ref.id}.json`);
let lesson;
try {
lesson = checkLesson(read(file));
} catch (e) {
problems.push(`${file}: ${e.code === 'ENOENT' ? 'is listed in index.json but the file is missing' : e.message}`);
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']) {
if (lesson[key] !== ref[key]) problems.push(`${file}: "${key}" differs from index.json`);
}
const practice = lesson.steps.some((s) => s.kind === 'practice');
if (practice !== ref.practice) problems.push(`${file}: index.json says "practice": ${ref.practice}, but the lesson ${practice ? 'has' : 'has no'} practice steps`);
for (const [name, def] of Object.entries(lesson.games)) {
try {
new PracticeGame(def, 'CSPR00000');
} catch (e) {
problems.push(`${file}: the game "${name}" cannot be built: ${e.message}`);
}
if (!lesson.steps.some((s) => s.kind === 'practice' && s.game === name)) problems.push(`${file}: the game "${name}" is not used by any step`);
}
}
}
for (const file of fs.readdirSync(path.join(dir, 'lessons'))) {
if (!listed.has(file)) problems.push(`lessons/${file}: is not listed in index.json`);
}
return { problems, lessons, index };
}
if (process.argv[1] && pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url) {
const { problems, lessons } = checkLessons(process.argv[2]);
if (problems.length) {
for (const p of problems) console.error(p);
process.exit(1);
}
const steps = lessons.reduce((n, l) => n + l.steps.length, 0);
console.log(`${lessons.length} lessons, ${steps} steps: all fine.`);
}
+21
View File
@@ -0,0 +1,21 @@
// Bundles one TypeScript entry file and everything it imports into a single
// .mjs that plain Node can run anywhere, for trying library code against a
// real console from a machine that does not have this project checked out.
//
// node scripts/dev-bundle.mjs path/to/entry.ts out.mjs
//
// iced-x86 (the disassembler) is left as an import, because it loads a
// WebAssembly file from beside itself.
import path from 'node:path';
import { rolldown } from 'rolldown';
const [entry, out] = process.argv.slice(2);
if (!entry || !out) {
console.error('Usage: node scripts/dev-bundle.mjs <entry.ts> <out.mjs>');
process.exit(1);
}
const bundle = await rolldown({ input: path.resolve(entry), platform: 'node', external: ['iced-x86'], logLevel: 'warn' });
await bundle.write({ file: path.resolve(out), format: 'esm', codeSplitting: false });
await bundle.close();
console.log(`Bundled ${entry} -> ${out}`);
+113 -20
View File
@@ -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}
+1 -1
View File
@@ -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}
+2 -1
View File
@@ -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}
+267
View File
@@ -0,0 +1,267 @@
// "What addresses does this instruction touch?"
//
// The counterpart of the Tracer. The Tracer starts from a value and finds the
// code that touches it; this starts from an instruction (say the one that
// subtracts health) and finds every address it is used on. Game code is
// shared: the same instruction lowers the player's health and every enemy's.
// Collecting the addresses it touches gives one object per character, and
// comparing those objects (structure.ts) shows which one is the player.
//
// How it works: a breakpoint goes on the instruction. Each time it is reached
// the console reports the registers from just before it ran; the address of
// its memory operand ([rdi+0x10], say) is worked out from those, counted, and
// the game is resumed.
//
// What it costs: every pass through the instruction stops the whole game for
// one network round trip (about 6 ms on a wired console). An instruction that
// runs a few times per frame is barely noticeable; one inside a hot loop
// brings the game to a crawl until the hit limit is reached. Hence the low
// default limits. The breakpoint is always removed at the end, whatever the
// reason for ending.
import { DebugSession } from './debugger.ts';
import type { Breakpoint, DebugDeps, Hit } from './debugger.ts';
import type { GameContext } from './locations.ts';
import { fmtAddr } from './bytes.ts';
import { effectiveAddress } from './x86.ts';
import type { Insn, RegisterName } from './x86.ts';
export interface AccessOptions {
/** Stop after this many passes through the instruction (default 200). */
maxHits?: number;
/** Stop after this many seconds whatever happened (default 10). */
maxSeconds?: number;
/**
* Registers to remember for each address, as they were at its latest hit.
* The operand's own base and index registers are always included.
*/
registers?: RegisterName[];
/**
* An open debugging session to work inside (its other breakpoints stay as
* they are). Without one the collector opens its own and closes it at the end.
*/
session?: DebugSession;
}
export interface AccessedAddress {
/** The address the instruction's memory operand referred to. */
address: bigint;
/** How many times the instruction ran on this address. */
count: number;
/**
* Value of the operand's base register: for `[rdi+0x10]` the object the
* field belongs to. Null when the operand has no base register.
*/
base: bigint | null;
/** Selected registers at the latest hit on this address. */
regs: Partial<Record<RegisterName, bigint>>;
/** Threads that ran the instruction on this address. */
threads: number[];
}
export interface AccessResult {
/** The instruction that was watched. */
insn: Insn;
/** Distinct addresses, most-hit first. */
addresses: AccessedAddress[];
/** Passes through the instruction, counted or not. */
hits: number;
/** Passes whose address could not be worked out (thread-local storage that could not be read). */
unresolved: number;
/** True once collecting has ended; `ended` then says why, in words for the user. */
done: boolean;
ended?: string;
}
export interface ObjectGroup {
/** The object: the value the operand's base register had. */
base: bigint;
/** Total hits on this object. */
count: number;
/** The addresses touched inside it, lowest first, with their distance from `base`. */
fields: { address: bigint; offset: bigint; count: number }[];
}
/**
* Groups touched addresses by the object they belong to (the operand's base
* register), most-hit object first. With a plain `[reg+offset]` operand every
* group is one object with one field; with an index register (an array
* inside the object) a group lists every element that was touched.
* Addresses without a base register are left out.
*/
export function groupByBase(addresses: AccessedAddress[]): ObjectGroup[] {
const groups = new Map<bigint, ObjectGroup>();
for (const a of addresses) {
if (a.base === null) continue;
let g = groups.get(a.base);
if (!g) {
g = { base: a.base, count: 0, fields: [] };
groups.set(a.base, g);
}
g.count += a.count;
g.fields.push({ address: a.address, offset: BigInt.asIntN(64, a.address - a.base), count: a.count });
}
const out = [...groups.values()];
for (const g of out) g.fields.sort((x, y) => (x.address < y.address ? -1 : x.address > y.address ? 1 : 0));
return out.sort((x, y) => y.count - x.count);
}
export class AccessCollector {
running = false;
private ctx: GameContext;
private deps: DebugDeps;
private session: DebugSession | null = null;
private ownsSession = false;
private bp: Breakpoint | null = null;
private insn: Insn | null = null;
private found = new Map<bigint, AccessedAddress>();
private hits = 0;
private unresolved = 0;
private timer: ReturnType<typeof setTimeout> | null = null;
private finishing: Promise<void> | null = null;
private onUpdate: ((result: AccessResult) => void) | null = null;
private unsubscribe: (() => void) | null = null;
private ended: string | undefined;
constructor(ctx: GameContext, deps: DebugDeps) {
this.ctx = ctx;
this.deps = deps;
}
/** What has been collected so far. */
result(): AccessResult {
if (!this.insn) throw new Error('Nothing has been collected yet');
const addresses = [...this.found.values()].sort((a, b) => b.count - a.count);
return { insn: this.insn, addresses, hits: this.hits, unresolved: this.unresolved, done: !this.running, ended: this.ended };
}
/**
* Starts collecting on the instruction at `address`, which must have a
* memory operand whose address depends on registers. `onUpdate` is called
* after every hit and once more when collecting ends (`done` is then true).
*
* Resolves once the breakpoint is in place. Ends by itself at the hit or
* time limit; call `cancel()` to end it sooner.
*/
async start(address: bigint, opts: AccessOptions, onUpdate: (result: AccessResult) => void): Promise<void> {
if (this.running) throw new Error('Already collecting');
const maxHits = opts.maxHits ?? 200;
const maxSeconds = opts.maxSeconds ?? 10;
const bytes = await this.ctx.dbg().read(this.ctx.pid, address, 16);
const [decoded] = await this.ctx.x86.disassemble(opts.session ? opts.session.unpatch(address, bytes) : bytes, address, 1);
if (!decoded || decoded.invalid) throw new Error(`There is no instruction at ${fmtAddr(address)}.`);
const mem = decoded.memory;
if (!mem) throw new Error(`"${decoded.text}" does not read or write memory, so there are no addresses to collect.`);
if (!mem.computable) throw new Error(`"${decoded.text}" works out its address in a way that cannot be followed from here.`);
if (mem.fixedAddress !== null) {
throw new Error(`"${decoded.text}" always uses the same address, ${fmtAddr(mem.fixedAddress)}. There is nothing to collect.`);
}
this.insn = decoded;
this.found.clear();
this.hits = 0;
this.unresolved = 0;
this.ended = undefined;
this.finishing = null;
this.onUpdate = onUpdate;
const wanted = new Set<RegisterName>(opts.registers ?? []);
if (mem.base) wanted.add(mem.base);
if (mem.index) wanted.add(mem.index);
const onHit = async (hit: Hit): Promise<'continue'> => {
// Hits that arrive after the end are just let through.
if (!this.running) return 'continue';
this.hits++;
let segments: { fs?: bigint; gs?: bigint } = {};
if (mem.segment) {
try {
segments = await hit.segmentBases();
} catch {
// counted as unresolved below
}
}
const target = effectiveAddress(mem, hit.regs, segments);
if (target === null) {
this.unresolved++;
} else {
let entry = this.found.get(target);
if (!entry) {
entry = { address: target, count: 0, base: mem.base ? hit.regs[mem.base] : null, regs: {}, threads: [] };
this.found.set(target, entry);
}
entry.count++;
for (const r of wanted) entry.regs[r] = hit.regs[r];
if (!entry.threads.includes(hit.lwpid)) entry.threads.push(hit.lwpid);
}
if (this.hits >= maxHits) void this.finish(`Stopped after ${maxHits} hits so the game is not slowed down for long.`);
else this.emit();
return 'continue';
};
this.session = opts.session ?? (await DebugSession.open(this.ctx, this.deps));
this.ownsSession = !opts.session;
try {
if (!this.ownsSession && this.session.breakpoints().some((b) => b.address === address)) {
throw new Error('There is already a breakpoint on this instruction. Remove it first.');
}
this.running = true;
this.bp = await this.session.addBreakpoint(address, { onHit });
} catch (e) {
this.running = false;
if (this.ownsSession) await this.session.close();
this.session = null;
throw e;
}
this.unsubscribe = this.session.onState((state, reason) => {
if (state === 'closed') void this.finish(reason ?? 'The debugging session ended.');
});
this.timer = setTimeout(() => void this.finish(`Stopped after ${maxSeconds} seconds.`), maxSeconds * 1000);
}
/**
* Ends collecting now. The results so far stay available from `result()`.
* Taking the breakpoint out is a single write that does not wait for the
* game, so this returns promptly even if the instruction never runs again.
*/
cancel(): Promise<void> {
return this.finish();
}
private emit(): void {
if (!this.onUpdate) return;
try {
this.onUpdate(this.result());
} catch {
// a listener's problem
}
}
private finish(ended?: string): Promise<void> {
if (!this.running) return this.finishing ?? Promise.resolve();
this.running = false;
this.ended = ended;
if (this.timer) clearTimeout(this.timer);
this.timer = null;
this.unsubscribe?.();
this.unsubscribe = null;
const session = this.session;
const bp = this.bp;
this.session = null;
this.bp = null;
this.finishing = (async () => {
try {
if (session && session.state !== 'closed') {
if (this.ownsSession) await session.close();
else if (bp) await session.removeBreakpoint(bp);
}
} catch {
// the session is gone; the console has cleaned up
}
this.emit();
})();
return this.finishing;
}
}
+731
View File
@@ -0,0 +1,731 @@
// Putting a piece of new code together: labels, variables, names defined
// elsewhere, and the game's own instructions moved to a new home.
//
// The console assembles text with Keystone (ps5debug-NG, assemble.c: one
// ks_asm call, Intel syntax, no symbol resolver). Keystone already does
// labels, forward and backward jumps and data directives inside one piece of
// text, so those are left to it. What it cannot or does not do safely is done
// here, before the text is sent:
//
// - ";" starts a comment (to Keystone it separates statements);
// - "db/dw/dd/dq" are turned into its ".byte/.word/.long/.quad";
// - "[name]" becomes "[rip + name]" for a label or variable of this code,
// because Keystone encodes a bare "[label]" as a 32-bit absolute address,
// which cannot hold where the console puts things;
// - a name defined elsewhere (another cheat, a game location) is replaced by
// its address, after checking the instruction can reach it: Keystone cuts
// an out-of-range address down without saying so;
// - an "original" line becomes the game's instruction the hook sits on,
// re-encoded for its new address; whatever else the jump had to replace is
// re-encoded at "return", so it runs when the new code is done;
// - variables are laid out after the code, and the address of every label
// and variable is read back from a table appended to the text, since the
// console only returns bytes.
import { bytesToHex, hexToBytes, view } from './bytes.ts';
import { encodeValue, valueKind } from './scanner.ts';
/** A named value stored next to the code, which the code can read and write. */
export interface AsmVariable {
name: string;
/** One of the value types of scanner.ts ("u32", "float", ...) or "bytes". */
type: string;
/** Starting value, as typed: a number, or hex bytes for "bytes". */
value: string;
}
export class AsmError extends Error {
/** Line of the user's text the problem is on, counting from 1; 0 when it is not about one line. */
line: number;
/** Which block of the unit it is in. */
block: number;
constructor(message: string, line = 0, block = 0) {
super(message);
this.name = 'AsmError';
this.line = line;
this.block = block;
}
}
// ---- words that are not names ----------------------------------------------------
const REGISTERS = new Set<string>();
for (const r of ['ax', 'bx', 'cx', 'dx', 'si', 'di', 'bp', 'sp']) {
REGISTERS.add(r).add('e' + r).add('r' + r);
}
for (const r of ['al', 'bl', 'cl', 'dl', 'ah', 'bh', 'ch', 'dh', 'sil', 'dil', 'bpl', 'spl', 'rip', 'eip', 'cs', 'ds', 'es', 'fs', 'gs', 'ss', 'st']) REGISTERS.add(r);
for (let i = 8; i < 16; i++) for (const s of ['', 'd', 'w', 'b']) REGISTERS.add(`r${i}${s}`);
for (let i = 0; i < 32; i++) REGISTERS.add(`xmm${i}`).add(`ymm${i}`).add(`zmm${i}`);
for (let i = 0; i < 8; i++) REGISTERS.add(`mm${i}`).add(`st${i}`).add(`k${i}`).add(`cr${i}`).add(`dr${i}`);
const KEYWORDS = new Set(['byte', 'word', 'dword', 'qword', 'tbyte', 'oword', 'xmmword', 'ymmword', 'zmmword', 'ptr', 'offset', 'short', 'near', 'far']);
const PREFIXES = new Set(['lock', 'rep', 'repe', 'repz', 'repne', 'repnz']);
const DATA_SHORTHAND: Record<string, string> = { db: '.byte', dw: '.word', dd: '.long', dq: '.quad' };
/** Directives whose operands may be 64-bit addresses. */
const WIDE_DATA = new Set(['.quad', '.8byte']);
const NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
export function isRegisterName(word: string): boolean {
return REGISTERS.has(word.toLowerCase());
}
/** Why a word cannot be used as the name of a label or variable, or null when it can. */
export function nameProblem(name: string): string | null {
if (!name) return 'Give it a name.';
if (!NAME.test(name)) return `"${name}" cannot be used as a name. Use letters, digits and _ only, starting with a letter.`;
const low = name.toLowerCase();
if (REGISTERS.has(low)) return `"${name}" is the name of a processor register. Pick another name.`;
if (KEYWORDS.has(low) || PREFIXES.has(low) || low in DATA_SHORTHAND) return `"${name}" already means something to the assembler. Pick another name.`;
if (low === 'original' || low === 'return' || low === 'source') return `"${name}" is a word with a fixed meaning in code cheats. Pick another name.`;
if (name.startsWith('__cs')) return 'Names starting with __cs are used internally. Pick another name.';
return null;
}
// ---- reading the text ---------------------------------------------------------------
interface Bracket {
hasRegister: boolean;
hasRip: boolean;
/** Position just after the "[". */
open: number;
}
interface Ref {
name: string;
start: number;
end: number;
bracket: Bracket | null;
}
export interface ParsedLine {
/** Line number in the user's text, from 1. */
no: number;
labels: string[];
kind: 'empty' | 'insn' | 'data' | 'original' | 'assign';
/** Lower case; the directive for data. */
mnemonic: string;
/** The statement without labels and comments, as it will be sent. */
text: string;
refs: Ref[];
}
export interface ParsedCode {
lines: ParsedLine[];
/** Labels defined, with the line each is on. */
labels: Map<string, number>;
/** Names given a fixed number with "name = 5". */
consts: Set<string>;
/** True when the text has an "original" line. */
usesOriginal: boolean;
}
function stripComment(line: string): string {
let quote = '';
for (let i = 0; i < line.length; i++) {
const c = line[i];
if (quote) {
if (c === '\\') i++;
else if (c === quote) quote = '';
} else if (c === '"' || c === "'") quote = c;
else if (c === ';' || c === '#' || (c === '/' && line[i + 1] === '/')) return line.slice(0, i);
}
return line;
}
/** Finds the names used in a statement's operands. `from` is where the operands start. */
function scanRefs(text: string, from: number, line: number, block: number): Ref[] {
const refs: Ref[] = [];
let bracket: Bracket | null = null;
for (let i = from; i < text.length; ) {
const c = text[i];
if (c === '"' || c === "'") {
let j = i + 1;
while (j < text.length && text[j] !== c) j += text[j] === '\\' ? 2 : 1;
i = j + 1;
} else if (c === '[') {
if (bracket) throw new AsmError(`Line ${line}: a "[" inside another "[".`, line, block);
bracket = { hasRegister: false, hasRip: false, open: i + 1 };
i++;
} else if (c === ']') {
bracket = null;
i++;
} else if (/[0-9]/.test(c)) {
// A number in any of the spellings the assembler takes: 10, 0x1F, 1Fh, 1.5
let j = i + 1;
while (j < text.length && /[0-9A-Za-z_.]/.test(text[j])) j++;
i = j;
} else if (/[A-Za-z_.$]/.test(c)) {
let j = i + 1;
while (j < text.length && /[A-Za-z0-9_.$@]/.test(text[j])) j++;
const word = text.slice(i, j);
const low = word.toLowerCase();
if (REGISTERS.has(low)) {
if (bracket) {
bracket.hasRegister = true;
if (low === 'rip' || low === 'eip') bracket.hasRip = true;
}
} else if (!KEYWORDS.has(low) && !word.startsWith('.')) {
if (!NAME.test(word)) throw new AsmError(`Line ${line}: "${word}" is not a name the assembler can use. Names are letters, digits and _.`, line, block);
refs.push({ name: word, start: i, end: j, bracket });
}
i = j;
} else i++;
}
return refs;
}
/** Reads code as the user wrote it. Throws AsmError for what is wrong on its face. */
export function parseCode(source: string, block = 0): ParsedCode {
const lines: ParsedLine[] = [];
const labels = new Map<string, number>();
const consts = new Set<string>();
let usesOriginal = false;
source.split(/\r?\n/).forEach((raw, index) => {
const no = index + 1;
let text = stripComment(raw).trim();
const mine: string[] = [];
for (;;) {
const m = /^([A-Za-z_.$][A-Za-z0-9_.$@]*)\s*:(?!:)/.exec(text);
// "fs:[...]" at the very start of a line is not something anyone writes; a word and a colon is a label.
if (!m) break;
const name = m[1];
const problem = nameProblem(name);
if (problem) throw new AsmError(`Line ${no}: ${problem}`, no, block);
if (labels.has(name) || mine.includes(name)) throw new AsmError(`Line ${no}: the label "${name}" is already used on line ${labels.get(name) ?? no}. Each label needs its own name.`, no, block);
mine.push(name);
text = text.slice(m[0].length).trim();
}
for (const l of mine) labels.set(l, no);
const line: ParsedLine = { no, labels: mine, kind: 'empty', mnemonic: '', text, refs: [] };
lines.push(line);
if (!text) return;
if (/^original$/i.test(text)) {
line.kind = 'original';
line.text = '';
usesOriginal = true;
return;
}
const assign = /^([A-Za-z_][A-Za-z0-9_]*)\s*=(?!=)/.exec(text);
const equ = /^\.(?:equ|set|equiv)\s+([A-Za-z_][A-Za-z0-9_]*)\s*,/i.exec(text);
if (assign || equ) {
const name = (assign ?? equ)![1];
const problem = nameProblem(name);
if (problem) throw new AsmError(`Line ${no}: ${problem}`, no, block);
consts.add(name);
line.kind = 'assign';
line.refs = scanRefs(text, (assign ?? equ)![0].length, no, block);
return;
}
const first = /^[A-Za-z_.][A-Za-z0-9_.]*/.exec(text);
if (!first) throw new AsmError(`Line ${no}: "${text}" does not start with an instruction.`, no, block);
let mnemonic = first[0].toLowerCase();
let operandsAt = first[0].length;
if (mnemonic in DATA_SHORTHAND) {
if (/["']/.test(text)) throw new AsmError(`Line ${no}: for text, use .ascii "..." (or .asciz to end it with a zero byte).`, no, block);
text = DATA_SHORTHAND[mnemonic] + text.slice(first[0].length);
operandsAt = DATA_SHORTHAND[mnemonic].length;
mnemonic = DATA_SHORTHAND[mnemonic];
} else if (PREFIXES.has(mnemonic)) {
const next = /^\s+([A-Za-z_][A-Za-z0-9_]*)/.exec(text.slice(operandsAt));
if (next) {
operandsAt += next[0].length;
mnemonic = next[1].toLowerCase();
}
}
line.text = text;
line.mnemonic = mnemonic;
line.kind = mnemonic.startsWith('.') ? 'data' : 'insn';
line.refs = scanRefs(text, operandsAt, no, block);
});
return { lines, labels, consts, usesOriginal };
}
// ---- what one piece of code is made of ---------------------------------------------------
/** One of the game's instructions that a hook replaces. */
export interface MovedInsn {
address: bigint;
bytes: Uint8Array;
/** True when its bytes depend on where it sits (a relative jump or call, or data found relative to itself). */
moves: boolean;
text: string;
}
export interface CodeBlock {
/** Said in error messages when there are several blocks: "Place 2". */
title?: string;
code: string;
/** Labels placed at the block's start. */
entryLabels?: string[];
/** Names with a fixed address that mean something only in this block ("source"). */
locals?: Map<string, bigint>;
/** The game's instructions an "original" line stands for. */
original?: MovedInsn[];
/**
* Further instructions the jump replaced, after the one the hook is for.
* They are run at "return", before going back to the game, so the code
* above them never has to know they exist.
*/
tail?: MovedInsn[];
/** Labels placed where "return" leads when there is a tail (without one, "return" is simply `returnTo`). */
returnLabels?: string[];
/** Run those instructions before the block's first line, or after its last, without the text saying so. */
leadOriginal?: boolean;
trailOriginal?: boolean;
/** Where to jump after the block's last line; nothing is added when left out. Code can say "return" for it. */
returnTo?: bigint;
}
export interface UnitInput {
blocks: CodeBlock[];
variables: AsmVariable[];
/** The address of a name defined outside this unit, or undefined when nobody defines it. */
resolve?: (name: string) => bigint | undefined;
}
export interface PlacedVariable {
name: string;
type: string;
address: bigint;
size: number;
}
export interface BuiltUnit {
base: bigint;
/** Everything to write at `base`. */
bytes: Uint8Array;
/** Where each block starts. */
entries: bigint[];
/** Every label and variable, with its address. */
symbols: Map<string, bigint>;
variables: PlacedVariable[];
/** Number of times the assembler was asked. */
passes: number;
}
export interface AsmEnv {
assemble: (base: bigint, text: string) => Promise<Uint8Array>;
relocate: (bytes: Uint8Array, from: bigint, to: bigint) => Promise<Uint8Array>;
}
/** How far a 32-bit relative jump or data reference reaches, with room to spare for the code's own size. */
const REACH = 0x7ff00000n;
export function inReach(from: bigint, to: bigint): boolean {
const d = to - from;
return d > -REACH && d < REACH;
}
const hexLit = (n: bigint): string => '0x' + BigInt.asUintN(64, n).toString(16);
/** The bytes a variable starts with. */
export function variableBytes(v: AsmVariable): Uint8Array {
if (v.type === 'bytes') {
let b: Uint8Array;
try {
if (!/^[0-9a-fA-F\s,]*$/.test(v.value)) throw new Error('not hex');
b = hexToBytes(v.value);
} catch {
throw new AsmError(`The variable "${v.name}" needs hex bytes, such as 00 00 80 3F.`);
}
if (b.length === 0) throw new AsmError(`The variable "${v.name}" needs at least one byte.`);
if (b.length > 4096) throw new AsmError(`The variable "${v.name}" is too long (4096 bytes at most).`);
return b;
}
try {
return encodeValue(valueKind(v.type), v.value);
} catch (e) {
throw new AsmError(`The variable "${v.name}": ${e instanceof Error ? e.message : String(e)}`);
}
}
function checkVariables(variables: AsmVariable[]): void {
const seen = new Set<string>();
for (const v of variables) {
const problem = nameProblem(v.name);
if (problem) throw new AsmError(`A variable's name: ${problem}`);
if (seen.has(v.name)) throw new AsmError(`Two variables are called "${v.name}". Each needs its own name.`);
seen.add(v.name);
variableBytes(v);
}
}
export interface UnitOutline {
/** Labels and variables this unit defines. */
defines: string[];
/** Names it uses that it does not define itself. */
needs: string[];
usesOriginal: boolean;
}
interface Parsed {
blocks: ParsedCode[];
defined: Map<string, 'label' | 'variable' | 'const'>;
}
function parseUnit(input: UnitInput): Parsed {
checkVariables(input.variables);
const defined = new Map<string, 'label' | 'variable' | 'const'>();
for (const v of input.variables) defined.set(v.name, 'variable');
const blocks = input.blocks.map((b, i) => {
const parsed = parseCode(b.code, i);
const where = input.blocks.length > 1 ? `${b.title ?? `Block ${i + 1}`}, line` : 'Line';
const add = (name: string, line: number, kind: 'label' | 'const') => {
const had = defined.get(name);
if (had) {
throw new AsmError(
had === 'variable' ? `${where} ${line}: "${name}" is already the name of a variable.` : `${where} ${line}: the name "${name}" is used twice in this cheat. Each label needs its own name.`,
line,
i,
);
}
defined.set(name, kind);
};
for (const l of b.entryLabels ?? []) add(l, 0, 'label');
if (b.tail?.length) for (const l of b.returnLabels ?? []) add(l, 0, 'label');
for (const [name, line] of parsed.labels) add(name, line, 'label');
for (const name of parsed.consts) add(name, 0, 'const');
return parsed;
});
return { blocks, defined };
}
/** What a unit defines and what it needs from elsewhere, without assembling anything. */
export function outlineUnit(input: UnitInput): UnitOutline {
const { blocks, defined } = parseUnit(input);
const needs = new Set<string>();
blocks.forEach((parsed, i) => {
const locals = input.blocks[i].locals;
for (const line of parsed.lines) {
for (const ref of line.refs) {
if (ref.name === 'return' && input.blocks[i].returnTo !== undefined) continue;
if (!defined.has(ref.name) && !locals?.has(ref.name)) needs.add(ref.name);
}
}
});
return {
defines: [...defined].filter(([, kind]) => kind !== 'const').map(([name]) => name),
needs: [...needs],
usesOriginal: blocks.some((b) => b.usesOriginal),
};
}
// ---- building ---------------------------------------------------------------------------
interface OriginalUse {
label: string;
insns: MovedInsn[];
/** Bytes reserved for it in the text. */
size: number;
/** True when the bytes have to be worked out again once its address is known. */
moves: boolean;
line: number;
block: number;
}
interface GenLine {
text: string;
/** The same statement with every label replaced by a number, so it can be tried on its own. */
probe?: string;
line: number;
block: number;
}
const isBranch = (mnemonic: string): boolean => /^(j[a-z]+|call|loop[a-z]*)$/.test(mnemonic);
function where(input: UnitInput, block: number, line: number): string {
if (line === 0) return input.blocks.length > 1 ? (input.blocks[block].title ?? `Block ${block + 1}`) : 'The code';
if (input.blocks.length > 1) return `${input.blocks[block].title ?? `Block ${block + 1}`}, line ${line}`;
return `Line ${line}`;
}
function generate(input: UnitInput, parsed: Parsed, base: bigint, sizes: Map<string, number>): { gen: GenLine[]; trailer: string[]; originals: OriginalUse[] } {
const gen: GenLine[] = [];
const originals: OriginalUse[] = [];
const thunks = new Map<bigint, string>();
const trailer: string[] = [];
const probeAddr = hexLit(base + 0x40n);
const out = (text: string, line = 0, block = 0, probe?: string) => gen.push({ text, line, block, probe });
const farJump = (to: bigint) => {
// jmp [rip+0], then the destination: reaches anywhere.
out('.byte 0xff, 0x25, 0, 0, 0, 0');
out(`.quad ${hexLit(to)}`);
};
input.blocks.forEach((block, bi) => {
const code = parsed.blocks[bi];
const entry = `__cs_entry${bi}`;
out(`${entry}:`);
trailer.push(entry);
for (const l of block.entryLabels ?? []) out(`${l}:`);
const stub = block.tail?.length ? `__cs_ret${bi}` : null;
const place = (insns: MovedInsn[], lineNo: number) => {
if (insns.length === 0) return;
const label = `__cs_orig${originals.length}`;
const moves = insns.some((x) => x.moves);
const plain = insns.reduce((n, x) => n + x.bytes.length, 0);
const use: OriginalUse = { label, insns, moves, size: sizes.get(label) ?? plain, line: lineNo, block: bi };
originals.push(use);
out(`${label}:`);
trailer.push(label);
if (!moves) out(`.byte ${insns.flatMap((x) => [...x.bytes]).map((b) => '0x' + b.toString(16)).join(', ')}`);
else out(`.fill ${use.size}, 1, 0x90`);
};
const original = (lineNo: number) => place(block.original ?? [], lineNo);
if (block.leadOriginal) original(0);
for (const line of code.lines) {
const at = where(input, bi, line.no);
for (const l of line.labels) out(`${l}:`);
if (line.kind === 'empty') continue;
if (line.kind === 'original') {
if (!block.original) throw new AsmError(`${at}: "original" stands for the game's instructions that a hook replaces, and this code is not a hook.`, line.no, bi);
original(line.no);
continue;
}
// Replace names, last first so earlier positions stay valid.
let text = line.text;
let probe = line.text;
let mnemonic = line.mnemonic;
const edits: { start: number; end: number; text: string; probe: string }[] = [];
const ripFor = new Set<Bracket>();
for (const ref of line.refs) {
const kind = parsed.defined.get(ref.name);
if (kind === 'const') continue;
// "return": where the game carries on. A label of this code when instructions have to be re-run there first.
const isReturn = kind === undefined && ref.name === 'return' && block.returnTo !== undefined;
const local = kind !== undefined || (isReturn && stub !== null);
const label = isReturn && stub !== null ? stub : ref.name;
const address = local ? undefined : isReturn ? block.returnTo : (block.locals?.get(ref.name) ?? input.resolve?.(ref.name));
if (!local && address === undefined) {
const known = [...parsed.defined.keys()].filter((n) => !n.startsWith('__cs'));
const hexLike = /^[0-9A-Fa-f]+h?$/.test(ref.name);
throw new AsmError(
`${at}: "${ref.name}" is not defined.` +
(hexLike ? ` If it is meant as a hex number, write it as 0x${ref.name.replace(/h$/i, '')}.` : '') +
(known.length ? ` This cheat defines: ${known.slice(0, 12).join(', ')}.` : ''),
line.no,
bi,
);
}
if (ref.bracket) {
if (ref.bracket.hasRegister && !ref.bracket.hasRip) {
throw new AsmError(`${at}: "${ref.name}" cannot be combined with a register inside [ ]. Load its address into a register first: lea rax, [${ref.name}]`, line.no, bi);
}
if (local) {
if (!ref.bracket.hasRip) ripFor.add(ref.bracket);
edits.push({ start: ref.start, end: ref.end, text: label, probe: '0x40' });
} else {
if (ref.bracket.hasRip) throw new AsmError(`${at}: write [${ref.name}] without "rip"; the right form is worked out for you.`, line.no, bi);
if (!inReach(base, address!)) {
throw new AsmError(`${at}: "${ref.name}" is too far from this code to be used inside [ ] directly. Load its address first: mov rax, ${ref.name} and then use [rax].`, line.no, bi);
}
edits.push({ start: ref.start, end: ref.end, text: hexLit(address!), probe: hexLit(address!) });
}
continue;
}
if (isBranch(mnemonic)) {
if (local) edits.push({ start: ref.start, end: ref.end, text: label, probe: probeAddr });
else if (inReach(base, address!)) edits.push({ start: ref.start, end: ref.end, text: hexLit(address!), probe: hexLit(address!) });
else {
// Out of reach of a relative jump: go through a jump that holds the full address.
let thunk = thunks.get(address!);
if (!thunk) {
thunk = `__cs_far${thunks.size}`;
thunks.set(address!, thunk);
}
edits.push({ start: ref.start, end: ref.end, text: thunk, probe: probeAddr });
}
continue;
}
if (line.kind === 'data') {
if (!WIDE_DATA.has(mnemonic)) throw new AsmError(`${at}: an address ("${ref.name}") needs 8 bytes. Use .quad (or dq).`, line.no, bi);
edits.push({ start: ref.start, end: ref.end, text: local ? label : hexLit(address!), probe: local ? probeAddr : hexLit(address!) });
continue;
}
if (mnemonic === 'mov' || mnemonic === 'movabs') {
// "mov rax, name" loads the address itself. For a label that needs the 64-bit form.
if (local) mnemonic = 'movabs';
edits.push({ start: ref.start, end: ref.end, text: local ? label : hexLit(address!), probe: local ? probeAddr : hexLit(address!) });
continue;
}
throw new AsmError(
`${at}: "${ref.name}" is an address, which does not fit this instruction as a plain number. To read or write what is stored there use [${ref.name}]; to get the address itself use: lea rax, [${ref.name}]`,
line.no,
bi,
);
}
for (const b of ripFor) edits.push({ start: b.open, end: b.open, text: 'rip + ', probe: 'rip + ' });
edits.sort((a, b) => b.start - a.start || b.end - a.end);
for (const e of edits) {
text = text.slice(0, e.start) + e.text + text.slice(e.end);
probe = probe.slice(0, e.start) + e.probe + probe.slice(e.end);
}
if (mnemonic === 'movabs' && line.mnemonic === 'mov') {
text = text.replace(/^(\s*)mov\b/i, '$1movabs');
probe = probe.replace(/^(\s*)mov\b/i, '$1movabs');
}
out(text, line.no, bi, line.kind === 'assign' ? undefined : probe);
}
if (block.trailOriginal) original(0);
if (block.returnTo !== undefined) {
if (stub !== null) {
out(`${stub}:`);
for (const l of block.returnLabels ?? []) out(`${l}:`);
place(block.tail!, 0);
}
if (inReach(base, block.returnTo)) out(`jmp ${hexLit(block.returnTo)}`);
else farJump(block.returnTo);
}
});
for (const [to, label] of thunks) {
out(`${label}:`);
farJump(to);
}
if (input.variables.length > 0) {
// Kept clear of the code: a processor slows right down when data it writes shares a cache line with code it runs.
out('.balign 64');
out('.fill 64, 1, 0');
for (const v of input.variables) {
const bytes = variableBytes(v);
const align = v.type === 'bytes' ? (bytes.length >= 16 ? 16 : 1) : bytes.length;
if (align > 1) out(`.balign ${align}`);
out(`${v.name}:`);
out(`.byte ${[...bytes].map((b) => '0x' + b.toString(16)).join(', ')}`);
}
}
for (const [name, kind] of parsed.defined) if (kind !== 'const') trailer.push(name);
return { gen, trailer, originals };
}
const KS_WORDS: [RegExp, string][] = [
[/MNEMONICFAIL|invalid mnemonic/i, 'the assembler does not know this instruction'],
[/INVALIDOPERAND|invalid operand/i, 'these operands do not fit this instruction'],
[/SYMBOL_MISSING|symbol/i, 'it uses a name that is not defined'],
[/FIXUP_INVALID|fixup/i, 'an address or jump in it is out of reach or does not fit'],
[/LABEL_INVALID/i, 'it has a label the assembler does not accept'],
[/SYMBOL_REDEFINED|redefin/i, 'it defines a name twice'],
];
function assemblerWords(message: string): string {
const raw = message.replace(/^Assembler:\s*/, '');
const hit = KS_WORDS.find(([re]) => re.test(raw));
return hit ? `${hit[1]} [Assembler: ${raw}]` : `Assembler: ${raw}`;
}
/** After a failed assembly: tries the user's lines one by one to say which it was. */
async function blame(env: AsmEnv, input: UnitInput, base: bigint, gen: GenLine[], failure: unknown): Promise<AsmError> {
const message = failure instanceof Error ? failure.message : String(failure);
// Only an answer from the assembler is worth narrowing down; a lost connection is not.
if (!/^Assembler|could not be assembled/.test(message)) return new AsmError(message);
const mine = gen.filter((g) => g.line > 0 && g.probe !== undefined);
if (mine.length <= 60) {
for (const g of mine) {
try {
await env.assemble(base, g.probe!);
} catch (e) {
const text = e instanceof Error ? e.message : String(e);
if (!/^Assembler|could not be assembled/.test(text)) break;
const shown = input.blocks[g.block].code.split(/\r?\n/)[g.line - 1]?.trim() ?? g.text;
return new AsmError(`${where(input, g.block, g.line)} (${shown}): ${assemblerWords(text)}`, g.line, g.block);
}
}
}
return new AsmError(`The code could not be assembled: ${assemblerWords(message)}`);
}
/**
* Assembles a unit for `base`. Nothing is written anywhere; the result says
* what to write and where everything ended up.
*/
export async function buildUnit(env: AsmEnv, base: bigint, input: UnitInput): Promise<BuiltUnit> {
const parsed = parseUnit(input);
const sizes = new Map<string, number>();
let passes = 0;
// A first guess at how long each moved piece will be; corrected below once its real address is known.
{
const { originals } = generate(input, parsed, base, sizes);
for (const o of originals) {
if (!o.moves) continue;
sizes.set(o.label, (await moveInsns(env, input, o, base)).length);
}
}
for (let round = 0; round < 5; round++) {
const { gen, trailer, originals } = generate(input, parsed, base, sizes);
const text = [...gen.map((g) => g.text), '__cs_end:', ...trailer.map((name) => `.quad ${name}`)].join('\n') + '\n';
let raw: Uint8Array;
passes++;
try {
raw = await env.assemble(base, text);
} catch (e) {
throw await blame(env, input, base, gen, e);
}
const tableSize = trailer.length * 8;
if (raw.length < tableSize) throw new AsmError('The console returned less code than it was sent. Try again.');
const bytes = raw.slice(0, raw.length - tableSize);
const table = view(raw.subarray(raw.length - tableSize));
const addresses = new Map<string, bigint>();
trailer.forEach((name, i) => addresses.set(name, table.getBigUint64(i * 8, true)));
for (const [name, address] of addresses) {
if (address < base || address > base + BigInt(bytes.length)) throw new AsmError(`The assembler put "${name}" somewhere unexpected. Try again.`);
}
let settled = true;
for (const o of originals) {
if (!o.moves) continue;
const to = addresses.get(o.label)!;
const moved = await moveInsns(env, input, o, to);
if (moved.length !== o.size) {
sizes.set(o.label, moved.length);
settled = false;
} else bytes.set(moved, Number(to - base));
}
if (!settled) continue;
const symbols = new Map<string, bigint>();
for (const [name, kind] of parsed.defined) if (kind !== 'const') symbols.set(name, addresses.get(name)!);
return {
base,
bytes,
entries: input.blocks.map((_, i) => addresses.get(`__cs_entry${i}`)!),
symbols,
variables: input.variables.map((v) => ({ name: v.name, type: v.type, address: addresses.get(v.name)!, size: variableBytes(v).length })),
passes,
};
}
throw new AsmError("The game's own instructions at this spot could not be fitted into the new code. Try a different spot.");
}
async function moveInsns(env: AsmEnv, input: UnitInput, use: OriginalUse, to: bigint): Promise<Uint8Array> {
const from = use.insns[0].address;
const bytes = new Uint8Array(use.insns.reduce((n, x) => n + x.bytes.length, 0));
let o = 0;
for (const x of use.insns) {
bytes.set(x.bytes, o);
o += x.bytes.length;
}
try {
return await env.relocate(bytes, from, to);
} catch {
const which = use.insns.find((x) => x.moves) ?? use.insns[0];
throw new AsmError(
`${where(input, use.block, use.line)}: the game's instruction "${which.text}" finds its data or jump target relative to its own position, and the new code is too far away for that to still work. Nothing was changed. Try a spot a line earlier or later.`,
use.line,
use.block,
);
}
}
/** Hex bytes with spaces, as shown to the user. */
export function spaced(bytes: Uint8Array): string {
return bytesToHex(bytes, ' ');
}
+107
View File
@@ -0,0 +1,107 @@
// Who is using the console's one debugger attach right now.
//
// Only one debugging session can exist on the console at a time, and four
// parts of the app want it: "what touches a value" (the Tracer), "what does
// this code touch" (the AccessCollector), pausing and stepping (DebugSession)
// and the first-cheat guide (which traces). Left to themselves, the second one
// to start fails with whatever the console says, or worse, waits.
//
// Each of them claims the gate before attaching and releases it when it lets
// go of the game. A claim that finds the gate taken fails at once with the
// name of whoever holds it, and the holder can be asked to stop, so a screen
// can say "X is using the debugger. Stop it?" instead of hanging.
export interface AttachHolder {
/** Stable name of the part that holds the attach: 'debug', 'trace', 'wizard'. */
id: string;
/** What it is, in the words its own screen uses: 'Pause and step'. */
label: string;
/** Makes it let go of the game. Must end with the gate released (the gate is released afterward regardless). */
stop: () => Promise<void> | void;
}
export class AttachBusyError extends Error {
readonly holder: AttachHolder;
constructor(holder: AttachHolder) {
super(`${holder.label} is using the debugger. Only one thing can watch the game at a time: stop it first.`);
this.name = 'AttachBusyError';
this.holder = holder;
}
}
export class AttachGate {
private holder: AttachHolder | null = null;
private listeners = new Set<() => void>();
/** Whoever holds the attach, or null when it is free. */
get current(): AttachHolder | null {
return this.holder;
}
/**
* Takes the attach for `holder`, or throws AttachBusyError naming the
* current holder. Claiming again under the same id just replaces the
* holder's details. Returns the function that gives it back; calling that
* after someone else has taken over does nothing.
*/
claim(holder: AttachHolder): () => void {
if (this.holder && this.holder.id !== holder.id) throw new AttachBusyError(this.holder);
this.holder = holder;
this.notify();
return () => {
if (this.holder !== holder) return;
this.holder = null;
this.notify();
};
}
/**
* Stops whoever holds the attach and waits until the game has been let go
* of. Does nothing when the gate is free. A holder whose `stop` throws is
* released all the same: by then its connection is the only thing left
* holding the game, and the console drops the session with it.
*/
async stopCurrent(): Promise<void> {
const holder = this.holder;
if (!holder) return;
try {
await holder.stop();
} catch {
// released below either way
}
if (this.holder === holder) {
this.holder = null;
this.notify();
}
}
/** Calls back whenever the holder changes. Returns a function that removes the listener. */
subscribe(cb: () => void): () => void {
this.listeners.add(cb);
return () => void this.listeners.delete(cb);
}
private notify(): void {
for (const cb of [...this.listeners]) {
try {
cb();
} catch {
// a listener's problem
}
}
}
}
// One gate per console connection, found from anything that identifies it
// (the Session object), so every screen reaches the same one without it
// having to be passed around.
const gates = new WeakMap<object, AttachGate>();
export function attachGate(owner: object): AttachGate {
let gate = gates.get(owner);
if (!gate) {
gate = new AttachGate();
gates.set(owner, gate);
}
return gate;
}
+21
View File
@@ -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
View File
@@ -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 };
}
+331 -17
View File
@@ -5,6 +5,17 @@
// title ID and one game version. Each mod is a list of patches; a patch is an
// offset from the start of the game's executable plus the bytes for "on" and
// "off".
//
// A patch can also name a "section" other than 0. That number comes from the
// PS4 tools these files were first made with: it is the position of a memory
// area in the list the debugger gave the cheat's author, and the offset
// counts from the start of that area. The list differs between a PS4 and a
// PS5 and between games, so the number alone cannot be trusted here. etaHEN,
// the PS5 tool these tables are used with, ignores it and always counts from
// the executable, which is right when the author's section was the
// executable and wrong when it was another module. SectionResolver below
// settles each section by looking for the cheat's own "off" bytes in the
// running game, and leaves a cheat alone when that gives no single answer.
import { bytesEqual, hexToBytes } from './bytes.ts';
import { PROT } from './ps5debug.ts';
@@ -14,6 +25,17 @@ export interface Patch {
offset: bigint;
on: Uint8Array;
off: Uint8Array;
/** Which memory area the offset counts from. 0 or missing is the game's executable. */
section?: number;
/** SHN/MC4 "Absolute": the file says the offset is an address, not an offset. */
absolute?: boolean;
/**
* Newer JSON tables also record what the section was on the author's
* console: the module's name, its protection bits, and the offset from the
* start of that one memory area. In those files `offset` itself counts from
* the executable.
*/
where?: { name: string; prot: number | null; offset: bigint };
}
export interface Mod {
@@ -62,17 +84,18 @@ export function parseCheatTable(text: string): CheatTable {
for (const p of Array.isArray(m.memory) ? (m.memory as Record<string, unknown>[]) : []) {
try {
const section = p.section === undefined || p.section === null ? 0 : parseInt(String(p.section), 10) || 0;
if (section !== 0) {
// Non-zero sections are offsets into a "master code" cave, which needs
// the relocation logic etaHEN has and this app does not yet.
unsupported = 'Depends on a master code';
continue;
}
patches.push({
offset: BigInt('0x' + String(p.offset).replace(/^0x/i, '')),
const patch: Patch = {
offset: hexNumber(p.offset),
on: hexToBytes(String(p.on ?? '')),
off: hexToBytes(String(p.off ?? '')),
});
section,
};
if (p.absolute === true || /^true$/i.test(String(p.absolute ?? ''))) patch.absolute = true;
if (section !== 0 && typeof p.sectionName === 'string' && p.sectionOffset !== undefined) {
const prot = Number(p.sectionProtection);
patch.where = { name: p.sectionName, prot: Number.isInteger(prot) ? prot : null, offset: hexNumber(p.sectionOffset) };
}
patches.push(patch);
} catch {
unsupported = 'Could not read this cheat';
}
@@ -91,6 +114,10 @@ export function parseCheatTable(text: string): CheatTable {
};
}
function hexNumber(value: unknown): bigint {
return BigInt('0x' + String(value).replace(/^0x/i, ''));
}
/**
* "01.026.000" and "1.26.0" are the same version; collections are not
* consistent about zero padding.
@@ -150,15 +177,300 @@ export function findImageBase(maps: MapEntry[], processName: string): ImageBase
return { address: m.start, from: m.name || 'first map' };
}
/** Reads the game's memory to work out whether a mod is currently applied. */
export async function readModState(dbg: Ps5Debug, pid: number, base: bigint, mod: Mod): Promise<ModState> {
// ---- sections --------------------------------------------------------------
/** One section a table uses, and what was worked out about it for the running game. */
export interface ResolvedSection {
section: number;
/** True for the lines marked "Absolute", which are settled separately from the numbered sections. */
absolute: boolean;
/** What this section's offsets count from, once known. */
base: bigint | null;
/** Where that came from, for display. */
from: string;
/** Why it is not known, in words for the user. */
problem: string | null;
}
/** Turns a table's patches into addresses in the running game. Built once when a table is loaded. */
export interface SectionResolver {
/** The game's executable, which section 0 counts from. */
base: ImageBase;
/** Where a patch goes, or null when that could not be worked out for certain. */
address(patch: Patch): bigint | null;
/** Why a cheat cannot be used with the running game, in words for the user; null when it can. */
problem(mod: Mod): string | null;
/** Every section other than 0 that the table uses. */
sections: ResolvedSection[];
}
const isPlain = (p: Patch): boolean => !p.section && !p.absolute;
const sectionKey = (p: Patch): string => (p.absolute ? 'abs' : `s${p.section ?? 0}`);
const NEEDS_MAP = 'This cheat changes memory outside the game\'s main executable, and where that is has not been looked up yet. Load the table again.';
const noAnchor = (s: ResolvedSection): string =>
s.absolute
? 'This cheat uses fixed memory addresses, and the table has no original game code to check them against, so it was left alone.'
: `This cheat counts from memory section ${s.section}, and the table has no original game code to check where that is on this console, so it was left alone.`;
const notFound = (s: ResolvedSection): string =>
s.absolute
? 'This cheat uses fixed memory addresses, and the running game does not have the expected code at them, so it was left alone. The table may be for a different version of the game.'
: `Memory section ${s.section}, which this cheat counts from, could not be found in the running game, so it was left alone. The table may be for a different version of the game.`;
const ambiguous = (s: ResolvedSection): string =>
`More than one place in the running game fits ${s.absolute ? 'the fixed addresses this cheat uses' : `memory section ${s.section}, which this cheat counts from`}, so it was left alone rather than risk changing the wrong one.`;
const READ_FAILED = 'The game\'s memory could not be read to check where this cheat goes. Load the table again.';
/** Finds the memory area an address is in. Areas never overlap, so a sorted copy and a binary search do. */
function mapLookup(maps: MapEntry[]) {
const sorted = [...maps].sort((a, b) => (a.start < b.start ? -1 : a.start > b.start ? 1 : 0));
const indexAt = (address: bigint): number => {
let lo = 0;
let hi = sorted.length - 1;
while (lo <= hi) {
const mid = (lo + hi) >> 1;
if (address < sorted[mid].start) hi = mid - 1;
else if (address >= sorted[mid].end) lo = mid + 1;
else return mid;
}
return -1;
};
return {
entryAt: (address: bigint): MapEntry | null => sorted[indexAt(address)] ?? null,
/**
* True when every byte of the range is in readable memory. Asking the
* debugger for anything else ends the connection, so this is checked first.
* With `within`, the range must also start in that area or one of the same module.
*/
readable(address: bigint, length: number, within: MapEntry | null): boolean {
if (address < 0n || length <= 0) return false;
let i = indexAt(address);
if (i < 0) return false;
if (within && sorted[i] !== within && !(within.name && sorted[i].name === within.name)) return false;
const last = address + BigInt(length);
for (; ; i++) {
if (!(sorted[i].prot & PROT.READ)) return false;
if (last <= sorted[i].end) return true;
if (i + 1 >= sorted.length || sorted[i + 1].start !== sorted[i].end) return false;
}
},
};
}
/**
* Where a patch that carries the author's module name goes. Its `offset`
* counts from the executable (what etaHEN uses), and section plus
* `where.offset` say the same thing as a position in the memory map. Either
* is accepted when it lands in the module the file names.
*/
function hintedAddress(maps: MapEntry[], lookup: ReturnType<typeof mapLookup>, base: bigint, p: Patch): bigint | string {
const where = p.where!;
const listed = maps[p.section ?? 0];
const byIndex =
listed && listed.name === where.name && (where.prot === null || (listed.prot & 7) === where.prot) ? listed.start + where.offset : null;
const fromBase = base + p.offset;
const byBase = lookup.entryAt(fromBase)?.name === where.name ? fromBase : null;
if (byIndex !== null && byBase !== null && byIndex !== byBase) {
return `This cheat describes its place in "${where.name}" in two ways that do not agree for the running game, so it was left alone.`;
}
return (
byIndex ??
byBase ??
`This cheat changes "${where.name}", which is not where the table expects it in the running game, so it was left alone. The table may be for a different version of the game.`
);
}
/**
* The resolver as far as the memory map alone can take it: section 0, and
* patches that name their module. Other sections stay unresolved until
* buildResolver has looked at the game's memory.
*/
export function sectionResolver(maps: MapEntry[], table: CheatTable): SectionResolver | null {
const base = findImageBase(maps, table.process);
if (!base) return null;
const lookup = mapLookup(maps);
const groups = new Map<string, ResolvedSection>();
for (const mod of table.mods) {
for (const p of mod.patches) {
if (isPlain(p) || (p.where && !p.absolute) || groups.has(sectionKey(p))) continue;
groups.set(sectionKey(p), { section: p.section ?? 0, absolute: !!p.absolute, base: null, from: '', problem: NEEDS_MAP });
}
}
const place = (p: Patch): bigint | string => {
if (isPlain(p)) return base.address + p.offset;
if (p.where && !p.absolute) return hintedAddress(maps, lookup, base.address, p);
const group = groups.get(sectionKey(p));
if (!group || group.base === null) return group?.problem ?? NEEDS_MAP;
return group.base + p.offset;
};
return {
base,
sections: [...groups.values()],
address(patch) {
const at = place(patch);
return typeof at === 'bigint' ? at : null;
},
problem(mod) {
if (mod.unsupported) return mod.unsupported;
for (const p of mod.patches) {
const at = place(p);
if (typeof at === 'string') return at;
}
return null;
},
};
}
/** Bytes that could not be there by chance: at least four, and not one value repeated (padding, NOPs, zeros). */
function distinctive(bytes: Uint8Array): boolean {
return bytes.length >= 4 && bytes.some((b) => b !== bytes[0]);
}
const MAX_ANCHORS = 8;
/** How many patches are tried against every named memory area; a big game has hundreds of those. */
const MAX_WIDE_ANCHORS = 3;
const MAX_CANDIDATES = 4096;
interface Candidate {
base: bigint;
from: string;
/** The memory area the patch has to land in (or its module) for this candidate to count. */
within: MapEntry | null;
}
/**
* Builds the resolver for a table and the running game, reading a little of
* the game's memory to settle each section the table uses besides 0.
*
* For each such section, the places it could count from are tried: first
* the executable (what etaHEN does) and the memory area with that number in
* the debugger's list (what the number meant where the cheat was made); if
* neither fits, the start of every named memory area (the section is usually
* a module, and its number differs between consoles). A place counts when a
* patch's original code ("off" bytes), or its "on" bytes if the cheat is
* already applied, is found there. The section is settled only if exactly
* one place fits.
*
* Nothing is written. Reads stay inside memory the map lists as readable;
* still, pass a connection of its own (session.openDebugger()) if you can,
* since the debugger drops a connection after a failed read.
*/
export async function buildResolver(
dbg: Pick<Ps5Debug, 'read'>,
pid: number,
maps: MapEntry[],
table: CheatTable,
): Promise<SectionResolver | null> {
const resolver = sectionResolver(maps, table);
if (!resolver) return null;
const lookup = mapLookup(maps);
const baseEntry = lookup.entryAt(resolver.base.address);
const fits = async (c: Candidate, anchor: Patch): Promise<boolean> => {
const alsoOn = distinctive(anchor.on);
const length = Math.max(anchor.off.length, alsoOn ? anchor.on.length : 0);
const at = c.base + anchor.offset;
if (!lookup.readable(at, length, c.within)) return false;
const now = await dbg.read(pid, at, length);
return bytesEqual(now.subarray(0, anchor.off.length), anchor.off) || (alsoOn && bytesEqual(now.subarray(0, anchor.on.length), anchor.on));
};
let failed = false;
for (const sec of resolver.sections) {
if (failed) {
sec.problem = READ_FAILED;
continue;
}
// Patches with recognizable original code. Code caves ("off" all zeros)
// and one-byte switches say nothing about where they are.
const seen = new Set<bigint>();
const anchors: Patch[] = [];
for (const mod of table.mods) {
for (const p of mod.patches) {
if (isPlain(p) || (p.where && !p.absolute) || !!p.absolute !== sec.absolute || (!sec.absolute && (p.section ?? 0) !== sec.section)) continue;
if (!distinctive(p.off) || bytesEqual(p.on, p.off) || seen.has(p.offset)) continue;
seen.add(p.offset);
anchors.push(p);
}
}
anchors.sort((a, b) => b.off.length - a.off.length);
anchors.length = Math.min(anchors.length, MAX_ANCHORS);
if (anchors.length === 0) {
sec.problem = noAnchor(sec);
continue;
}
const likely: Candidate[] = [];
const others: Candidate[] = [];
const taken = new Set<bigint>();
const add = (list: Candidate[], c: Candidate) => {
if (taken.has(c.base) || list.length >= MAX_CANDIDATES) return;
taken.add(c.base);
list.push(c);
};
if (sec.absolute) {
add(likely, { base: 0n, from: 'fixed addresses', within: null });
add(likely, { base: resolver.base.address, from: 'the main executable', within: baseEntry });
} else {
add(likely, { base: resolver.base.address, from: 'the main executable', within: null });
const listed = maps[sec.section];
if (listed) add(likely, { base: listed.start, from: `memory area ${sec.section}${listed.name ? ` (${listed.name})` : ''}`, within: listed });
for (const m of maps) {
if (!m.name || m.name === '(aux)' || m.name === 'stack guard' || m.name.startsWith('anon:')) continue;
add(others, { base: m.start, from: m.name, within: m });
}
}
const search = async (candidates: Candidate[], tries: Patch[]): Promise<Candidate[] | null> => {
let found: Candidate[] | null = null;
for (const anchor of tries) {
const matching: Candidate[] = [];
for (const c of found ?? candidates) if (await fits(c, anchor)) matching.push(c);
// A patch whose code is nowhere (it may depend on another cheat being
// on first) neither settles the section nor rules a place out.
if (matching.length > 0) found = matching;
if (found?.length === 1) break;
}
return found;
};
try {
const found = (await search(likely, anchors)) ?? (await search(others, anchors.slice(0, MAX_WIDE_ANCHORS)));
if (!found) sec.problem = notFound(sec);
else if (found.length > 1) sec.problem = ambiguous(sec);
else {
sec.base = found[0].base;
sec.from = found[0].from;
sec.problem = null;
}
} catch {
sec.problem = READ_FAILED;
failed = true;
}
}
return resolver;
}
/** Where a patch goes, given either the executable's address (section 0 only) or a resolver. */
export function patchAddress(where: bigint | SectionResolver, patch: Patch): bigint | null {
if (typeof where !== 'bigint') return where.address(patch);
return isPlain(patch) ? where + patch.offset : null;
}
/**
* Reads the game's memory to work out whether a mod is currently applied.
* `where` is the executable's address, which is enough for tables that only
* use section 0, or the table's resolver.
*/
export async function readModState(dbg: Ps5Debug, pid: number, where: bigint | SectionResolver, mod: Mod): Promise<ModState> {
if (mod.unsupported || mod.patches.length === 0) return 'unknown';
const places = mod.patches.map((p) => patchAddress(where, p));
if (places.includes(null)) return 'unknown';
let allOn = true;
let allOff = true;
for (const p of mod.patches) {
for (const [i, p] of mod.patches.entries()) {
const len = Math.max(p.on.length, p.off.length);
if (len === 0) return 'unknown';
const cur = await dbg.read(pid, base + p.offset, len);
const cur = await dbg.read(pid, places[i]!, len);
if (!bytesEqual(cur.subarray(0, p.on.length), p.on)) allOn = false;
if (!bytesEqual(cur.subarray(0, p.off.length), p.off)) allOff = false;
}
@@ -171,12 +483,14 @@ export async function readModState(dbg: Ps5Debug, pid: number, base: bigint, mod
return 'mismatch';
}
/** Writes a mod's "on" or "off" bytes. */
export async function applyMod(dbg: Ps5Debug, pid: number, base: bigint, mod: Mod, on: boolean): Promise<void> {
/** Writes a mod's "on" or "off" bytes. Nothing is written unless every patch of the mod has a known place. */
export async function applyMod(dbg: Ps5Debug, pid: number, where: bigint | SectionResolver, mod: Mod, on: boolean): Promise<void> {
if (mod.unsupported) throw new Error(mod.unsupported);
for (const p of mod.patches) {
const places = mod.patches.map((p) => patchAddress(where, p));
if (places.includes(null)) throw new Error((typeof where === 'bigint' ? null : where.problem(mod)) ?? NEEDS_MAP);
for (const [i, p] of mod.patches.entries()) {
const data = on ? p.on : p.off;
if (data.length === 0) continue;
await dbg.write(pid, base + p.offset, data);
await dbg.write(pid, places[i]!, data);
}
}
+50
View File
@@ -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;
}
+1205
View File
File diff suppressed because it is too large. Load diff
+282
View File
@@ -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.`;
}
+347
View File
@@ -0,0 +1,347 @@
// The debugger as the screens use it: one object per game that opens a
// DebugSession when something needs one and closes it when nothing does.
//
// Nobody using the app should have to know that a debugger "attaches". They
// pause the game, or put a breakpoint somewhere, or ask what an instruction
// touches; the session behind that is opened on the first such request and
// closed again as soon as the game is running with no breakpoint left and
// nothing being collected. While it is open it holds the console's one
// attach (see attach.ts), so the Tracer and the first-cheat guide can tell.
//
// "What does this code touch" (AccessCollector) runs inside the same session,
// so it works alongside the user's own breakpoints instead of competing with
// them for the attach.
import { AccessCollector } from './access.ts';
import type { AccessOptions, AccessResult } from './access.ts';
import { AttachGate } from './attach.ts';
import { DebugSession } from './debugger.ts';
import type { Breakpoint, DebugDeps, DebugStop } from './debugger.ts';
import type { GameContext } from './locations.ts';
import type { Registers } from './ps5debug.ts';
export type DeskState = 'idle' | 'opening' | 'running' | 'stopped';
export interface DeskDeps extends DebugDeps {
gate: AttachGate;
}
/** Names the desk holds the attach under, so a screen can word its "busy" message. */
export const DESK_HOLDER = 'debug';
export class DebugDesk {
/** Why the last session ended, when it was not asked to (the game quit, the connection dropped). Cleared when a new one opens. */
ended: string | null = null;
/** The latest "what does this code touch" result; stays after collecting ends. */
access: AccessResult | null = null;
private ctx: GameContext;
private deps: DeskDeps;
private sess: DebugSession | null = null;
private opening: Promise<DebugSession> | null = null;
private closing: Promise<void> | null = null;
private release: (() => void) | null = null;
private collector: AccessCollector | null = null;
private collectorAt: bigint | null = null;
private listeners = new Set<() => void>();
private count = 0;
private disposed = false;
constructor(ctx: GameContext, deps: DeskDeps) {
this.ctx = ctx;
this.deps = deps;
}
// ---- what the screens read --------------------------------------------------------
/** Goes up by one on every change; for `useSyncExternalStore`. */
get version(): number {
return this.count;
}
subscribe(cb: () => void): () => void {
this.listeners.add(cb);
return () => void this.listeners.delete(cb);
}
private changed(): void {
this.count++;
for (const cb of [...this.listeners]) {
try {
cb();
} catch {
// a listener's problem
}
}
}
get session(): DebugSession | null {
return this.sess;
}
get state(): DeskState {
if (this.sess && this.sess.state !== 'closed') return this.sess.state === 'stopped' ? 'stopped' : 'running';
return this.opening ? 'opening' : 'idle';
}
/** The stop the game is sitting at, or null while it runs. */
get stop(): DebugStop | null {
return this.sess?.stop ?? null;
}
/** True while addresses are being collected. */
get collecting(): boolean {
return this.collector?.running === true;
}
/** The user's breakpoints. The one the collector works through is its own business and is not listed. */
breakpoints(): Breakpoint[] {
if (!this.sess || this.sess.state === 'closed') return [];
const hidden = this.collecting ? this.collectorAt : null;
return this.sess.breakpoints().filter((bp) => bp.address !== hidden);
}
/**
* `bytes` (read from the game at `address`) with the game's own byte put
* back wherever a breakpoint sits, so memory and code are shown as the game
* really is. Returns `bytes` itself when there is nothing to put back.
*/
unpatch(address: bigint, bytes: Uint8Array): Uint8Array {
return this.sess && this.sess.state !== 'closed' ? this.sess.unpatch(address, bytes) : bytes;
}
/** Breakpoints switched on inside [address, address + length): do not write a patch over these. */
breakpointsIn(address: bigint, length: number): Breakpoint[] {
return this.sess && this.sess.state !== 'closed' ? this.sess.breakpointsIn(address, length) : [];
}
// ---- opening and closing ------------------------------------------------------------
/**
* The open session, opening one if need be. Throws AttachBusyError (from
* the gate) when the Tracer or the guide holds the attach.
*/
open(): Promise<DebugSession> {
if (this.disposed) return Promise.reject(new Error('This game is no longer the one on screen.'));
if (this.sess && this.sess.state !== 'closed') return Promise.resolve(this.sess);
if (this.opening) return this.opening;
const desk = this;
const release = this.deps.gate.claim({
id: DESK_HOLDER,
get label() {
return desk.collecting && desk.breakpoints().length === 0 && desk.state !== 'stopped' ? 'What this code touches' : 'Pause and step';
},
stop: () => this.close(),
});
this.opening = (async () => {
try {
// A session that is still letting go must finish first, or the console refuses the new one.
if (this.closing) await this.closing;
const s = await DebugSession.open(this.ctx, this.deps);
if (this.disposed) {
await s.close();
throw new Error('This game is no longer the one on screen.');
}
this.sess = s;
this.release = release;
this.ended = null;
s.onStop(() => this.changed());
s.onState((state, reason) => {
if (state === 'closed' && this.sess === s) {
this.sess = null;
this.release?.();
this.release = null;
if (reason) this.ended = reason;
}
this.changed();
});
return s;
} catch (e) {
release();
throw e;
} finally {
this.opening = null;
this.changed();
}
})();
this.changed();
return this.opening;
}
/**
* Lets go of the game: every breakpoint out, the game running. Safe at any
* time, more than once. Collecting ends with it.
*/
close(): Promise<void> {
const s = this.sess;
if (!s) return this.closing ?? Promise.resolve();
if (!this.closing) {
this.closing = s.close().finally(() => {
this.closing = null;
});
}
return this.closing;
}
/** For when the game or the page goes away: closes, and refuses to open again until `revive`. */
dispose(): Promise<void> {
this.disposed = true;
return this.close();
}
/** Undoes `dispose`, for a screen that is shown again for the same game. */
revive(): void {
this.disposed = false;
}
/** Closes the session once nothing needs it: the game running, no breakpoint, nothing being collected. */
private async settle(): Promise<void> {
const s = this.sess;
if (!s || s.state !== 'running' || this.collecting) return;
if (s.breakpoints().length > 0 || s.watchpoints().length > 0) return;
await this.close();
}
// ---- pausing, stepping --------------------------------------------------------------
/** Freezes the game. */
async pause(): Promise<DebugStop> {
const s = await this.open();
try {
return await s.pause();
} finally {
this.changed();
}
}
/** Lets the game run again. With no breakpoint left, this also lets go of the game. */
async resume(): Promise<void> {
const s = this.sess;
if (!s || s.state !== 'stopped') return;
try {
await s.resume();
await this.settle();
} finally {
this.changed();
}
}
/** Runs one instruction of one thread (the stopped one unless told otherwise) and stops again. */
async step(lwpid?: number): Promise<DebugStop> {
const s = this.sess;
if (!s) throw new Error('The game is running. Pause it first.');
try {
return await s.step(lwpid);
} finally {
this.changed();
}
}
/** Changes registers of the stopped thread (see DebugSession.setRegisters for when that is possible). */
async setRegisters(changes: Partial<Registers>): Promise<DebugStop> {
const s = this.sess;
if (!s) throw new Error('The game is running. Pause it first.');
try {
return await s.setRegisters(changes);
} finally {
this.changed();
}
}
// ---- breakpoints --------------------------------------------------------------------
async addBreakpoint(address: bigint): Promise<Breakpoint> {
if (this.collecting && this.collectorAt === address) {
throw new Error('That instruction is being watched for the addresses it touches. Stop that first, then add the breakpoint.');
}
const s = await this.open();
try {
return await s.addBreakpoint(address);
} catch (e) {
// A session opened only for this must not stay behind holding the game.
await this.settle();
throw e;
} finally {
this.changed();
}
}
async setBreakpointEnabled(bp: Breakpoint, enabled: boolean): Promise<void> {
if (!this.sess) return;
try {
await this.sess.setBreakpointEnabled(bp, enabled);
} finally {
this.changed();
}
}
async removeBreakpoint(bp: Breakpoint): Promise<void> {
if (!this.sess) return;
try {
await this.sess.removeBreakpoint(bp);
await this.settle();
} finally {
this.changed();
}
}
// ---- what does this code touch --------------------------------------------------------
/**
* Starts collecting the addresses the instruction at `address` reads or
* writes. Results arrive in `access` (with a change notification per hit).
* Ends by itself at the limits in `opts`; `stopAccess` ends it sooner.
*/
async startAccess(address: bigint, opts: Pick<AccessOptions, 'maxHits' | 'maxSeconds'> = {}): Promise<void> {
if (this.collecting) await this.stopAccess();
if (this.breakpoints().some((bp) => bp.address === address)) {
throw new Error('You have a breakpoint on this instruction. Remove it on the Debug tab first; the two cannot share one instruction.');
}
const s = await this.open();
const c = new AccessCollector(this.ctx, this.deps);
this.collector = c;
this.collectorAt = address;
this.access = null;
try {
await c.start(address, { ...opts, session: s }, (result) => {
if (this.collector !== c) return;
this.access = result;
this.changed();
if (result.done) void this.settle().then(() => this.changed());
});
this.access = c.result();
} catch (e) {
if (this.collector === c) {
this.collector = null;
this.collectorAt = null;
}
await this.settle();
throw e;
} finally {
this.changed();
}
}
/** Ends collecting now; what was found stays in `access`. */
async stopAccess(): Promise<void> {
const c = this.collector;
if (!c) return;
await c.cancel();
if (this.collector === c && this.access) this.access = c.result();
await this.settle();
this.changed();
}
}
// One desk per game on screen. Found from the game context so that every tab
// reaches the same one without a provider of its own.
const desks = new WeakMap<GameContext, DebugDesk>();
export function debugDesk(ctx: GameContext, deps: () => DeskDeps): DebugDesk {
let desk = desks.get(ctx);
if (!desk) {
desk = new DebugDesk(ctx, deps());
desks.set(ctx, desk);
}
return desk;
}
+1258
View File
File diff suppressed because it is too large. Load diff
+171
View File
@@ -0,0 +1,171 @@
// Small pure helpers behind the Debug tab: turning what a stopped game
// reports (registers, the stack) into rows a person can read, and turning
// what a person types back into register values.
import { PROT } from './ps5debug.ts';
import type { Registers } from './ps5debug.ts';
import { fmtAddr, view } from './bytes.ts';
import { addressOwner, describeAddress } from './locations.ts';
import type { GameContext } from './locations.ts';
import type { DebugStop, StackEntry } from './debugger.ts';
import type { Insn } from './x86.ts';
/** The general registers in the order people expect to read them. */
export const REGISTER_ORDER: (keyof Registers)[] = [
'rax', 'rbx', 'rcx', 'rdx', 'rsi', 'rdi', 'rbp', 'rsp',
'r8', 'r9', 'r10', 'r11', 'r12', 'r13', 'r14', 'r15', 'rip', 'rflags',
];
/** One line of plain words per register, for people who have never met them. */
export const REGISTER_HINTS: Partial<Record<keyof Registers, string>> = {
rax: 'Results of calculations and of function calls',
rcx: 'Often a counter, or the 4th argument',
rdx: 'Often the 3rd argument',
rsi: 'Often the 2nd argument',
rdi: 'Often the 1st argument: the object a function works on',
rbp: 'Start of the current function\'s own stack space',
rsp: 'Top of the stack',
rip: 'The instruction that runs next',
rflags: 'Results of the last comparison',
};
/**
* What someone typed into a register box, as a 64-bit value. Hex is the
* default, as everywhere else addresses are typed; "#123" is decimal, and a
* leading minus is allowed for both.
*/
export function parseRegisterValue(text: string): bigint {
let t = text.trim().replace(/[\s_]/g, '');
if (!t) throw new Error('Enter a value');
let negative = false;
if (t.startsWith('-')) {
negative = true;
t = t.slice(1);
}
let value: bigint;
if (/^#\d+$/.test(t)) value = BigInt(t.slice(1));
else if (/^(0x)?[0-9a-f]+$/i.test(t)) value = BigInt('0x' + t.replace(/^0x/i, ''));
else throw new Error(`"${text.trim()}" is not a number. Type hex (1A2B), or decimal with a # in front (#100).`);
if (value > 0xffffffffffffffffn) throw new Error('That is too large for a register (64 bits).');
return BigInt.asUintN(64, negative ? -value : value);
}
/**
* The 16 bytes of an XMM register holding one decimal number in its lowest
* part, the way game code keeps a single float or double there. The rest of
* the register is taken from `current` so the other lanes stay as they were.
*/
export function xmmWithNumber(current: Uint8Array, kind: 'float' | 'double', text: string): Uint8Array {
const n = Number(text.trim());
if (text.trim() === '' || Number.isNaN(n)) throw new Error(`"${text.trim()}" is not a number.`);
if (current.length !== 16) throw new Error('An XMM register is 16 bytes');
const out = current.slice();
if (kind === 'float') view(out).setFloat32(0, n, true);
else view(out).setFloat64(0, n, true);
return out;
}
/** A float or double for display: short, and never in a form that hides that it is tiny or huge. */
export function fmtNumber(n: number, single: boolean): string {
if (!Number.isFinite(n)) return String(n);
if (n === 0) return '0';
const a = Math.abs(n);
if (a < 1e-4 || a >= 1e9) return n.toExponential(3);
return String(Number(n.toPrecision(single ? 7 : 12)));
}
/** In plain words, why the game stopped. */
export function describeStop(stop: DebugStop): string {
const c = stop.cause;
switch (c.kind) {
case 'breakpoint':
return `The game reached your breakpoint at ${c.breakpoint.where}.`;
case 'watchpoint':
return `Something ${c.watchpoint.kind === 'write' ? 'wrote to' : c.watchpoint.kind === 'execute' ? 'ran the code at' : 'used'} ${fmtAddr(c.watchpoint.address)}.`;
case 'step':
return 'One instruction ran, and the game is paused again.';
case 'pause':
return 'You paused the game.';
case 'signal':
return c.signal === 11
? 'The game tried to use memory it does not have (signal 11). It is about to crash; resuming will most likely just repeat it.'
: c.signal === 4
? 'The game ran into something that is not a valid instruction (signal 4). It is about to crash.'
: c.signal === 5
? 'The game keeps stopping on a trap of its own (signal 5).'
: `The game received signal ${c.signal}, which usually means it is about to crash.`;
}
}
export interface StackWord {
/** Where on the stack this value is. */
address: bigint;
/** Distance from the top of the stack (RSP). */
offset: number;
value: bigint;
/**
* return: where a caller continues, as the console's own stack walk found it.
* code: points into code but is not on that chain (an old return address, or a function pointer).
* module: points into a module's data. stack: points elsewhere on this stack. memory: points at other mapped memory.
* Undefined for plain numbers.
*/
kind?: 'return' | 'code' | 'module' | 'stack' | 'memory';
/** "game+0x5010" for module addresses, "stack +0x40" for stack ones; undefined for plain numbers. */
label?: string;
}
/**
* The top `count` 8-byte values of a stopped thread's stack, each explained
* where it can be: return addresses from `chain` are picked out, other
* values that point into code or a module are named, and pointers into the
* stack itself say where they lead.
*
* `bytes` is what was read from the game at `rsp` (pass it through
* `unpatch` first only if it could overlap code, which a stack does not).
*/
export async function stackWords(ctx: GameContext, rsp: bigint, bytes: Uint8Array, chain: StackEntry[]): Promise<StackWord[]> {
const maps = (await ctx.maps()).filter((m) => m.end > m.start);
const returns = new Set(chain.filter((e) => e.kind === 'return').map((e) => e.address));
const here = maps.find((m) => rsp >= m.start && rsp < m.end) ?? null;
const dv = view(bytes);
const out: StackWord[] = [];
for (let off = 0; off + 8 <= bytes.length; off += 8) {
const value = dv.getBigUint64(off, true);
const word: StackWord = { address: rsp + BigInt(off), offset: off, value };
out.push(word);
if (value < 0x10000n || value >= 0x800000000000n) continue;
const map = maps.find((m) => value >= m.start && value < m.end);
if (!map) continue;
const owner = await addressOwner(ctx, value);
if (returns.has(value)) {
word.kind = 'return';
word.label = await describeAddress(ctx, value);
} else if (map.prot & PROT.EXEC) {
word.kind = 'code';
word.label = await describeAddress(ctx, value);
} else if (owner) {
word.kind = 'module';
word.label = await describeAddress(ctx, value);
} else if (here && map === here) {
word.kind = 'stack';
const d = value - rsp;
word.label = d >= 0n ? `stack +0x${d.toString(16).toUpperCase()}` : `stack -0x${(-d).toString(16).toUpperCase()}`;
} else {
word.kind = 'memory';
}
}
return out;
}
/** The number type an instruction most likely works with, from its operand size and name. */
export function kindForInsn(insn: Insn): string {
const size = insn.memory?.size ?? 4;
// "movsd" is also the name of a plain string copy, so the name only counts next to a vector register.
const vector = /\b[xy]mm\d/.test(insn.text);
const scalar = vector && /^\s*v?\w+(ss|ps)\b/.test(insn.text);
const wide = vector && /^\s*v?\w+(sd|pd)\b/.test(insn.text);
if (size === 1) return 'u8';
if (size === 2) return 'u16';
if (size === 8) return wide ? 'double' : 'u64';
return scalar ? 'float' : wide ? 'double' : 'u32';
}
+342
View File
@@ -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;
}
+158
View File
@@ -0,0 +1,158 @@
// A made-up engine helper for the demo console's made-up game ("Specter
// Run"), so the Engine tab can be tried without a PS5. Everything it reports
// is read from the mock game's memory (mock/mock-ps5.mjs lays that out), and
// it only ever answers for that game: detectDemoEngine checks the mock's
// fingerprints first and returns null for anything else, so none of this can
// show up for a real console.
import type { ClassInfo, EngineHelper, FieldInfo, MethodInfo, ObjectInfo } from './engine.ts';
import { imageBase } from './locations.ts';
import type { GameContext } from './locations.ts';
import { PROT } from './ps5debug.ts';
import { view } from './bytes.ts';
import type { ConsoleVariable } from './unreal-cvars.ts';
// Offsets as in GAME in the mock.
const DATA = 0x10000n;
const STATIC = { player: 0x100n, world: 0x200n, characterVtable: 0x300n };
const FUNCS = { add: 0x4000n, setGold: 0x4100n };
const SETTINGS = [
{ name: 't.MaxFPS', at: 0x800n, kind: 'float' as const, help: 'Caps the frame rate. 0 means no cap. (Made up for the demo.)' },
{ name: 'r.ScreenPercentage', at: 0x810n, kind: 'float' as const, help: 'Rendering resolution in percent. (Made up for the demo.)' },
{ name: 'r.MotionBlurQuality', at: 0x820n, kind: 'int' as const, help: 'Motion blur strength, 0 to 4. (Made up for the demo.)' },
];
const field = (name: string, offset: number, type: string, size: number): FieldInfo => ({ name, offset, type, size });
const CLASSES: ClassInfo[] = [
{
name: 'Character',
fullName: 'SpecterRun.Character',
parent: 'Actor',
size: 0x40,
fields: [
field('Name', 0x8, 'char*', 8),
field('Stamina', 0x10, 'int32', 4),
field('Health', 0x14, 'float', 4),
field('Team', 0x18, 'int32', 4),
field('Position', 0x20, 'float [3]', 12),
field('Speed', 0x30, 'double', 8),
field('Target', 0x38, 'Character*', 8),
],
},
{
name: 'Player',
fullName: 'SpecterRun.Player',
parent: 'Actor',
size: 0x408,
fields: [field('Inventory', 0x100, 'int32 [4]', 16), field('Health', 0x340, 'int32', 4), field('Ammo', 0x344, 'int32', 4), field('Unlocks', 0x400, 'uint32', 4)],
},
{ name: 'World', fullName: 'SpecterRun.World', size: 0x10, fields: [field('Player', 0x8, 'Player*', 8)] },
{ name: 'Actor', fullName: 'SpecterRun.Actor', size: 0x8, fields: [] },
];
export interface DemoHelper extends EngineHelper {
readonly notes: string[];
consoleVariables(query: string, limit?: number): Promise<ConsoleVariable[]>;
consoleVariablesNamed(names: string[]): Promise<ConsoleVariable[]>;
}
export async function detectDemoEngine(ctx: GameContext): Promise<DemoHelper | null> {
const image = await imageBase(ctx);
const data = image + DATA;
const read = (address: bigint, length: number): Promise<Uint8Array> => ctx.dbg().read(ctx.pid, address, length);
const u64 = async (address: bigint): Promise<bigint> => view(await read(address, 8)).getBigUint64(0, true);
// The mock's fingerprints: its two static pointers, and a world that points back at the player.
const maps = await ctx.maps();
const readable = (a: bigint): boolean => maps.some((m) => m.prot & PROT.READ && a >= m.start && a + 16n <= m.end);
if (!readable(data + STATIC.world)) return null;
const player = await u64(data + STATIC.player);
const world = await u64(data + STATIC.world);
if (!readable(player) || !readable(world) || (await u64(world + 8n)) !== player) return null;
const vtable = data + STATIC.characterVtable;
const heap = maps.find((m) => player >= m.start && player < m.end)!;
const characters = async (limit: number): Promise<bigint[]> => {
const pattern = new Uint8Array(8);
view(pattern).setBigUint64(0, vtable, true);
const mask = new Uint8Array(8).fill(1);
const out: bigint[] = [];
for (let at = heap.start; at < heap.end && out.length < limit; ) {
const hit = await ctx.dbg().aobScan({ pid: ctx.pid, address: at, length: Number(heap.end - at), pattern, mask });
if (hit === 0n || hit < at) break;
out.push(hit);
at = hit + 8n;
}
return out;
};
const characterName = async (address: bigint): Promise<string | undefined> => {
const p = await u64(address + 8n);
if (!readable(p)) return undefined;
const raw = await read(p, 16);
let s = '';
for (const c of raw) {
if (c === 0) break;
s += String.fromCharCode(c);
}
return s || undefined;
};
const describe = async (address: bigint): Promise<ObjectInfo | null> => {
if (address === player) return { address, className: 'Player', name: 'Player' };
if (address === world) return { address, className: 'World', name: 'World' };
if (!readable(address) || (await u64(address)) !== vtable) return null;
const name = await characterName(address);
return name ? { address, className: 'Character', name } : { address, className: 'Character' };
};
const has = (text: string, query: string): boolean => text.toLowerCase().includes(query.trim().toLowerCase());
const methods: MethodInfo[] = [
{ name: 'Wallet.SetGold', address: image + FUNCS.setGold, signature: '(amount: int64) -> float' },
{ name: 'Math.Add', address: image + FUNCS.add, signature: '(a: int64, b: int64) -> int64' },
];
const setting = async (s: (typeof SETTINGS)[number]): Promise<ConsoleVariable> => {
const raw = view(await read(data + s.at, 4));
return {
name: s.name,
kind: s.kind,
value: s.kind === 'float' ? raw.getFloat32(0, true) : raw.getInt32(0, true),
address: data + s.at,
mirrors: [data + s.at + 4n],
help: s.help,
object: data + s.at,
};
};
return {
engine: 'cpp',
label: 'Specter Run demo engine',
notes: [
'Everything on this page is made up for the demo console, so you can try the screens. A real game shows what its own engine reports.',
'The demo game does not act on its hidden settings; changing one only changes the number in memory.',
],
describeObject: describe,
classInfo: async (nameOrAddress) => {
if (typeof nameOrAddress === 'bigint') {
const o = await describe(nameOrAddress);
return o ? (CLASSES.find((c) => c.name === o.className) ?? null) : null;
}
const want = nameOrAddress.trim().toLowerCase();
return CLASSES.find((c) => c.name.toLowerCase() === want || c.fullName.toLowerCase() === want) ?? null;
},
findClasses: async (query, limit = 100) => CLASSES.filter((c) => has(c.name, query)).slice(0, limit),
findObjects: async (query, limit = 100) => {
const out: ObjectInfo[] = [];
for (const c of CLASSES) {
if (!has(c.name, query)) continue;
const addresses = c.name === 'Player' ? [player] : c.name === 'World' ? [world] : c.name === 'Character' ? await characters(limit) : [];
for (const a of addresses) {
const o = await describe(a);
if (o && out.length < limit) out.push(o);
}
}
return out;
},
findMethods: async (query, limit = 100) => methods.filter((m) => has(m.name, query)).slice(0, limit),
consoleVariables: async (query, limit = 100) => Promise.all(SETTINGS.filter((s) => has(s.name, query)).slice(0, limit).map(setting)),
consoleVariablesNamed: async (names) => Promise.all(SETTINGS.filter((s) => names.some((n) => n.toLowerCase() === s.name.toLowerCase())).map(setting)),
};
}
+329
View File
@@ -0,0 +1,329 @@
// A cheap first guess at which engine the running game was built with, and
// the small memory tools the Godot and GameMaker helpers share.
//
// The guess reads nothing but the memory map and searches the game's own
// program for a handful of telltale texts. It never writes, and it never
// touches the game's working memory, so it is safe to run on anything.
import { PROT } from './ps5debug.ts';
import type { MapEntry } from './ps5debug.ts';
import { imageRange } from './locations.ts';
import type { GameContext } from './locations.ts';
import type { EngineId } from './engine.ts';
import { view } from './bytes.ts';
// ---- shared memory tools ------------------------------------------------------
export function asciiBytes(text: string): Uint8Array {
const out = new Uint8Array(text.length);
for (let i = 0; i < text.length; i++) out[i] = text.charCodeAt(i) & 0xff;
return out;
}
export function utf16Bytes(text: string): Uint8Array {
const out = new Uint8Array(text.length * 2);
for (let i = 0; i < text.length; i++) view(out).setUint16(i * 2, text.charCodeAt(i), true);
return out;
}
export function pointerBytes(address: bigint): Uint8Array {
const out = new Uint8Array(8);
view(out).setBigUint64(0, address, true);
return out;
}
/**
* The readable parts of the game's own executable, in the order worth
* searching for text: read-only data first (where compilers put text), then
* code, then writable data.
*/
export async function imageMaps(ctx: GameContext): Promise<MapEntry[]> {
const maps = await ctx.maps();
let range: { start: bigint; end: bigint };
try {
range = await imageRange(ctx);
} catch {
return [];
}
const rank = (m: MapEntry): number => (m.prot & PROT.EXEC ? 1 : m.prot & PROT.WRITE ? 2 : 0);
return maps
.filter((m) => m.start >= range.start && m.end <= range.end && m.end > m.start && m.prot & PROT.READ)
.sort((a, b) => rank(a) - rank(b) || (a.start < b.start ? -1 : 1));
}
/**
* Where to look for text the compiler put into the game's program. A PS5
* game's code is in maps of its own with the text kept apart in read-only
* ones (seen on a real game: two read-only maps, one code map, two writable),
* so when read-only maps exist only the non-code maps are searched, which is a
* quarter of the bytes. Older layouts that mix text into the code get the lot.
*/
export async function textMaps(ctx: GameContext): Promise<MapEntry[]> {
const image = await imageMaps(ctx);
const apart = image.some((m) => !(m.prot & PROT.EXEC) && !(m.prot & PROT.WRITE));
return apart ? image.filter((m) => !(m.prot & PROT.EXEC)) : image;
}
export function inMaps(maps: MapEntry[], address: bigint): MapEntry | null {
for (const m of maps) if (address >= m.start && address < m.end) return m;
return null;
}
/** The debugger takes a 32-bit length, so very large maps are searched in slices. */
const SLICE = 0x40000000;
/**
* Addresses where the bytes occur inside the given maps, in map order, up to
* `limit`. Each search stops at its first match, so finding something near the
* start of a map is quick; proving something is absent reads the whole map.
*/
export async function findBytes(
ctx: GameContext,
maps: MapEntry[],
pattern: Uint8Array,
opts: { limit?: number; aligned?: number; deadline?: number } = {},
): Promise<bigint[]> {
const limit = opts.limit ?? 1;
const mask = new Uint8Array(pattern.length).fill(1);
const out: bigint[] = [];
for (const m of maps) {
let start = m.start;
while (start < m.end && out.length < limit) {
if (opts.deadline !== undefined && Date.now() > opts.deadline) return out;
const remaining = m.end - start;
if (remaining < BigInt(pattern.length)) break;
const length = remaining > BigInt(SLICE) ? SLICE : Number(remaining);
const hit = await ctx.scanDbg().aobScan({ pid: ctx.pid, address: start, length, pattern, mask });
if (hit === 0n) {
if (remaining <= BigInt(SLICE)) break;
// Overlap slices by the pattern length so a match across the seam is not lost.
start += BigInt(length - (pattern.length - 1));
continue;
}
// An answer outside what was asked for would send the next search backward.
if (hit < start || hit >= m.end) break;
if (!opts.aligned || hit % BigInt(opts.aligned) === 0n) out.push(hit);
start = hit + 1n;
}
if (out.length >= limit) break;
}
return out;
}
const PAGE = 0x1000n;
/**
* Reads game memory through a small page cache, because following pointers
* means many tiny reads close together and each one is a round trip to the
* console. Make a new one for each question: memory changes while the game
* runs, and a stale page would give a stale answer.
*
* The console returns zeros for memory that cannot be read, so nothing here
* throws for a bad address; callers must check what they get.
*/
export class MemReader {
private ctx: GameContext;
private pages = new Map<bigint, Uint8Array>();
constructor(ctx: GameContext) {
this.ctx = ctx;
}
private async page(base: bigint): Promise<Uint8Array> {
let p = this.pages.get(base);
if (!p) {
p = await this.ctx.dbg().read(this.ctx.pid, base, Number(PAGE));
// Keeps a long crawl from holding on to megabytes.
if (this.pages.size >= 512) this.pages.clear();
this.pages.set(base, p);
}
return p;
}
async bytes(address: bigint, length: number): Promise<Uint8Array> {
if (address < 0n || length <= 0) return new Uint8Array(Math.max(0, length));
// Big reads go straight through; caching them would only copy them twice.
if (length > 0x4000) return this.ctx.dbg().read(this.ctx.pid, address, length);
const out = new Uint8Array(length);
let done = 0;
while (done < length) {
const at = address + BigInt(done);
const base = at - (at % PAGE);
const skip = Number(at - base);
const take = Math.min(length - done, Number(PAGE) - skip);
out.set((await this.page(base)).subarray(skip, skip + take), done);
done += take;
}
return out;
}
async u64(address: bigint): Promise<bigint> {
return view(await this.bytes(address, 8)).getBigUint64(0, true);
}
async u32(address: bigint): Promise<number> {
return view(await this.bytes(address, 4)).getUint32(0, true);
}
/**
* A NUL-terminated text of plain printable ASCII, or null if the bytes at
* the address are anything else (including longer than `max`).
*/
async cstring(address: bigint, max = 256): Promise<string | null> {
if (address < 0x10000n) return null;
let text = '';
for (let got = 0; got < max; ) {
// Read to the end of the page first so a text near the end of readable memory still works.
const at = address + BigInt(got);
const take = Math.min(max - got, Number(PAGE - (at % PAGE)));
const chunk = await this.bytes(at, take);
for (let i = 0; i < chunk.length; i++) {
const c = chunk[i];
if (c === 0) return text.length ? text : null;
if (c < 0x20 || c > 0x7e) return null;
text += String.fromCharCode(c);
}
got += take;
}
return null;
}
}
/** True for a value that could be a pointer into a process on this console at all. */
export function plausiblePointer(p: bigint): boolean {
return p >= 0x10000n && p < 0x0001000000000000n && p % 8n === 0n;
}
// ---- the guess ------------------------------------------------------------------
export interface EngineGuess {
engine: EngineId | 'unknown';
/** What was found, in plain words, for showing to the person. */
evidence: string[];
}
interface Probe {
engine: EngineId;
text: string;
/** Unreal keeps its text as 16-bit characters; everything else here is plain bytes. */
wide?: boolean;
/** Enough on its own to name the engine. The others are only looked for afterward, as support. */
decides?: boolean;
}
// Order matters twice: the search stops at the first engine that is found, and
// each absent text costs one pass over the executable's data, so the engines
// most games use come first.
const PROBES: Probe[] = [
{ engine: 'unreal', text: '/Script/CoreUObject', wide: true, decides: true },
{ engine: 'unreal', text: 'UnrealEngine', wide: true },
{ engine: 'unity', text: 'UnityEngine', decides: true },
{ engine: 'unity', text: 'il2cpp' },
{ engine: 'unity', text: 'UnityPlayer' },
{ engine: 'godot', text: 'Godot Engine', decides: true },
{ engine: 'godot', text: 'GDScript' },
{ engine: 'godot', text: 'res://' },
{ engine: 'gamemaker', text: 'gml_Object_', decides: true },
{ engine: 'gamemaker', text: 'gml_Script_' },
{ engine: 'gamemaker', text: 'YoYo' },
{ engine: 'gamemaker', text: 'GameMaker' },
];
const ENGINE_NAMES: Record<EngineId, string> = {
unreal: 'Unreal Engine',
unity: 'Unity',
godot: 'Godot',
gamemaker: 'GameMaker',
cpp: 'a custom C++ engine',
};
interface ModuleHint {
match: RegExp;
/** Set when the file names the engine without any further looking. */
engine?: EngineId;
what: string;
}
// Files that games load beside their main program. Only Unity ships its game
// code this way on PlayStation; the others are middleware worth mentioning
// because they show up in the memory map and people ask what they are.
const MODULE_HINTS: ModuleHint[] = [
{ match: /il2cpp/i, engine: 'unity', what: 'which holds a Unity game\'s own code (IL2CPP)' },
{ match: /unityplayer/i, engine: 'unity', what: 'which is the Unity engine itself' },
// Not proof of Unity: MonoGame and others bring the same runtime, so the text search still decides.
{ match: /^lib(mono|coreclr)|mono-?2\.0|monosgen/i, what: 'a C# runtime, which Unity games built with Mono use (so do a few other engines)' },
{ match: /fmod/i, what: 'the FMOD sound library (used with many engines)' },
{ match: /^lib(ak|wwise)|wwise/i, what: 'the Wwise sound library (used with many engines)' },
{ match: /bink/i, what: 'the Bink video player (used with many engines)' },
];
/**
* Guesses the engine of the running game. Read-only and bounded: the memory
* map, then a few text searches in the game's executable that stop as soon as
* one engine is recognized. An unrecognized game costs the most, because every
* text has to be shown absent; `budgetMs` caps that, and the evidence says so
* when the cap was reached before everything was checked.
*/
export async function identifyEngine(ctx: GameContext, opts: { budgetMs?: number } = {}): Promise<EngineGuess> {
const deadline = Date.now() + (opts.budgetMs ?? 4000);
const evidence: string[] = [];
const maps = await ctx.maps();
const seen = new Set<ModuleHint>();
for (const m of maps) {
// Thread stacks and shared memory are in the map too, named after whoever
// made them ("FMOD mixer thread"); only loaded files count here. The map
// keeps 31 characters of a name, so a name that long may have lost its ending.
if (!/\.s?prx$/i.test(m.name) && m.name.length < 31) continue;
const hint = MODULE_HINTS.find((h) => h.match.test(m.name));
// One line per kind of thing: a game often loads several files of the same library.
if (!hint || seen.has(hint)) continue;
seen.add(hint);
evidence.push(`The game has loaded "${m.name}", ${hint.what}.`);
if (hint.engine) return { engine: hint.engine, evidence };
}
const image = await textMaps(ctx);
if (image.length === 0) {
evidence.push('Could not find the game\'s main program in its memory map, so its text could not be checked.');
return { engine: 'unknown', evidence };
}
const found = async (p: Probe): Promise<boolean> => {
const hits = await findBytes(ctx, image, p.wide ? utf16Bytes(p.text) : asciiBytes(p.text), { deadline });
return hits.length > 0;
};
const say = (p: Probe): string => `The game's program contains the text "${p.text}", which ${ENGINE_NAMES[p.engine]} games carry.`;
let engine: EngineId | null = null;
let outOfTime = false;
for (const p of PROBES) {
if (!p.decides) continue;
if (Date.now() > deadline) {
outOfTime = true;
break;
}
if (await found(p)) {
engine = p.engine;
evidence.push(say(p));
break;
}
}
if (engine) {
// Supporting texts are a courtesy, so they only get whatever time is left.
for (const p of PROBES) {
if (p.engine !== engine || evidence.includes(say(p))) continue;
if (Date.now() > deadline) break;
if (await found(p)) evidence.push(say(p));
}
return { engine, evidence };
}
if (outOfTime || Date.now() > deadline) {
evidence.push('Stopped looking before every engine had been checked, because the search was taking too long.');
} else {
evidence.push('None of the texts that Unreal, Unity, Godot or GameMaker games carry were found in the game\'s program.');
}
return { engine: 'unknown', evidence };
}
+104
View File
@@ -0,0 +1,104 @@
// Reading the fields an engine helper lists: which of the scanner's value
// types fits a field's engine type, and how to show its current value.
import type { FieldInfo } from './engine.ts';
import { valueKind } from './scanner.ts';
import type { ValueKind } from './scanner.ts';
import { fmtAddr, view } from './bytes.ts';
// Engine type names (Unreal's, C#'s, and the usual C ones) by the scanner type that reads them.
const SIMPLE: Record<string, string> = {
float: 'float',
single: 'float',
double: 'double',
int: 'i32',
int32: 'i32',
uint: 'u32',
uint32: 'u32',
short: 'i16',
int16: 'i16',
ushort: 'u16',
uint16: 'u16',
char: 'u16',
long: 'i64',
int64: 'i64',
ulong: 'u64',
uint64: 'u64',
byte: 'u8',
uint8: 'u8',
sbyte: 'i8',
int8: 'i8',
bool: 'u8',
};
const BY_SIZE: Record<number, string> = { 1: 'u8', 2: 'u16', 4: 'u32', 8: 'u64' };
/** The scanner value type that reads this field whole, or null for anything that is not one plain number. */
export function fieldValueKind(field: FieldInfo): ValueKind | null {
const type = field.type.trim().toLowerCase().replace(/^system\./, '');
if (SIMPLE[type]) return valueKind(SIMPLE[type]);
// An enum is a small whole number; how small is in the field's size.
if (/^enum\b/.test(type) && field.size && BY_SIZE[field.size]) return valueKind(BY_SIZE[field.size]);
return null;
}
/** True for a field that holds the address of something else. */
export function isPointerField(field: FieldInfo): boolean {
if ((field as { reference?: boolean }).reference) return true;
const type = field.type.trim();
return /\*$/.test(type) || /^(Object|ObjectPtr|Class|ClassPtr|Interface)Property\b/.test(type);
}
export interface FieldValue {
/** For display. */
text: string;
/** Set when the field is one plain number that can be written back as this type. */
kind: ValueKind | null;
/** As it would be typed into a value box, when `kind` is set. */
input?: string;
/** Set when the field is a pointer that leads somewhere. */
pointer?: bigint;
}
/**
* A field's current value, from bytes that start at `base` bytes into the
* object (0 when they are the whole object). Null when the bytes do not
* reach the field or its type is not one this can show.
*/
export function decodeField(field: FieldInfo, bytes: Uint8Array, base = 0): FieldValue | null {
const at = field.offset - base;
if (at < 0 || field.isStatic) return null;
const dv = view(bytes);
const type = field.type.trim().toLowerCase();
const bit = /^bool \(bit (\d)\)$/.exec(type);
if (bit) {
if (at + 1 > bytes.length) return null;
return { text: (bytes[at] >> Number(bit[1])) & 1 ? 'true' : 'false', kind: null };
}
if (isPointerField(field)) {
if (at + 8 > bytes.length) return null;
const p = dv.getBigUint64(at, true);
return p === 0n ? { text: 'nothing (null)', kind: null } : { text: fmtAddr(p), kind: null, pointer: p };
}
const kind = fieldValueKind(field);
if (!kind || at + kind.size > bytes.length) return null;
let n: number | bigint;
if (kind.float) n = kind.size === 4 ? dv.getFloat32(at, true) : dv.getFloat64(at, true);
else if (kind.size === 8) n = kind.signed ? dv.getBigInt64(at, true) : dv.getBigUint64(at, true);
else if (kind.size === 4) n = kind.signed ? dv.getInt32(at, true) : dv.getUint32(at, true);
else if (kind.size === 2) n = kind.signed ? dv.getInt16(at, true) : dv.getUint16(at, true);
else n = kind.signed ? dv.getInt8(at) : dv.getUint8(at);
// Floats are shown short; the full value goes into the value box.
const input = typeof n === 'number' && kind.float ? String(kind.size === 4 ? Number(n.toPrecision(7)) : n) : String(n);
if (type === 'bool') return { text: n ? 'true' : 'false', kind, input };
return { text: typeof n === 'number' && kind.float ? String(Number(n.toPrecision(6))) : String(n), kind, input };
}
/** How many bytes of an object to read so that every listed field is covered, within reason. */
export function objectSpan(fields: FieldInfo[], classSize?: number, cap = 0x8000): number {
let end = 0;
for (const f of fields) if (!f.isStatic) end = Math.max(end, f.offset + (f.size ?? 8));
if (classSize && classSize > end) end = classSize;
return Math.max(8, Math.min(end, cap));
}
+77
View File
@@ -0,0 +1,77 @@
// Engine helpers: what the game's own engine can tell us about its memory.
//
// A search finds numbers; an engine helper finds *names*. Unity, Unreal and a
// few others keep the names of classes and fields in the running game, and
// most C++ games keep at least each class's name. With those, "the object at
// this address" becomes "a PlayerCharacter, and offset 0x1038 is FireRate".
//
// Each engine gets one module (unreal.ts, il2cpp.ts, rtti.ts, ...) exporting a
// detect function that returns an EngineHelper, or null when the running game
// is not that engine. The screens only ever talk to this interface.
import type { GameContext } from './locations.ts';
export type EngineId = 'unreal' | 'unity' | 'godot' | 'gamemaker' | 'cpp';
export interface FieldInfo {
name: string;
/** Bytes from the start of the object. */
offset: number;
/** As the engine names it, e.g. "float", "int32", "FVector", "PlayerState*". */
type: string;
/** Bytes the field occupies, when known. */
size?: number;
/** Class that declares it, when it is inherited. */
declaredIn?: string;
/** True for a static field, whose offset is into the class's static data, not the object. */
isStatic?: boolean;
}
export interface ClassInfo {
/** Short name, e.g. "PlayerCharacter". */
name: string;
/** With namespace or package where the engine has one, e.g. "Game.PlayerCharacter". */
fullName: string;
parent?: string;
/** Size of an instance in bytes, when known. */
size?: number;
/** Own and inherited fields, by offset. May be empty until asked for with classInfo(). */
fields: FieldInfo[];
/** Where the engine's description of the class lives, for looking it up again. */
address?: bigint;
}
export interface ObjectInfo {
address: bigint;
className: string;
/** The object's own name, where objects have one (Unreal). */
name?: string;
}
export interface MethodInfo {
/** e.g. "PlayerWallet.set_Money". */
name: string;
/** Where its code starts in the running game. */
address: bigint;
/** Parameters and return type as text, when known. */
signature?: string;
}
export interface EngineHelper {
readonly engine: EngineId;
/** What was detected, for display: "Unreal Engine 5.3", "Unity (IL2CPP, metadata v29)". */
readonly label: string;
/** The class of the object at this address, or null if it is not an object this engine knows. */
describeObject(address: bigint): Promise<ObjectInfo | null>;
/** A class by name or by the address of its description, with its fields filled in. */
classInfo(nameOrAddress: string | bigint): Promise<ClassInfo | null>;
/** Classes whose name contains the text (case-insensitive). Fields may be left empty. */
findClasses(query: string, limit?: number): Promise<ClassInfo[]>;
/** Live objects whose class name contains the text (case-insensitive). */
findObjects(query: string, limit?: number): Promise<ObjectInfo[]>;
/** Functions by name, for engines that keep them. Absent where that is not possible. */
findMethods?(query: string, limit?: number): Promise<MethodInfo[]>;
}
/** Each engine module exports one of these. It must be cheap and return null quickly for other engines. */
export type EngineDetector = (ctx: GameContext) => Promise<EngineHelper | null>;
+257
View File
@@ -0,0 +1,257 @@
// One question for the screens: "which engine is this game built with, and
// what can it tell us?" The answer is worked out once per running game.
//
// The order is: a cheap first guess from the game's loaded files and a few
// telltale texts (engine-detect.ts), then the helper for the engine that
// guess names, and as a last resort plain C++ class names (rtti.ts), which
// work for any game that kept its type information.
import type { EngineDetector, EngineHelper, EngineId } from './engine.ts';
import { identifyEngine } from './engine-detect.ts';
import type { EngineGuess } from './engine-detect.ts';
import { detectDemoEngine } from './engine-demo.ts';
import { detectGameMaker } from './gamemaker.ts';
import { detectGodot } from './godot.ts';
import { detectUnity } from './il2cpp.ts';
import type { GameContext } from './locations.ts';
import { detectCpp } from './rtti.ts';
import { detectUnreal } from './unreal.ts';
import type { ConsoleVariable, ConsoleVariableOptions } from './unreal-cvars.ts';
export const ENGINE_TITLES: Record<EngineId | 'unknown', string> = {
unreal: 'Unreal Engine',
unity: 'Unity',
godot: 'Godot',
gamemaker: 'GameMaker',
cpp: 'its own C++ engine',
unknown: 'an engine CheatSpecter does not recognize',
};
export interface EngineReport {
/** Null when nothing could be read from this game. */
helper: EngineHelper | null;
/** The first guess and what it was based on, in plain words. */
guess: EngineGuess;
/** 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 {
/** Told what is being done, as it happens. */
onProgress?: (text: string) => void;
/** Look again instead of using the answer in hand. */
refresh?: boolean;
/** The demo console's made-up game gets a made-up helper. Never set for a real console. */
demo?: boolean;
/** For tests: stand-ins for the first guess and for the per-engine detectors. */
identify?: (ctx: GameContext) => Promise<EngineGuess>;
detectors?: Partial<Record<EngineId, EngineDetector>>;
}
const DETECTORS: Record<EngineId, EngineDetector> = {
unreal: detectUnreal,
unity: detectUnity,
godot: detectGodot,
gamemaker: detectGameMaker,
cpp: detectCpp,
};
/** The order the named engines are tried in when the first guess ran out of time. */
const ORDER: EngineId[] = ['unreal', 'unity', 'godot', 'gamemaker'];
/** The engines still tried after a first guess that found nothing. Both turn other games away quickly. */
const SECOND_LOOK: EngineId[] = ['unreal', 'unity'];
const pending = new WeakMap<GameContext, Promise<EngineReport>>();
const settled = new WeakMap<GameContext, EngineReport>();
const reason = (e: unknown): string => (e instanceof Error ? e.message : String(e));
async function run(ctx: GameContext, opts: DetectOptions): Promise<EngineReport> {
const started = Date.now();
const steps: string[] = [];
const say = (text: string): void => {
try {
opts.onProgress?.(text);
} catch {
// A listener's own trouble must not stop the search.
}
};
const detectors = { ...DETECTORS, ...opts.detectors };
if (opts.demo) {
say('Looking at the demo game');
const helper = await detectDemoEngine(ctx).catch(() => null);
if (helper) {
return {
helper,
guess: { engine: helper.engine, evidence: ['This is the demo console\'s made-up game, so its classes and settings are made up too. They are there to try the screens with.'] },
steps: [],
ms: Date.now() - started,
};
}
}
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> => {
const title = id === 'cpp' ? 'C++ class names' : ENGINE_TITLES[id];
say(id === 'cpp' ? 'Looking for C++ class names' : `Reading ${title} data from the game`);
const t = Date.now();
try {
const helper = await detectors[id](ctx);
const took = `${((Date.now() - t) / 1000).toFixed(1)} s`;
steps.push(helper ? `${title}: found (${helper.label}), in ${took}.` : `${title}: not found in this game (${took}).`);
return helper;
} catch (e) {
steps.push(`${title}: stopped with an error (${reason(e)}).`);
errors++;
return null;
}
};
let helper: EngineHelper | null = null;
if (guess.engine !== 'unknown' && guess.engine !== 'cpp') helper = await attempt(guess.engine);
else {
// A guess that did not get through its list rules nothing out. One that
// did has shown the Godot and GameMaker texts absent, which is all their
// helpers go by; the Unreal and Unity helpers look at other things (the
// engine's built-in names, the loaded files), so they still get a turn.
for (const id of unfinished ? ORDER : SECOND_LOOK) {
helper = await attempt(id);
if (helper) break;
}
}
if (!helper) helper = await attempt('cpp');
if (helper && guess.engine === 'unknown' && helper.engine !== 'cpp') guess = { engine: helper.engine, evidence: guess.evidence };
return helper || errors === 0 ? { helper, guess, steps, ms: Date.now() - started } : { helper, guess, steps, ms: Date.now() - started, failed: true };
}
/**
* Finds the engine helper for the game behind `ctx`. The answer is kept for
* that game process: asking again is free, and two askers share one search.
* Never throws. Takes from under a second to about half a minute.
*/
export function detectEngine(ctx: GameContext, opts: DetectOptions = {}): Promise<EngineReport> {
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, failed: true }))
.then((report) => {
settled.set(ctx, report);
return report;
});
pending.set(ctx, job);
}
return job;
}
/** What detectEngine found for this game, if it has finished; null otherwise. Never starts a search. */
export function knownEngine(ctx: GameContext): EngineReport | null {
return settled.get(ctx) ?? null;
}
// ---- things only some helpers can do ---------------------------------------------
/** A helper that can list the game's hidden settings (Unreal's console variables). */
export interface SettingsHelper extends EngineHelper {
consoleVariables(query: string, limit?: number, opts?: ConsoleVariableOptions): Promise<ConsoleVariable[]>;
consoleVariablesNamed(names: string[], opts?: ConsoleVariableOptions): Promise<ConsoleVariable[]>;
}
export function hasSettings(helper: EngineHelper | null): helper is SettingsHelper {
const h = helper as Partial<SettingsHelper> | null;
return !!h && typeof h.consoleVariables === 'function' && typeof h.consoleVariablesNamed === 'function';
}
/** The helper's own list of limits, for those that carry one. */
export function helperNotes(helper: EngineHelper | null): string[] {
const notes = (helper as { notes?: unknown } | null)?.notes;
return Array.isArray(notes) ? notes.filter((n): n is string => typeof n === 'string') : [];
}
export interface SettingShortcut {
/** What a person would call it. */
title: string;
/** The console variable behind it. */
name: string;
/** What changing it does, and what values make sense. */
hint: string;
/** Values worth offering in a list. `value` is as it would be typed. */
choices?: { label: string; value: string }[];
}
/** Settings people most often look for, with values worth offering. Only those the game has are shown. */
export const SETTING_SHORTCUTS: SettingShortcut[] = [
{
title: 'Frame-rate cap',
name: 't.MaxFPS',
hint: 'The most frames per second the game will draw. 0 means no cap of its own (the console\'s display still limits it).',
choices: [
{ label: 'No cap', value: '0' },
{ label: '30', value: '30' },
{ label: '60', value: '60' },
{ label: '120', value: '120' },
],
},
{
title: 'Resolution scale',
name: 'r.ScreenPercentage',
hint: 'How much of full resolution the game renders at, in percent. Lower is blurrier and faster.',
choices: [
{ label: '50%', value: '50' },
{ label: '75%', value: '75' },
{ label: '100%', value: '100' },
],
},
{
title: 'Motion blur',
name: 'r.MotionBlurQuality',
hint: '0 switches motion blur off; 4 is the strongest.',
choices: [
{ label: 'Off', value: '0' },
{ label: 'Low', value: '1' },
{ label: 'High', value: '4' },
],
},
{
title: 'Motion blur (default for cameras)',
name: 'r.DefaultFeature.MotionBlur',
hint: 'Whether cameras use motion blur unless told otherwise.',
choices: [
{ label: 'Off', value: '0' },
{ label: 'On', value: '1' },
],
},
{ title: 'Field of view', name: 'fov', hint: 'Only some games register this. Degrees; 90 is typical.' },
{
title: 'Vertical sync',
name: 'r.VSync',
hint: 'Waits for the display before showing each frame.',
choices: [
{ label: 'Off', value: '0' },
{ label: 'On', value: '1' },
],
},
{ title: 'Depth of field', name: 'r.DepthOfFieldQuality', hint: '0 switches the out-of-focus blur off.', choices: [{ label: 'Off', value: '0' }, { label: 'Normal', value: '2' }] },
{ title: 'Film grain', name: 'r.FilmGrain', hint: '0 switches film grain off.', choices: [{ label: 'Off', value: '0' }, { label: 'On', value: '1' }] },
{ title: 'Color fringing', name: 'r.SceneColorFringeQuality', hint: '0 switches the colored edges (chromatic aberration) off.', choices: [{ label: 'Off', value: '0' }, { label: 'On', value: '1' }] },
{ title: 'View distance', name: 'r.ViewDistanceScale', hint: 'How far away things are drawn. 1 is normal, 2 is twice as far.' },
];
+408
View File
@@ -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,
};
}
+278
View File
@@ -0,0 +1,278 @@
// GameMaker (compiled, "YYC") helper: the names of the game's scripts and
// object events, and where the code of each one starts.
//
// A compiled GameMaker game keeps a table of every script and every object
// event it contains: a name such as "gml_Object_obj_player_Step_0" beside the
// address of the code. Nothing else in this file is needed to find "the code
// that runs when the player takes a step", which is most of what a cheat
// maker wants from this engine.
//
// HOW SURE THIS IS. No GameMaker game was available when this was written, so
// it has only ever run against memory built by its own tests. It is written
// so that it cannot invent an answer: the table is located and accepted by
// what it contains (each name really is a text starting with "gml_", each
// code address really is inside the game's code), never by a fixed offset. If
// a real game lays things out differently the helper reports nothing rather
// than something wrong.
//
// What the layout knowledge rests on:
// - The table entry (`YYGMLFuncs`: name, code, a third pointer) is from the
// YYToolkit modding project's headers, which describe current Windows
// runners. Runners older than 2.3 are said to have no third pointer; both
// widths are tried and the contents decide.
// - The runner's start-up record (`SLLVMVars`) holding the number of entries
// next to the table pointer is from memory of generated YYC projects. It is
// only used to confirm the count, and its absence is not an error.
//
// NOT DONE, and what each would take on a real game:
// - describeObject / findObjects (which object is this instance, list the
// instances). Needs CInstance -> its CObjectGM -> name, and the room's or
// object's instance list. Field positions differ between runner versions.
// On a real game: find one instance (pointer-scan from a known variable),
// look for a pointer to a record whose first field points at the object's
// name (the object names are known from findClasses), then find the list
// that links instances of the same object.
// - Instance variables by name. Each instance has a hash map from a variable
// number to a 16-byte value (`RValue`: 8 bytes of data, 4 of flags, 4 of
// kind; kind 0 is a double). Per YYToolkit the map is `CHashMap<int,
// RValue*>` with a 16-byte header (size, used, mask, grow threshold), an
// element array of (value pointer, key, hash) and hash = key * 0x9E3779B1
// + 1, which could be checked by content. What is missing is the entry
// point (an instance address, see above) and the table that turns a
// variable number into its name: compiled games bake the numbers of their
// own variables into the code, and where the runner keeps the names was
// not known well enough to look for it blind.
import { PROT } from './ps5debug.ts';
import type { MapEntry } from './ps5debug.ts';
import type { GameContext } from './locations.ts';
import type { ClassInfo, EngineDetector, EngineHelper, MethodInfo, ObjectInfo } from './engine.ts';
import { view } from './bytes.ts';
import { MemReader, asciiBytes, findBytes, imageMaps, inMaps, pointerBytes, textMaps } from './engine-detect.ts';
export interface GameMakerHelper extends EngineHelper {
/** Plain sentences about what this helper cannot do, for showing next to its results. */
readonly notes: string[];
/** Every script and event in the game's table, in table order. */
readonly functions: readonly MethodInfo[];
/** True when the runner's own count of functions matched the table that was read. */
readonly countConfirmed: boolean;
}
/** More than any real game has; a table "longer" than this is something else. */
const MAX_FUNCTIONS = 200_000;
const NAME_MAX = 512;
const isName = (s: string | null): s is string => !!s && /^gml_[\x21-\x7e]+$/.test(s);
// Object events are named gml_Object_<object>_<event>_<number>. Collision
// events end in the other object's name (or number) instead.
const EVENT = /^gml_Object_(.+)_(?:PreCreate|Create|Destroy|CleanUp|Alarm|Step|Keyboard|KeyPress|KeyRelease|Mouse|Other|Draw|Gesture|Trigger)_\d+$/;
const COLLISION = /^gml_Object_(.+?)_Collision_.+$/;
export function objectNameOf(functionName: string): string | null {
const m = EVENT.exec(functionName) ?? COLLISION.exec(functionName);
return m ? m[1] : null;
}
// From the code GameMaker's compiler generates; given as text only, to help read the code.
function signatureOf(name: string): string | undefined {
if (name.startsWith('gml_Object_')) return 'void (CInstance* self, CInstance* other)';
if (name.startsWith('gml_Script_')) return 'RValue& (CInstance* self, CInstance* other, RValue& result, int argumentCount, RValue** arguments)';
return undefined;
}
interface Table {
start: bigint;
stride: number;
functions: MethodInfo[];
}
/**
* Reads the table that the entry at `seed` belongs to, or null if `seed` is
* not inside such a table. An entry is a pointer to a "gml_" name followed by
* a pointer into the game's code; `stride` is how far apart entries are.
*/
async function readTable(ctx: GameContext, image: MapEntry[], seed: bigint, stride: number): Promise<Table | null> {
const code = image.filter((m) => m.prot & PROT.EXEC);
const holder = inMaps(image, seed);
if (!holder || code.length === 0) return null;
const reader = new MemReader(ctx);
const entryAt = (buf: Uint8Array, off: number): { name: bigint; code: bigint } | null => {
const dv = view(buf);
const name = dv.getBigUint64(off, true);
const fn = dv.getBigUint64(off + 8, true);
if (!inMaps(image, name) || !inMaps(code, fn)) return null;
// The third pointer, where there is one, is empty or points back into the game.
if (stride === 24) {
const extra = dv.getBigUint64(off + 16, true);
if (extra !== 0n && !inMaps(image, extra)) return null;
}
return { name, code: fn };
};
// Pass 1: how far the run of entry-shaped records extends either side of the seed.
const BLOCK = 2048 * stride;
const shaped: { name: bigint; code: bigint }[] = [];
let first = seed;
for (let at = seed; at + BigInt(stride) <= holder.end && shaped.length < MAX_FUNCTIONS; ) {
const want = Math.min(BLOCK, Number(holder.end - at) - (Number(holder.end - at) % stride));
if (want <= 0) break;
const buf = await reader.bytes(at, want);
let n = 0;
for (; n * stride < want; n++) {
const e = entryAt(buf, n * stride);
if (!e) break;
shaped.push(e);
}
if (n * stride < want) break;
at += BigInt(want);
}
if (shaped.length === 0) return null;
const before: { name: bigint; code: bigint }[] = [];
for (let end = seed; end - BigInt(stride) >= holder.start && before.length < MAX_FUNCTIONS; ) {
const room = Number(end - holder.start);
const want = Math.min(BLOCK, room - (room % stride));
if (want <= 0) break;
const buf = await reader.bytes(end - BigInt(want), want);
let n = 0;
for (; n * stride < want; n++) {
const e = entryAt(buf, want - (n + 1) * stride);
if (!e) break;
before.push(e);
}
if (n * stride < want) break;
end -= BigInt(want);
}
// Pass 2: the names. They sit together in the game's text, so one read of
// the span they cover is far cheaper than one read per name.
const all = [...before.reverse(), ...shaped];
const seedIndex = before.length;
let lo = all[0].name;
let hi = all[0].name;
for (const e of all) {
if (e.name < lo) lo = e.name;
if (e.name > hi) hi = e.name;
}
const span = Number(hi - lo) + NAME_MAX;
let blob: Uint8Array | null = null;
if (span <= 0x4000000) {
blob = new Uint8Array(span);
for (let off = 0; off < span; off += 0x100000) {
const take = Math.min(0x100000, span - off);
blob.set(await ctx.dbg().read(ctx.pid, lo + BigInt(off), take), off);
}
}
const nameOf = async (address: bigint): Promise<string | null> => {
if (!blob) return reader.cstring(address, NAME_MAX);
let text = '';
for (let i = Number(address - lo); i < blob.length && text.length <= NAME_MAX; i++) {
const c = blob[i];
if (c === 0) return text;
if (c < 0x20 || c > 0x7e) return null;
text += String.fromCharCode(c);
}
return null;
};
// Keep the unbroken run of real names around the seed. Whatever else looked
// like an entry (another table of name and code pairs could sit right next
// to this one) is dropped here.
const names: (string | null)[] = new Array(all.length).fill(null);
if (!isName((names[seedIndex] = await nameOf(all[seedIndex].name)))) return null;
let from = seedIndex;
let to = seedIndex + 1;
while (to < all.length && isName((names[to] = await nameOf(all[to].name)))) to++;
while (from > 0 && isName((names[from - 1] = await nameOf(all[from - 1].name)))) from--;
first = seed - BigInt((seedIndex - from) * stride);
const functions: MethodInfo[] = [];
for (let i = from; i < to; i++) {
const name = names[i] as string;
const signature = signatureOf(name);
functions.push(signature ? { name, address: all[i].code, signature } : { name, address: all[i].code });
}
return { start: first, stride, functions };
}
/** True when the runner's start-up record next to a pointer to the table holds the same number of entries. */
async function confirmCount(ctx: GameContext, data: MapEntry[], table: Table): Promise<boolean> {
const refs = await findBytes(ctx, data, pointerBytes(table.start), { limit: 4, aligned: 8 });
const reader = new MemReader(ctx);
for (const ref of refs) {
// The count is one of the small numbers stored just before the pointers.
const buf = await reader.bytes(ref - 0x30n, 0x30);
for (let off = 0; off < 0x30; off += 4) if (view(buf).getUint32(off, true) === table.functions.length) return true;
}
return false;
}
export const detectGameMaker: EngineDetector = async (ctx: GameContext): Promise<GameMakerHelper | null> => {
const text = await textMaps(ctx);
if (text.length === 0) return null;
const image = await imageMaps(ctx);
// The table and the pointers to it are data, never code.
const data = image.filter((m) => !(m.prot & PROT.EXEC));
const reader = new MemReader(ctx);
// A few places where an event name occurs. The first may be a text the
// runner itself uses rather than a name in the table, so several are tried.
const hits = await findBytes(ctx, text, asciiBytes('gml_Object_'), { limit: 6 });
if (hits.length === 0) return null;
const tables: Table[] = [];
const tried = new Set<bigint>();
for (const hit of hits) {
// Step back to where this text starts.
const back = await reader.bytes(hit - 64n, 64);
let start = hit;
for (let i = 63; i >= 0 && back[i] >= 0x21 && back[i] <= 0x7e; i--) start = hit - BigInt(64 - i);
if (tried.has(start) || !isName(await reader.cstring(start, NAME_MAX))) continue;
tried.add(start);
for (const ref of await findBytes(ctx, data, pointerBytes(start), { limit: 4, aligned: 8 })) {
for (const stride of [24, 16]) {
const found = await readTable(ctx, image, ref, stride);
if (found) tables.push(found);
}
}
// Three entries is the least that tells a table from a coincidence.
if (tables.some((t) => t.functions.length >= 3)) break;
}
tables.sort((a, b) => b.functions.length - a.functions.length);
if (tables.length === 0 || tables[0].functions.length < 3) return null;
const table = tables[0];
const countConfirmed = await confirmCount(ctx, data, table);
const objects = new Map<string, number>();
for (const f of table.functions) {
const o = objectNameOf(f.name);
if (o) objects.set(o, (objects.get(o) ?? 0) + 1);
}
const classOf = (name: string): ClassInfo => ({ name, fullName: name, fields: [] });
const contains = (name: string, query: string): boolean => name.toLowerCase().includes(query.toLowerCase());
return {
engine: 'gamemaker',
label: `GameMaker (compiled, ${table.functions.length.toLocaleString('en-US')} scripts and events)`,
functions: table.functions,
countConfirmed,
notes: [
'Scripts and object events can be found by name. Search for "gml_Object_" plus an object\'s name to see its events.',
'Objects are listed from the names of their events, so an object with no events of its own is missing.',
'Telling which object a piece of memory belongs to, listing live objects, and reading variables by name are not available for GameMaker games.',
'This helper has not been tried on a real GameMaker game yet.',
],
// See the top of the file for what these two would need.
describeObject: async (): Promise<ObjectInfo | null> => null,
findObjects: async (): Promise<ObjectInfo[]> => [],
classInfo: async (nameOrAddress: string | bigint): Promise<ClassInfo | null> =>
typeof nameOrAddress === 'string' && objects.has(nameOrAddress) ? classOf(nameOrAddress) : null,
findClasses: async (query: string, limit = 100): Promise<ClassInfo[]> =>
[...objects.keys()]
.filter((name) => contains(name, query))
.slice(0, limit)
.map(classOf),
findMethods: async (query: string, limit = 100): Promise<MethodInfo[]> => table.functions.filter((f) => contains(f.name, query)).slice(0, limit),
};
};
+917
View File
@@ -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);
}
+571
View File
@@ -0,0 +1,571 @@
// Godot helper: which class an object is, which script is attached to it, and
// the script's variables by name with their current values.
//
// HOW SURE THIS IS. No Godot game was available when this was written, so it
// has only ever run against memory built by its own tests. The engine's source
// is public, and the layouts below were read from it (3.5, 4.2, 4.3, 4.5,
// 4.6), but a console port can be built from changed source. So nothing here
// trusts a fixed offset. Every structure is found by scanning a small window
// for something that proves itself by its contents, and anything that could
// be read two ways is reported as "not found" rather than guessed:
//
// - A name (`StringName`) is accepted only when the record holding the text
// also holds the engine's own checksum of that exact text (djb2, the same
// in every version from 3.5 to 4.6). A stray pointer passing that by
// accident is a one in four billion event.
// - An object's class is the name reached from one of the object's own
// fields in exactly the way the engine reaches it (`_class_name_ptr`, or
// `_class_ptr` in Godot 3, or `_gdtype_ptr` from 4.6), and the object must
// start with a table of functions that lies in the game's code.
// - A script's variables are accepted only when three independent things
// agree: the script instance points back at the object, the script it
// points at is of class "GDScript" (by the rule above), and the script
// holds a name -> number map whose numbers are exactly 0..N-1, where N is
// the length the engine recorded for the instance's array of values.
//
// KNOWN LIMITS:
// - Each value (`Variant`) is taken to be 24 bytes. A game built with 64-bit
// decimals ("double precision") uses 40; such builds say "double" in their
// version text and readMembers refuses them. If the version text was not
// found this cannot be noticed, and values after the first would be wrong.
// - A script with exactly one variable and also exactly one signal (Godot 3)
// or one constant holding null can be read two ways, and is refused.
// - Scripts in C# are not handled (different instance layout).
//
// NOT DONE, and what each would take on a real game:
// - findObjects / findClasses for engine classes. The engine keeps every
// live object in `ObjectDB` (4.x: a static array `object_slots` of
// {validator, next_free, Object*}; 3.x: a static HashMap) and every class
// in `ClassDB::classes`. Both are static variables with no name left in a
// shipped game, so they have to be found through the code that uses them
// (for example the function behind `instance_from_id`). With one real game
// to look at, that is a signature search; blind, it would be a guess.
// - Unverified on hardware: all of it. The first thing to check on a real
// game is describeObject on any address a pointer scan lands on that holds
// a node; if that names a class, the rest follows from the same rules.
import { PROT } from './ps5debug.ts';
import type { MapEntry } from './ps5debug.ts';
import type { GameContext } from './locations.ts';
import type { ClassInfo, EngineDetector, EngineHelper, FieldInfo, ObjectInfo } from './engine.ts';
import { view } from './bytes.ts';
import { MemReader, asciiBytes, findBytes, imageMaps, inMaps, plausiblePointer, textMaps } from './engine-detect.ts';
export interface GodotVersion {
major: number;
minor?: number;
patch?: number;
/** As found in the game, e.g. "4.2.1.stable.official". Empty when only the major version could be told. */
text: string;
}
export interface GodotMember {
name: string;
/** Position in the instance's array of values. */
index: number;
/** The kind of value it holds right now, e.g. "int", "float", "String". Variables can change kind. */
type: string;
/** The current value as text. */
text: string;
/** Where the 24-byte value record is. */
address: bigint;
/**
* Where the number itself is, for the kinds a cheat can simply overwrite:
* a 64-bit whole number for "int", a 64-bit decimal for "float", one byte
* for "bool". Absent for everything else.
*/
valueAddress?: bigint;
valueType?: 'int64' | 'double' | 'bool';
}
export interface GodotHelper extends EngineHelper {
readonly version: GodotVersion | null;
/** Plain sentences about what this helper cannot do, for showing next to its results. */
readonly notes: string[];
/**
* The class of the object at this address. For an object with a GDScript
* attached, `name` is the script's path ("res://player.gd"): Godot objects
* are told apart by their script far more than by their engine class.
*/
describeObject(address: bigint): Promise<ObjectInfo | null>;
/**
* Given the address of an object with a GDScript, or of the script itself:
* the script's variables. Here FieldInfo.offset is NOT a byte offset. It is
* the variable's position in the instance's array of values; use
* readMembers for addresses. A name works only for scripts this helper has
* already seen through an address.
*/
classInfo(nameOrAddress: string | bigint): Promise<ClassInfo | null>;
/** The script variables of the object at this address with their current values, or null if it has no GDScript. */
readMembers(objectAddress: bigint): Promise<GodotMember[] | null>;
}
// How far into a structure to look for the field we want. Generous compared
// with the real sizes (an Object is about 0x150 bytes, a script instance 0x50,
// a GDScript about 0x400), because a wider window only costs time.
const OBJECT_SCAN = 0x180;
const INSTANCE_SCAN = 0x60;
const SCRIPT_SCAN = 0x800;
// A name record is 0x28 to 0x30 bytes with the text and checksum in its first
// 0x28. Looking further would read into whatever was allocated next, and a
// text from one record with a checksum from its neighbor proves nothing.
const DATA_SCAN = 0x28;
const MAX_MEMBERS = 4096;
const VARIANT_SIZE = 24;
const IDENT = /^[\p{L}_][\p{L}\p{N}_]*$/u;
const TYPES_3 = ['null', 'bool', 'int', 'float', 'String', 'Vector2', 'Rect2', 'Vector3', 'Transform2D', 'Plane', 'Quat', 'AABB', 'Basis', 'Transform', 'Color', 'NodePath', 'RID', 'Object', 'Dictionary', 'Array', 'PoolByteArray', 'PoolIntArray', 'PoolRealArray', 'PoolStringArray', 'PoolVector2Array', 'PoolVector3Array', 'PoolColorArray'];
const TYPES_4 = ['null', 'bool', 'int', 'float', 'String', 'Vector2', 'Vector2i', 'Rect2', 'Rect2i', 'Vector3', 'Vector3i', 'Transform2D', 'Vector4', 'Vector4i', 'Plane', 'Quaternion', 'AABB', 'Basis', 'Transform3D', 'Projection', 'Color', 'StringName', 'NodePath', 'RID', 'Object', 'Callable', 'Signal', 'Dictionary', 'Array', 'PackedByteArray', 'PackedInt32Array', 'PackedInt64Array', 'PackedFloat32Array', 'PackedFloat64Array', 'PackedStringArray', 'PackedVector2Array', 'PackedVector3Array', 'PackedColorArray', 'PackedVector4Array'];
/** The engine's checksum of a name: djb2 over its characters. */
export function godotNameHash(text: string): number {
let h = 5381;
for (const ch of text) h = (Math.imul(h, 33) + (ch.codePointAt(0) as number)) >>> 0;
return h;
}
/**
* Text the engine stores as wide characters. Godot 4 uses 32-bit characters;
* Godot 3 uses the platform's wide character, which may be 16-bit, so that is
* tried second. Null unless the bytes are well-formed text with an ending.
*/
async function wideText(reader: MemReader, address: bigint, max = 300): Promise<string | null> {
if (address < 0x10000n || address >= 0x0001000000000000n) return null;
for (const width of [4, 2]) {
const buf = await reader.bytes(address, 64 * width);
let text = '';
let ok = true;
let ended = false;
for (let got = 0, chunk = buf; ok && !ended && got < max; ) {
const dv = view(chunk);
for (let i = 0; i + width <= chunk.length; i += width) {
const c = width === 4 ? dv.getUint32(i, true) : dv.getUint16(i, true);
if (c === 0) {
ended = true;
break;
}
if (c < 0x20 || c > 0x10ffff || (c >= 0xd800 && c <= 0xdfff) || c === 0x7f) {
ok = false;
break;
}
text += String.fromCodePoint(c);
got++;
}
if (ok && !ended) chunk = await reader.bytes(address + BigInt(got * width), 64 * width);
}
if (ok && ended && text.length > 0) return text;
}
return null;
}
interface Lens {
reader: MemReader;
image: MapEntry[];
code: MapEntry[];
names: Map<bigint, string | null>;
}
/**
* The text of the name whose record (`StringName::_Data`) is at this address,
* or null. The record holds the text either as a pointer to plain characters
* or as an engine string, and, somewhere beside it, the checksum of the text;
* both must be present and agree.
*/
async function nameAt(lens: Lens, data: bigint): Promise<string | null> {
if (!plausiblePointer(data)) return null;
const known = lens.names.get(data);
if (known !== undefined) return known;
let found: string | null = null;
const buf = await lens.reader.bytes(data, DATA_SCAN);
if (buf.some((b) => b !== 0)) {
const dv = view(buf);
for (let off = 0; off < DATA_SCAN - 8 && found === null; off += 8) {
const p = dv.getBigUint64(off, true);
if (p < 0x10000n || p >= 0x0001000000000000n) continue;
for (const text of [await lens.reader.cstring(p, 200), await wideText(lens.reader, p, 200)]) {
if (!text) continue;
// In every version the checksum comes after the text in the record.
const sum = godotNameHash(text);
for (let at = off + 8; at < DATA_SCAN; at += 4) if (dv.getUint32(at, true) === sum) found = text;
if (found !== null) break;
}
}
}
lens.names.set(data, found);
return found;
}
/** True when the address starts with a pointer to a table of functions in the game's code, as every engine object does. */
async function hasVtable(lens: Lens, address: bigint): Promise<boolean> {
if (!plausiblePointer(address)) return false;
const vtable = await lens.reader.u64(address);
if (!plausiblePointer(vtable) || !inMaps(lens.image, vtable)) return false;
return !!inMaps(lens.code, await lens.reader.u64(vtable));
}
/**
* The engine class of the object at this address. One of the object's fields
* points at a static name (4.0 to 4.5), at a name stored in the object itself
* (3.x), or at a type record whose second field is the name (4.6 on); in each
* case the name is one or two pointers into what the field points at.
*/
async function classNameOf(lens: Lens, object: bigint): Promise<string | null> {
if (!(await hasVtable(lens, object))) return null;
const head = view(await lens.reader.bytes(object, OBJECT_SCAN));
for (let off = 8; off < OBJECT_SCAN; off += 8) {
const p = head.getBigUint64(off, true);
if (!plausiblePointer(p)) continue;
const direct = await nameAt(lens, await lens.reader.u64(p));
if (direct && IDENT.test(direct)) return direct;
// The type record of 4.6 on: parent type, then the name. Taking "the
// second pointer" on its own would also pick up whatever name happens to be
// stored next to an unrelated pointer, so the first field has to check out
// too: empty (the root class) or another type record with a name.
const parent = await lens.reader.u64(p);
if (parent !== 0n) {
const parentName = plausiblePointer(parent) ? await nameAt(lens, await lens.reader.u64(parent + 8n)) : null;
if (!parentName || !IDENT.test(parentName)) continue;
}
const typed = await nameAt(lens, await lens.reader.u64(p + 8n));
if (typed && IDENT.test(typed)) return typed;
}
return null;
}
interface Attached {
/** The script instance: the object's own copy of the script's variables. */
instance: bigint;
script: bigint;
}
/**
* The GDScript attached to an object: a field of the object points at a
* record that starts with a table of functions, points back at the object,
* and points at an object of class "GDScript".
*/
async function attachedScript(lens: Lens, object: bigint): Promise<Attached | null> {
const head = view(await lens.reader.bytes(object, OBJECT_SCAN));
for (let off = 8; off < OBJECT_SCAN; off += 8) {
const instance = head.getBigUint64(off, true);
if (!plausiblePointer(instance) || instance === object || inMaps(lens.image, instance)) continue;
const body = view(await lens.reader.bytes(instance, INSTANCE_SCAN));
let pointsBack = false;
for (let o = 8; o < 0x40; o += 8) if (body.getBigUint64(o, true) === object) pointsBack = true;
if (!pointsBack || !(await hasVtable(lens, instance))) continue;
for (let o = 8; o < INSTANCE_SCAN; o += 8) {
const script = body.getBigUint64(o, true);
if (script === object || !plausiblePointer(script)) continue;
if ((await classNameOf(lens, script)) === 'GDScript') return { instance, script };
}
}
return null;
}
/**
* Follows a linked container of (name, number) pairs starting from `start`
* and returns the pairs if the numbers are exactly 0..count-1. `keyAt` is
* where the name sits in each node: 0x10 in Godot 4's HashMap element (after
* next and prev), 0x30 in Godot 3's Map element (after color, right, left,
* parent, next and prev). The links are every pointer before the name.
*/
async function pairsFrom(lens: Lens, start: bigint, keyAt: number): Promise<Map<string, number> | null> {
const size = keyAt + 16;
const pairs = new Map<string, number>();
const seenIndex = new Set<number>();
const visited = new Set<bigint>([start]);
const queue: bigint[] = [start];
for (let steps = 0; queue.length > 0; steps++) {
if (steps > MAX_MEMBERS * 3) return null;
const at = queue.shift() as bigint;
const node = view(await lens.reader.bytes(at, size));
const index = node.getInt32(keyAt + 8, true);
let name: string | null = null;
if (index >= 0 && index < MAX_MEMBERS) name = await nameAt(lens, node.getBigUint64(keyAt, true));
if (name !== null && IDENT.test(name)) {
if (pairs.has(name) || seenIndex.has(index)) return null;
pairs.set(name, index);
seenIndex.add(index);
} else if (at !== start) {
// The ends of the container are marker nodes with no name. They are not
// followed, or the walk would wander off into unrelated memory. Only the
// starting point may be one (Godot 3's map is entered through a marker).
continue;
}
for (let off = 0; off < keyAt; off += 8) {
const next = node.getBigUint64(off, true);
if (!plausiblePointer(next) || visited.has(next) || inMaps(lens.image, next)) continue;
visited.add(next);
queue.push(next);
}
}
if (pairs.size === 0) return null;
for (let i = 0; i < pairs.size; i++) if (!seenIndex.has(i)) return null;
return pairs;
}
const sameMap = (a: Map<string, number>, b: Map<string, number>): boolean => a.size === b.size && [...a].every(([k, v]) => b.get(k) === v);
/**
* The script's variable-name -> position map. With `count` given, only a map
* of that many names will do. Null when there is none, and also when two
* different maps fit, because then there is no telling which is the right one.
*/
async function memberMap(lens: Lens, script: bigint, counts: number[] | null): Promise<Map<string, number> | null> {
const body = view(await lens.reader.bytes(script, SCRIPT_SCAN));
const tried = new Set<bigint>();
let found: Map<string, number> | null = null;
for (let off = 8; off < SCRIPT_SCAN; off += 8) {
const p = body.getBigUint64(off, true);
if (!plausiblePointer(p) || tried.has(p) || inMaps(lens.image, p)) continue;
tried.add(p);
for (const keyAt of [0x10, 0x30]) {
const pairs = await pairsFrom(lens, p, keyAt);
if (!pairs || (counts && !counts.includes(pairs.size))) continue;
if (found && !sameMap(found, pairs)) return null;
found = pairs;
}
}
return found;
}
/** The script's file, e.g. "res://actors/player.gd": the first engine string in the script that looks like one. */
async function scriptPath(lens: Lens, script: bigint): Promise<string | null> {
const body = view(await lens.reader.bytes(script, SCRIPT_SCAN));
for (let off = 8; off < SCRIPT_SCAN; off += 8) {
const p = body.getBigUint64(off, true);
if (!plausiblePointer(p) || inMaps(lens.image, p)) continue;
const text = await wideText(lens.reader, p);
if (text && /^res:\/\/.+\.gdc?$/.test(text)) return text;
}
return null;
}
interface Members {
attached: Attached;
/** Address of the first value record. */
values: bigint;
names: Map<string, number>;
}
/**
* The object's script variables: the name map from the script, and the array
* of values from the instance. The array is the pointer in the instance whose
* recorded length equals the number of names. The engine writes the length
* just before the data: 4 bytes before up to 4.2, 8 bytes before from 4.3,
* 16 bytes before from 4.6; all three are looked at.
*/
async function membersOf(lens: Lens, object: bigint, version: GodotVersion | null): Promise<Members | null> {
// 40-byte values; see the limits at the top of the file.
if (version && /double/i.test(version.text)) return null;
const attached = await attachedScript(lens, object);
if (!attached) return null;
const maxType = version?.major === 3 ? TYPES_3.length : TYPES_4.length;
const body = view(await lens.reader.bytes(attached.instance, INSTANCE_SCAN));
let found: Members | null = null;
for (let off = 8; off < INSTANCE_SCAN; off += 8) {
const values = body.getBigUint64(off, true);
if (!plausiblePointer(values) || values === object || values === attached.script || inMaps(lens.image, values)) continue;
const before = view(await lens.reader.bytes(values - 16n, 16));
const counts: number[] = [];
for (const n of [BigInt(before.getUint32(12, true)), before.getBigUint64(8, true), before.getBigUint64(0, true)]) {
if (n >= 1n && n <= BigInt(MAX_MEMBERS) && !counts.includes(Number(n))) counts.push(Number(n));
}
if (counts.length === 0) continue;
const names = await memberMap(lens, attached.script, counts);
if (!names) continue;
// Every record must start with a kind the engine has.
const records = view(await lens.reader.bytes(values, names.size * VARIANT_SIZE));
let valid = true;
for (let i = 0; i < names.size; i++) if (records.getUint32(i * VARIANT_SIZE, true) >= maxType) valid = false;
if (!valid) continue;
if (found && found.values !== values) return null;
found = { attached, values, names };
}
return found;
}
// These are 32-bit decimals; seven digits is all they hold.
const num = (v: number): string => String(Number(v.toPrecision(7)));
/** One value record as text, with the address of its number when it is a plain one. */
async function describeValue(lens: Lens, major: number | undefined, address: bigint, record: DataView): Promise<Pick<GodotMember, 'type' | 'text' | 'valueAddress' | 'valueType'>> {
const kind = record.getUint32(0, true);
const at = address + 8n;
// The first five kinds are the same in Godot 3 and 4; beyond them the numbering differs.
switch (kind) {
case 0:
return { type: 'null', text: 'null' };
case 1:
return { type: 'bool', text: record.getUint8(8) ? 'true' : 'false', valueAddress: at, valueType: 'bool' };
case 2:
return { type: 'int', text: record.getBigInt64(8, true).toString(), valueAddress: at, valueType: 'int64' };
case 3:
return { type: 'float', text: String(record.getFloat64(8, true)), valueAddress: at, valueType: 'double' };
case 4: {
const p = record.getBigUint64(8, true);
const text = p === 0n ? '' : await wideText(lens.reader, p);
return { type: 'String', text: text === null ? '(text could not be read)' : JSON.stringify(text) };
}
}
if (major !== 3 && major !== 4) return { type: `kind ${kind}`, text: '(not shown: the Godot version is unknown)' };
const type = (major === 3 ? TYPES_3 : TYPES_4)[kind] ?? `kind ${kind}`;
const floats = (n: number): string => Array.from({ length: n }, (_, i) => num(record.getFloat32(8 + i * 4, true))).join(', ');
const ints = (n: number): string => Array.from({ length: n }, (_, i) => record.getInt32(8 + i * 4, true)).join(', ');
switch (type) {
case 'Vector2':
return { type, text: `(${floats(2)})` };
case 'Vector3':
return { type, text: `(${floats(3)})` };
case 'Color':
return { type, text: `(${floats(4)})` };
case 'Vector2i':
return { type, text: `(${ints(2)})` };
case 'Vector3i':
return { type, text: `(${ints(3)})` };
case 'Object': {
// Godot 4 keeps the object's address in the second half of the record. Godot 3 keeps a
// reference-count record there instead, so the address is not shown.
const object = record.getBigUint64(16, true);
if (major === 4) return { type, text: object === 0n ? 'null' : `object at 0x${object.toString(16).toUpperCase()}` };
return { type, text: '(an object)' };
}
}
return { type, text: `(${type})` };
}
/** Reads "4.2.1.stable.official" style text that follows or surrounds a hit. */
function parseVersion(text: string): GodotVersion | null {
const m = /(\d{1,2})\.(\d{1,2})(?:\.(\d{1,2}))?\.(?:stable|rc\d*|beta\d*|alpha\d*|dev\d*)[\w.]*/.exec(text);
if (!m) return null;
const major = Number(m[1]);
if (major < 2 || major > 9) return null;
const v: GodotVersion = { major, minor: Number(m[2]), text: m[0] };
if (m[3] !== undefined) v.patch = Number(m[3]);
return v;
}
async function findVersion(ctx: GameContext, text: MapEntry[], nameHits: bigint[]): Promise<GodotVersion | null> {
const printable = (b: Uint8Array): string => Array.from(b, (c) => (c >= 0x20 && c <= 0x7e ? String.fromCharCode(c) : '\n')).join('');
// "Godot Engine v4.2.1.stable.official" is one text in most builds.
for (const hit of nameHits) {
for (const line of printable(await ctx.dbg().read(ctx.pid, hit, 64)).split('\n')) {
const v = line.startsWith('Godot Engine v') ? parseVersion(line) : null;
if (v) return v;
}
}
// Otherwise the number is a text of its own.
for (const hit of await findBytes(ctx, text, asciiBytes('.stable'), { limit: 8 })) {
const around = printable(await ctx.dbg().read(ctx.pid, hit - 16n, 64));
for (const line of around.split('\n')) {
const v = parseVersion(line);
if (v) return v;
}
}
// Last resort: Godot 4 renamed its drawing server, and both names are kept as plain text.
if ((await findBytes(ctx, text, asciiBytes('\0RenderingServer\0'))).length) return { major: 4, text: '' };
if ((await findBytes(ctx, text, asciiBytes('\0VisualServer\0'))).length) return { major: 3, text: '' };
return null;
}
export const detectGodot: EngineDetector = async (ctx: GameContext): Promise<GodotHelper | null> => {
const text = await textMaps(ctx);
if (text.length === 0) return null;
const nameHits = await findBytes(ctx, text, asciiBytes('Godot Engine'), { limit: 8 });
if (nameHits.length === 0) return null;
const version = await findVersion(ctx, text, nameHits);
const image = await imageMaps(ctx);
const code = image.filter((m) => m.prot & PROT.EXEC);
// A fresh reader for every question, so nothing is answered from old memory.
const lens = (): Lens => ({ reader: new MemReader(ctx), image, code, names: new Map() });
/** Scripts seen so far, by path, so classInfo and findClasses can be asked by name. */
const scripts = new Map<string, bigint>();
const shortName = (path: string): string => path.slice(path.lastIndexOf('/') + 1);
const scriptClass = async (l: Lens, script: bigint, names: Map<string, number>, types: Map<number, string> | null, parent?: string): Promise<ClassInfo> => {
const path = await scriptPath(l, script);
if (path) scripts.set(path, script);
const fields: FieldInfo[] = [...names]
.sort((a, b) => a[1] - b[1])
.map(([name, index]) => ({ name, offset: index, type: types?.get(index) ?? 'Variant', size: VARIANT_SIZE }));
const info: ClassInfo = { name: path ? shortName(path) : 'GDScript', fullName: path ?? 'GDScript', fields, address: script };
if (parent) info.parent = parent;
return info;
};
const readMembers = async (l: Lens, object: bigint): Promise<{ members: GodotMember[]; found: Members } | null> => {
const found = await membersOf(l, object, version);
if (!found) return null;
const records = await l.reader.bytes(found.values, found.names.size * VARIANT_SIZE);
const members: GodotMember[] = [];
for (const [name, index] of [...found.names].sort((a, b) => a[1] - b[1])) {
const address = found.values + BigInt(index * VARIANT_SIZE);
const record = view(records.subarray(index * VARIANT_SIZE, (index + 1) * VARIANT_SIZE));
members.push({ name, index, address, ...(await describeValue(l, version?.major, address, record)) });
}
return { members, found };
};
const label = version ? (version.text ? `Godot ${version.text}` : `Godot ${version.major}`) : 'Godot (version not found)';
return {
engine: 'godot',
label,
version,
notes: [
'Give it the address of an object to learn its class, its script, and the script\'s variables with their current values.',
'Listing every live object or every class is not available for Godot games.',
'Only scripts written in GDScript are understood, not C#.',
'This helper has not been tried on a real Godot game yet.',
],
describeObject: async (address: bigint): Promise<ObjectInfo | null> => {
const l = lens();
const className = await classNameOf(l, address);
if (!className) return null;
const info: ObjectInfo = { address, className };
const attached = await attachedScript(l, address);
const path = attached ? await scriptPath(l, attached.script) : null;
if (attached && path) {
scripts.set(path, attached.script);
info.name = path;
}
return info;
},
classInfo: async (nameOrAddress: string | bigint): Promise<ClassInfo | null> => {
const l = lens();
let address: bigint;
if (typeof nameOrAddress === 'string') {
const wanted = nameOrAddress.toLowerCase();
const hit = [...scripts].find(([path]) => path.toLowerCase() === wanted || shortName(path).toLowerCase() === wanted);
if (!hit) return null;
address = hit[1];
} else {
address = nameOrAddress;
}
const className = await classNameOf(l, address);
if (!className) return null;
if (className === 'GDScript') {
const names = await memberMap(l, address, null);
return names ? scriptClass(l, address, names, null) : null;
}
const read = await readMembers(l, address);
// An engine object without a script: its class is known, its fields are not.
if (!read) return { name: className, fullName: className, fields: [] };
const types = new Map(read.members.map((m) => [m.index, m.type]));
return scriptClass(l, read.found.attached.script, read.found.names, types, className);
},
findClasses: async (query: string, limit = 100): Promise<ClassInfo[]> =>
[...scripts]
.filter(([path]) => path.toLowerCase().includes(query.toLowerCase()))
.slice(0, limit)
.map(([path, address]) => ({ name: shortName(path), fullName: path, fields: [], address })),
// See the top of the file for what this would need.
findObjects: async (): Promise<ObjectInfo[]> => [],
readMembers: async (objectAddress: bigint): Promise<GodotMember[] | null> => (await readMembers(lens(), objectAddress))?.members ?? null,
};
};
+404
View File
@@ -0,0 +1,404 @@
// Grouped search: several values that sit close together in memory, in any
// order. Health 100, armor 50 and ammo 30 are each everywhere on their own;
// all three within a few dozen bytes of each other is usually one place, the
// player.
//
// The console has no command for this, so it is done in two steps. The value
// that looks rarest is searched for the ordinary way (the console does the
// comparing). Then the memory around each of those hits is read back in
// batches and checked here for the other values.
//
// What counts as a group: every value is found at an address that is a
// multiple of its own size (as compilers lay fields out), no two of them
// overlap, and from the first byte of the lowest to the last byte of the
// highest is at most `distance` bytes.
import { COMPARE } from './ps5debug.ts';
import type { Ps5Debug } from './ps5debug.ts';
import { readMulti } from './ps5debug-extra.ts';
import { view } from './bytes.ts';
import { mergeRanges } from './regions.ts';
import type { MemRange } from './regions.ts';
import { ScanCanceledError, cancelable, encodeValue, liveDbg } from './scanner.ts';
import type { ScanRegion, ValueKind } from './scanner.ts';
export interface GroupMember {
kind: ValueKind;
/** The value as typed. */
text: string;
}
export interface GroupHit {
/** Address of the lowest member. */
base: bigint;
/** Bytes from the start of the lowest member to the end of the highest. */
span: number;
/** In the order the members were given. */
members: { address: bigint; kind: ValueKind; value: Uint8Array }[];
}
export interface GroupResult {
hits: GroupHit[];
/** Index of the member that was searched for first. */
anchor: number;
/** How many places held that member's value. */
anchorMatches: number;
/** 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 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 {
/** Called for every exchange, because the connection can be replaced mid-search. */
dbg: () => Ps5Debug;
pid: number;
/** Slices to search, as for a number search. */
regions: ScanRegion[];
members: GroupMember[];
/** Largest size of the whole group, in bytes. */
distance: number;
/** Give up when the first value is found in more places than this (default 100,000). */
maxAnchors?: number;
/** Keep at most this many groups (default 2,000). */
maxGroups?: number;
signal?: AbortSignal;
onProgress?: (p: GroupProgress) => void;
}
export const GROUP_MAX_MEMBERS = 8;
export const GROUP_MAX_DISTANCE = 4096;
/** The first value alone was found in too many places to check each one. */
export class GroupTooBroadError extends Error {
constructor(member: GroupMember, limit: number) {
super(
`${member.text} is in more than ${limit.toLocaleString()} places, which is too many to check one by one. ` +
'Add a less common value to the group (a big or odd number works best), or search a smaller part of memory.',
);
this.name = 'GroupTooBroadError';
}
}
function numberOf(kind: ValueKind, bytes: Uint8Array, at = 0): number | bigint {
const dv = view(bytes);
if (kind.float) return kind.size === 4 ? dv.getFloat32(at, true) : dv.getFloat64(at, true);
switch (kind.size) {
case 1:
return dv.getUint8(at);
case 2:
return dv.getUint16(at, true);
case 4:
return dv.getUint32(at, true);
default:
return dv.getBigUint64(at, true);
}
}
/**
* How unusual a value looks, higher being rarer. Zero and one are everywhere,
* small whole numbers are common, and a decimal number or a large one is not.
* It only decides which value is searched for first.
*/
export function rarity(kind: ValueKind, bytes: Uint8Array): number {
if (bytes.every((b) => b === 0)) return -100;
if (kind.float) return kind.size === 8 ? 6 : 5;
const raw = BigInt(numberOf({ ...kind, signed: false }, bytes));
// A negative number has its high bits set, which is as rare as a large one.
const small = raw <= 1n ? -50 : raw <= 0xffn ? 1 : raw <= 0xffffn ? 3 : 5;
// One- and two-byte values are found by chance far more often than wider ones.
return small + (kind.size === 1 ? -3 : kind.size === 2 ? -1 : kind.size === 8 ? 0.5 : 0);
}
/** Decimal values match the way the console's own exact search does: equal to about six digits. */
function sameValue(kind: ValueKind, want: Uint8Array, mem: Uint8Array, at: number): boolean {
if (!kind.float) {
for (let i = 0; i < kind.size; i++) if (mem[at + i] !== want[i]) return false;
return true;
}
const a = numberOf(kind, want) as number;
const b = numberOf(kind, mem, at) as number;
if (a === b) return true;
if (a === 0 || b === 0 || !Number.isFinite(b)) return false;
return Math.abs(a - b) / (Math.abs(a) + Math.abs(b)) < 1e-6;
}
/** Reads kept under this size each, so one dense patch of hits cannot ask for a huge reply. */
const RUN_BYTES = 256 * 1024;
/** Hits checked per batch of reads. */
const BATCH = 2000;
/**
* 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, 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(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);
let anchor = 0;
for (let i = 1; i < members.length; i++) if (rarity(members[i].kind, want[i]) > rarity(members[anchor].kind, want[anchor])) anchor = i;
const anchorKind = members[anchor].kind;
// Step 1: every place that holds the first value. The scan takes most of the time.
const total = opts.regions.reduce((n, r) => n + r.length, 0);
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 = await liveDbg(opts.dbg);
if (!dbg.authed) await dbg.auth();
let tooMany = false;
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;
step1();
}
// Step 2: read around each hit and look for the rest.
const ranges = mergeRanges(opts.regions.map((r) => ({ start: r.start, end: r.start + BigInt(r.length) })));
// Hits and ranges are both in address order, so one walk forward finds each hit's range.
let rangeIndex = 0;
const rangeOf = (address: bigint): MemRange | undefined => {
while (rangeIndex < ranges.length - 1 && address >= ranges[rangeIndex].end) rangeIndex++;
const r = ranges[rangeIndex];
return r && address >= r.start && address < r.end ? r : undefined;
};
const hits: GroupHit[] = [];
const seen = new Set<string>();
let capped = false;
for (let done = 0; done < anchors.length && !canceled && !capped; done += BATCH) {
if (opts.signal?.aborted) {
canceled = true;
break;
}
const batch = anchors.slice(done, done + BATCH);
// The memory a group around this hit could occupy, kept inside the range it was found in:
// a read that runs off the end of mapped memory comes back as zeros.
const windows = batch.map((a) => {
const range = rangeOf(a);
const lo = a + BigInt(anchorKind.size) - distance;
const hi = a + distance;
return { start: range && lo < range.start ? range.start : lo, end: range && hi > range.end ? range.end : hi };
});
// Neighboring hits share one read.
const runs: { start: bigint; end: bigint; first: number; count: number }[] = [];
for (let i = 0; i < batch.length; i++) {
const run = runs[runs.length - 1];
if (run && windows[i].start <= run.end && windows[i].end - run.start <= BigInt(RUN_BYTES)) {
if (windows[i].end > run.end) run.end = windows[i].end;
run.count++;
} else {
runs.push({ start: windows[i].start, end: windows[i].end, first: i, count: 1 });
}
}
const data = await readMulti(
await liveDbg(opts.dbg),
opts.pid,
runs.map((r) => ({ address: r.start, length: Number(r.end - r.start) })),
);
runs.forEach((run, r) => {
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(',');
if (seen.has(key)) continue;
seen.add(key);
if (hits.length >= maxGroups) capped = true;
else hits.push(group);
}
});
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, bytesSearched: scanned, bytesTotal: total };
}
/** How many arrangements are tried around one hit before settling for the best so far. */
const TRY_LIMIT = 4000;
/**
* The tightest group around one hit of the first value, or null when the other
* values are not all close enough. `data` holds memory from `dataStart`.
*/
function bestGroup(
members: GroupMember[],
want: Uint8Array[],
anchor: number,
anchorAt: bigint,
window: MemRange,
data: Uint8Array,
dataStart: bigint,
distance: number,
): GroupHit | null {
// Where each other value appears in the window, nearest to the hit first.
const options: bigint[][] = [];
for (let m = 0; m < members.length; m++) {
if (m === anchor) {
options.push([anchorAt]);
continue;
}
const size = BigInt(members[m].kind.size);
const list: bigint[] = [];
let at = window.start + ((size - (window.start % size)) % size);
for (; at + size <= window.end; at += size) {
const off = Number(at - dataStart);
if (off < 0 || off + Number(size) > data.length) continue;
if (sameValue(members[m].kind, want[m], data, off)) list.push(at);
}
if (list.length === 0) return null;
const far = (a: bigint) => (a > anchorAt ? a - anchorAt : anchorAt - a);
list.sort((a, b) => (far(a) < far(b) ? -1 : far(a) > far(b) ? 1 : 0));
options.push(list.slice(0, 16));
}
const found: { best: bigint[] | null } = { best: null };
let bestSpan = distance + 1;
let tries = 0;
const chosen: bigint[] = new Array(members.length).fill(0n);
const place = (m: number, lo: bigint, hi: bigint): void => {
if (tries > TRY_LIMIT) return;
if (m === members.length) {
tries++;
const span = Number(hi - lo);
if (span < bestSpan) {
bestSpan = span;
found.best = [...chosen];
}
return;
}
const size = BigInt(members[m].kind.size);
for (const at of options[m]) {
const end = at + size;
// Two values cannot share bytes.
let clash = false;
for (let k = 0; k < m; k++) {
const otherEnd = chosen[k] + BigInt(members[k].kind.size);
if (at < otherEnd && chosen[k] < end) {
clash = true;
break;
}
}
if (clash) continue;
const nlo = m === 0 || at < lo ? at : lo;
const nhi = m === 0 || end > hi ? end : hi;
if (Number(nhi - nlo) >= bestSpan) continue;
chosen[m] = at;
place(m + 1, nlo, nhi);
}
};
place(0, 0n, 0n);
if (!found.best) return null;
const picked = found.best;
const base = picked.reduce((a, b) => (b < a ? b : a));
return {
base,
span: bestSpan,
members: members.map((m, i) => {
const off = Number(picked[i] - dataStart);
return { address: picked[i], kind: m.kind, value: data.slice(off, off + m.kind.size) };
}),
};
}
+258
View File
@@ -0,0 +1,258 @@
// "Find hidden switches": looks through the text built into a game's program
// for words that hint at things left in by its developers (debug menus, cheat
// flags, frame-rate settings), and says which code uses each one.
//
// It works on any engine because it reads nothing but the game's own
// read-only data: that data is downloaded in pieces and searched here, so one
// pass finds every word, in any mix of capitals, as plain text and as the
// 16-bit text Unreal uses. Nothing is written and nothing is attached.
//
// A text found this way is a lead, not a switch. "What uses this" then finds
// the code that mentions it, which is where a curious person starts reading
// in the Code tab.
import { imageMaps, findBytes, pointerBytes } from './engine-detect.ts';
import { findReferences, moduleRange } from './locations.ts';
import type { GameContext } from './locations.ts';
import { PROT } from './ps5debug.ts';
import type { MapEntry } from './ps5debug.ts';
export type HiddenTopic = 'cheats' | 'debug' | 'framerate' | 'yours' | 'mentions';
export const HIDDEN_TOPICS: Record<HiddenTopic, string> = {
yours: 'Your words',
cheats: 'Cheats and god modes',
debug: 'Debug and developer menus',
framerate: 'Frame rate and display',
mentions: 'Other mentions of "debug"',
};
// Order is priority: a text is listed under the first topic it fits.
const TOPICS: { topic: HiddenTopic; match: RegExp }[] = [
{ topic: 'cheats', match: /cheat|god[ _-]?mode|\bgod\b|no[ _-]?clip|invincib|invulnerab|infinite[ _-]?(ammo|health|money|lives|stamina)|unlock[ _-]?all|one[ _-]?hit[ _-]?kill/i },
{
topic: 'debug',
match: /debug[ _-]?(menu|mode|draw|cam|ui|hud|option|setting|enable|cheat|console|view|overlay|text)|dev[ _-]?(menu|mode|console|tools?)\b|developer[ _-]?(mode|menu|console|options?)|test[ _-]?(menu|mode|level|map)\b|free[ _-]?cam|level[ _-]?select|skip[ _-]?(intro|cutscene|logo)/i,
},
{ topic: 'framerate', match: /fps|frame[ _-]?rate|frame[ _-]?(limit|cap)|v[ _-]?sync|motion[ _-]?blur|field[ _-]?of[ _-]?view|\bfov\b|resolution[ _-]?scale|screenpercentage/i },
{ topic: 'mentions', match: /debug/i },
];
export interface HiddenHit {
/** Where the text starts. */
address: bigint;
/** The whole text the word was found in, cut to a sensible length. */
text: string;
/** The part of `text` that matched, for highlighting. */
word: string;
topic: HiddenTopic;
encoding: 'utf8' | 'utf16';
/** How many more places hold exactly the same text. */
repeats: number;
}
export interface HiddenProgress {
/** 0 to 1. */
fraction: number;
hits: number;
}
export interface HiddenResult {
hits: HiddenHit[];
/** True when a topic reached its limit and the rest of its hits were dropped. */
capped: boolean;
canceled: boolean;
bytesSearched: number;
bytesTotal: number;
/** True when the game's program is larger than this search reads, so the end of it was not looked at. */
truncated: boolean;
}
export interface HiddenOptions {
/** Extra words to look for, listed first. Any capitals. */
words?: string[];
/** Most hits kept per topic. Default 120. */
perTopic?: number;
/** Most bytes read. Default 384 MB, more than any game program seen so far. */
maxBytes?: number;
/** A library to search instead of the game's own program, by its name in the memory map. */
module?: string;
signal?: AbortSignal;
onProgress?: (p: HiddenProgress) => void;
}
const CHUNK = 0x100000;
/** Read again at the start of the next piece, so a text across the seam is seen whole. */
const OVERLAP = 0x200;
const MIN_CHARS = 3;
const MAX_TEXT = 160;
const escapeRegExp = (text: string): string => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
function classify(text: string, yours: RegExp | null): { topic: HiddenTopic; word: string } | null {
if (yours) {
const m = yours.exec(text);
if (m) return { topic: 'yours', word: m[0] };
}
for (const t of TOPICS) {
const m = t.match.exec(text);
if (m) return { topic: t.topic, word: m[0] };
}
return null;
}
/** Cuts a long text down around the word that matched. */
function excerpt(text: string, word: string): string {
if (text.length <= MAX_TEXT) return text;
const at = Math.max(0, text.indexOf(word));
const start = Math.max(0, Math.min(at - 40, text.length - MAX_TEXT));
return (start > 0 ? '…' : '') + text.slice(start, start + MAX_TEXT) + (start + MAX_TEXT < text.length ? '…' : '');
}
const printable = (c: number): boolean => c >= 0x20 && c < 0x7f;
/**
* Calls `found` for each run of printable text in the bytes: plain (one byte
* per character) and 16-bit. Runs that touch the end of the bytes are left
* for the next piece unless `last` is set.
*/
function eachText(bytes: Uint8Array, last: boolean, found: (offset: number, text: string, encoding: 'utf8' | 'utf16') => void): void {
const n = bytes.length;
let start = -1;
for (let i = 0; i <= n; i++) {
if (i < n && printable(bytes[i])) {
if (start < 0) start = i;
continue;
}
if (start >= 0 && i - start >= MIN_CHARS && (i < n || last)) found(start, String.fromCharCode(...bytes.subarray(start, Math.min(i, start + 1024))), 'utf8');
start = -1;
}
// 16-bit text: a printable byte then a zero, repeated. Both alignments are tried.
for (const phase of [0, 1]) {
start = -1;
for (let i = phase; ; i += 2) {
const atEnd = i + 1 >= n;
if (!atEnd && printable(bytes[i]) && bytes[i + 1] === 0) {
if (start < 0) start = i;
continue;
}
if (start >= 0 && (i - start) / 2 >= MIN_CHARS && (!atEnd || last)) {
let s = '';
for (let k = start; k < i && s.length < 1024; k += 2) s += String.fromCharCode(bytes[k]);
found(start, s, 'utf16');
}
start = -1;
if (atEnd) break;
}
}
}
/** The maps holding a program's built-in text: its read-only data, and its code where the two are not kept apart. */
async function textRegions(ctx: GameContext, module?: string): Promise<MapEntry[]> {
let all: MapEntry[];
if (module) {
const range = await moduleRange(ctx, module);
if (!range) throw new Error(`The module "${module}" is not loaded in this game right now.`);
all = (await ctx.maps()).filter((m) => m.start >= range.start && m.end <= range.end && m.end > m.start && m.prot & PROT.READ);
} else all = await imageMaps(ctx);
const fixed = all.filter((m) => !(m.prot & PROT.WRITE));
const apart = fixed.some((m) => !(m.prot & PROT.EXEC));
return (apart ? fixed.filter((m) => !(m.prot & PROT.EXEC)) : fixed).sort((a, b) => (a.start < b.start ? -1 : 1));
}
/**
* Searches the game's built-in text for hints of hidden features. Read-only,
* bounded by `maxBytes`, and stops soon after `signal` is aborted with what
* it has found so far.
*/
export async function findHiddenSwitches(ctx: GameContext, opts: HiddenOptions = {}): Promise<HiddenResult> {
const perTopic = opts.perTopic ?? 120;
const maxBytes = opts.maxBytes ?? 384 * 1024 * 1024;
const words = (opts.words ?? []).map((w) => w.trim()).filter((w) => w.length >= 2);
const yours = words.length ? new RegExp(words.map(escapeRegExp).join('|'), 'i') : null;
const regions = await textRegions(ctx, opts.module);
const total = regions.reduce((sum, m) => sum + Number(m.end - m.start), 0);
const bytesTotal = Math.min(total, maxBytes);
const byText = new Map<string, HiddenHit>();
const counts: Record<HiddenTopic, number> = { yours: 0, cheats: 0, debug: 0, framerate: 0, mentions: 0 };
let capped = false;
let canceled = false;
let searched = 0;
outer: for (const m of regions) {
for (let at = m.start; at < m.end; ) {
if (opts.signal?.aborted) {
canceled = true;
break outer;
}
if (searched >= maxBytes) break outer;
const fresh = Math.min(CHUNK, Number(m.end - at), maxBytes - searched);
// The piece starts a little early so a text cut by the last piece's end is read whole this time.
const back = at > m.start ? OVERLAP : 0;
const bytes = await ctx.scanDbg().read(ctx.pid, at - BigInt(back), back + fresh);
const last = at + BigInt(fresh) >= m.end;
eachText(bytes, last, (offset, text, encoding) => {
// Texts wholly inside the overlap were reported with the previous piece.
if (offset + (encoding === 'utf16' ? text.length * 2 : text.length) <= back && back > 0) return;
const fit = classify(text, yours);
if (!fit) return;
const key = `${encoding}:${text}`;
const seen = byText.get(key);
const address = at - BigInt(back) + BigInt(offset);
if (seen) {
if (seen.address !== address) seen.repeats++;
return;
}
if (counts[fit.topic] >= perTopic) {
capped = true;
return;
}
counts[fit.topic]++;
byText.set(key, { address, text: excerpt(text, fit.word), word: fit.word, topic: fit.topic, encoding, repeats: 0 });
});
searched += fresh;
at += BigInt(fresh);
opts.onProgress?.({ fraction: bytesTotal ? Math.min(1, searched / bytesTotal) : 1, hits: byText.size });
}
}
const order: HiddenTopic[] = ['yours', 'cheats', 'debug', 'framerate', 'mentions'];
// Short texts first: a name like "bGodMode" is a better lead than a sentence that happens to say "debug".
const hits = [...byText.values()].sort((a, b) => order.indexOf(a.topic) - order.indexOf(b.topic) || a.text.length - b.text.length || (a.address < b.address ? -1 : 1));
return { hits, capped, canceled, bytesSearched: searched, bytesTotal, truncated: total > maxBytes && !canceled };
}
export interface HiddenUses {
/** Instructions that refer to the text directly. */
code: bigint[];
/** Places in the game's data that hold a pointer to the text (tables of names, option lists). */
data: bigint[];
/** Set when the code could not be searched, with the reason in plain words. */
codeProblem?: string;
}
/**
* "What uses this": the code that refers to a text, and the tables that
* point at it. Searching the code is done by the console and takes several
* seconds on a large game; it needs ps5debug-NG.
*/
export async function whatUses(ctx: GameContext, address: bigint, opts: { module?: string; limit?: number } = {}): Promise<HiddenUses> {
const limit = opts.limit ?? 32;
const out: HiddenUses = { code: [], data: [] };
try {
out.code = (await findReferences(ctx, address, opts.module)).slice(0, limit);
} catch (e) {
out.codeProblem = e instanceof Error ? e.message : String(e);
}
let maps: MapEntry[];
if (opts.module) {
const range = await moduleRange(ctx, opts.module);
maps = range ? (await ctx.maps()).filter((m) => m.start >= range.start && m.end <= range.end && m.prot & PROT.READ) : [];
} else maps = await imageMaps(ctx);
// Pointers live in data, never in code.
const data = maps.filter((m) => !(m.prot & PROT.EXEC));
out.data = await findBytes(ctx, data, pointerBytes(address), { limit: Math.min(limit, 8), aligned: 8, deadline: Date.now() + 15_000 });
return out;
}
+73
View File
@@ -0,0 +1,73 @@
// Keyboard shortcuts for cheats.
//
// A hotkey is stored as text, modifiers first in a fixed order and then one
// key: "Ctrl+Shift+1", "F6", "Alt+G". They only work while the CheatSpecter
// page is open and has focus; a web page cannot see the controller, or keys
// pressed in another window.
interface KeyLike {
key: string;
code?: string;
ctrlKey: boolean;
altKey: boolean;
shiftKey: boolean;
metaKey: boolean;
}
const MODIFIER_KEYS = new Set(['Control', 'Shift', 'Alt', 'Meta', 'AltGraph', 'OS', 'Hyper', 'Super', 'Fn', 'CapsLock', 'NumLock', 'ScrollLock']);
const NAMES: Record<string, string> = {
' ': 'Space',
ArrowUp: 'Up',
ArrowDown: 'Down',
ArrowLeft: 'Left',
ArrowRight: 'Right',
Escape: 'Esc',
};
/** The hotkey a key press means, or null while only modifier keys are down. */
export function hotkeyFromEvent(e: KeyLike): string | null {
if (MODIFIER_KEYS.has(e.key) || e.key === 'Dead' || e.key === 'Unidentified' || e.key === 'Process') return null;
let key: string;
// The physical key for letters and digits, so Shift+1 is "Shift+1" and not "!", on any keyboard layout.
const code = e.code ?? '';
if (/^Key[A-Z]$/.test(code)) key = code.slice(3);
else if (/^Digit[0-9]$/.test(code)) key = code.slice(5);
else if (/^Numpad[0-9]$/.test(code)) key = `Num${code.slice(6)}`;
else key = NAMES[e.key] ?? (e.key.length === 1 ? e.key.toUpperCase() : e.key);
return [e.ctrlKey && 'Ctrl', e.altKey && 'Alt', e.shiftKey && 'Shift', e.metaKey && 'Meta', key].filter(Boolean).join('+');
}
/** Combinations browsers act on before a page hears of them, or that would be unkind to take over. */
const RESERVED = new Set([
'Ctrl+T', 'Ctrl+N', 'Ctrl+W', 'Ctrl+Shift+T', 'Ctrl+Shift+N', 'Ctrl+Shift+W', 'Ctrl+Tab', 'Ctrl+Shift+Tab',
'Ctrl+R', 'Ctrl+Shift+R', 'F5', 'Ctrl+F5', 'F11', 'F12', 'Ctrl+Shift+I', 'Ctrl+Shift+J', 'Ctrl+L', 'Alt+D',
'Alt+F4', 'Ctrl+Q', 'Ctrl+Shift+Q', 'Alt+Left', 'Alt+Right', 'Alt+Home', 'Ctrl+H', 'Ctrl+J', 'Ctrl+Shift+Delete',
'Tab', 'Shift+Tab', 'Enter', 'Space', 'Esc', 'Backspace',
'Ctrl+C', 'Ctrl+V', 'Ctrl+X', 'Ctrl+A', 'Ctrl+Z',
]);
/**
* Why a hotkey may not work, in words for the user, or null when it looks
* fine. A warning, not a refusal: what is reserved differs between browsers.
*/
export function hotkeyProblem(hotkey: string): string | null {
const parts = hotkey.split('+');
const key = parts[parts.length - 1];
if (parts.includes('Meta')) return 'Combinations with the Windows or Command key usually belong to the computer and may never reach this page.';
if (['Tab', 'Enter', 'Space', 'Esc', 'Backspace'].includes(hotkey) || /^(Shift\+)?Tab$/.test(hotkey)) {
return `${hotkey} is used to move around and press things on this page, so it would get in the way. Pick another.`;
}
if (RESERVED.has(hotkey) || /^Ctrl\+[1-9]$/.test(hotkey) || /^Alt\+[1-9]$/.test(hotkey)) {
return `Most browsers keep ${hotkey} for themselves, so it may not reach this page. A function key such as F6, or Ctrl+Shift with a number, is a safer pick.`;
}
if (parts.length === 1 && key.length === 1) {
return 'A single letter or number works, but only while you are not typing in a box on this page.';
}
return null;
}
/** Names of the other entries that use the same hotkey. */
export function hotkeyClashes(entries: { id: string; name: string; hotkey?: string }[], id: string, hotkey: string): string[] {
return entries.filter((e) => e.id !== id && e.hotkey === hotkey).map((e) => e.name);
}
+295
View File
@@ -0,0 +1,295 @@
// Reads Unity's global-metadata.dat: the file an IL2CPP game ships with the
// names of every class, field and method in it.
//
// Everything here is a pure function over bytes. The same code reads the file
// from disk (once the payload can hand it over) and the copy the game keeps in
// its own memory (il2cpp.ts reads just the parts it needs from there).
//
// Covers metadata versions 24 to 31 (roughly Unity 2017 through 2022). Other
// versions are refused rather than risk misreading them.
// Only the parts whose layout is stable across those versions are parsed.
// The record sizes that did change between sub-versions (which the header does
// not tell apart) are worked out from the data: every record carries a token
// whose top byte says what kind of record it is, so a wrong size is obvious.
import { view } from './bytes.ts';
export const METADATA_MAGIC = 0xfab11baf;
export const METADATA_MIN_VERSION = 24;
export const METADATA_MAX_VERSION = 31;
/** Bytes of header that parseMetadataHeader needs. */
export const METADATA_HEADER_BYTES = 0xa8;
export interface MetadataSection {
offset: number;
size: number;
}
export interface MetadataHeader {
version: number;
/** NUL-terminated UTF-8 names, addressed by byte offset. */
strings: MetadataSection;
methods: MetadataSection;
fields: MetadataSection;
typeDefinitions: MetadataSection;
}
export interface MetadataType {
/** Position in the type definition table. The running game indexes its classes the same way. */
index: number;
/** Where the name sits in the string table. The running game's classes point at this same spot. */
nameIndex: number;
name: string;
namespace: string;
/** "Namespace.Name", or just the name when there is no namespace. */
fullName: string;
/** 0x02xxxxxx. */
token: number;
/** .NET type attributes (0x80 abstract, 0x100 sealed, 0x20 interface). */
flags: number;
fieldStart: number;
fieldCount: number;
methodStart: number;
methodCount: number;
}
export interface MetadataMethod {
name: string;
/** Index of the type that declares it. */
declaringType: number;
token: number;
/** .NET method attributes (0x10 static). */
flags: number;
parameterCount: number;
}
export interface GlobalMetadata {
version: number;
header: MetadataHeader;
types: MetadataType[];
/** Bytes per record, as worked out from the data. 0 when that table could not be read. */
typeDefinitionSize: number;
methodDefinitionSize: number;
fieldDefinitionSize: number;
/** The name stored at a byte offset of the string table. */
string(index: number): string;
/** Field names of a type, in declaration order. Empty when the field table could not be read. */
fieldNames(type: MetadataType): string[];
/** Methods of a type, in declaration order. Empty when the method table could not be read. */
methods(type: MetadataType): MetadataMethod[];
/** Types whose name or full name contains the text (case-insensitive). */
findTypes(query: string, limit?: number): MetadataType[];
}
/**
* Checks the first bytes of a metadata file and returns where its tables are,
* or null when this is not metadata this parser understands. `fileSize` is
* optional because the copy in a game's memory has no known length.
*/
export function parseMetadataHeader(bytes: Uint8Array, fileSize?: number): MetadataHeader | null {
if (bytes.length < METADATA_HEADER_BYTES) return null;
const dv = view(bytes);
if (dv.getUint32(0, true) !== METADATA_MAGIC) return null;
const version = dv.getInt32(4, true);
if (version < METADATA_MIN_VERSION || version > METADATA_MAX_VERSION) return null;
// Each table is an (offset, size in bytes) pair. These four sit at the same
// place in every supported version; later header fields moved around.
const section = (at: number): MetadataSection => ({ offset: dv.getInt32(at, true), size: dv.getInt32(at + 4, true) });
const header: MetadataHeader = {
version,
strings: section(0x18),
methods: section(0x30),
fields: section(0x60),
typeDefinitions: section(0xa0),
};
// The tables are written in header order, so anything else is a chance
// match of the magic number rather than a real file.
const order = [header.strings, header.methods, header.fields, header.typeDefinitions];
let floor = METADATA_HEADER_BYTES;
for (const s of order) {
if (s.offset < floor || s.size < 0 || s.offset % 4 !== 0) return null;
if (fileSize !== undefined && s.offset + s.size > fileSize) return null;
floor = s.offset + s.size;
}
if (header.strings.size === 0 || header.typeDefinitions.size === 0) return null;
return header;
}
const decoder = new TextDecoder('utf-8', { fatal: false });
/** The NUL-terminated string at `index` of a string table, or null when the index is outside it. */
export function metadataString(strings: Uint8Array, index: number): string | null {
if (index < 0 || index >= strings.length) return null;
let end = strings.indexOf(0, index);
if (end < 0) end = strings.length;
if (end - index > 4096) return null;
return decoder.decode(strings.subarray(index, end));
}
// Record sizes seen across versions 24 to 31, newest first.
// type definition: 88 (v27+), 92 (v24.2-24.5), 100 (v24.1), 104 (v24.0)
// method definition: 32 (v24.2-v29), 36 (v31), 52 (v24.1), 56 (v24.0)
// field definition: 12, or 16 in v24.0
const TYPE_SIZES = [88, 92, 100, 104];
const METHOD_SIZES = [32, 36, 52, 56];
const FIELD_SIZES = [12, 16];
/** How many records are checked when working out a record size. */
const SAMPLE = 48;
/**
* Tries each candidate record size and returns the one under which the
* sampled records make sense. `tokenAt` is where the record's token sits,
* counted back from the end of the record; `tokenKind` is its top byte.
*/
function recordSize(
table: Uint8Array,
strings: Uint8Array,
sizes: number[],
tokenFromEnd: number,
tokenKind: number,
extra?: (dv: DataView, at: number, size: number) => boolean,
): number {
const dv = view(table);
for (const size of sizes) {
if (table.length === 0 || table.length % size !== 0) continue;
const count = table.length / size;
const step = Math.max(1, Math.floor(count / SAMPLE));
let ok = true;
for (let i = 0; i < count && ok; i += step) {
const at = i * size;
if (dv.getUint32(at + size - tokenFromEnd, true) >>> 24 !== tokenKind) ok = false;
else if (metadataString(strings, dv.getInt32(at, true)) === null) ok = false;
else if (extra && !extra(dv, at, size)) ok = false;
}
if (ok) return size;
}
return 0;
}
/** Bytes per type definition record, or 0 when the table does not look like type definitions. */
export function typeDefinitionSize(table: Uint8Array, strings: Uint8Array): number {
// The second field of a type is its namespace, also a string.
return recordSize(table, strings, TYPE_SIZES, 4, 0x02, (dv, at) => metadataString(strings, dv.getInt32(at + 4, true)) !== null);
}
/**
* Reads the type definition table. The fields used here are addressed from
* the end of the record, where every version agrees: ..., flags, eight
* "first index" fields, eight 16-bit counts, a bitfield and the token.
*/
export function parseTypeDefinitions(table: Uint8Array, strings: Uint8Array, size: number): MetadataType[] {
const dv = view(table);
const count = Math.floor(table.length / size);
const out: MetadataType[] = new Array(count);
for (let i = 0; i < count; i++) {
const at = i * size;
const name = metadataString(strings, dv.getInt32(at, true)) ?? '';
const namespace = metadataString(strings, dv.getInt32(at + 4, true)) ?? '';
out[i] = {
index: i,
nameIndex: dv.getInt32(at, true),
name,
namespace,
fullName: namespace ? `${namespace}.${name}` : name,
token: dv.getUint32(at + size - 4, true),
flags: dv.getUint32(at + size - 60, true),
fieldStart: dv.getInt32(at + size - 56, true),
methodStart: dv.getInt32(at + size - 52, true),
methodCount: dv.getUint16(at + size - 24, true),
fieldCount: dv.getUint16(at + size - 20, true),
};
}
return out;
}
/** Types whose name or full name contains the text, best matches first. */
export function matchTypes(types: MetadataType[], query: string, limit = 50): MetadataType[] {
const q = query.trim().toLowerCase();
if (!q) return [];
const scored: { t: MetadataType; score: number }[] = [];
for (const t of types) {
const name = t.name.toLowerCase();
const full = t.fullName.toLowerCase();
let score: number;
if (name === q || full === q) score = 0;
else if (name.startsWith(q)) score = 1;
else if (name.includes(q)) score = 2;
else if (full.includes(q)) score = 3;
else continue;
scored.push({ t, score });
}
scored.sort((a, b) => a.score - b.score || a.t.name.length - b.t.name.length || (a.t.fullName < b.t.fullName ? -1 : a.t.fullName > b.t.fullName ? 1 : 0));
return scored.slice(0, limit).map((s) => s.t);
}
/**
* Parses a whole global-metadata.dat. Throws with a plain explanation when
* the bytes are not a metadata file this version of the parser can read.
*/
export function parseGlobalMetadata(bytes: Uint8Array): GlobalMetadata {
if (bytes.length < 8 || view(bytes).getUint32(0, true) !== METADATA_MAGIC) {
throw new Error('This is not a Unity global-metadata.dat file (it does not start with the expected marker). Some games encrypt it.');
}
const version = view(bytes).getInt32(4, true);
if (version < METADATA_MIN_VERSION || version > METADATA_MAX_VERSION) {
throw new Error(`This global-metadata.dat is version ${version}. Only versions ${METADATA_MIN_VERSION} to ${METADATA_MAX_VERSION} are understood.`);
}
const header = parseMetadataHeader(bytes, bytes.length);
if (!header) throw new Error('This global-metadata.dat is damaged or cut short: its table of contents points outside the file.');
const slice = (s: MetadataSection): Uint8Array => bytes.subarray(s.offset, s.offset + s.size);
const strings = slice(header.strings);
const typeTable = slice(header.typeDefinitions);
const typeSize = typeDefinitionSize(typeTable, strings);
if (!typeSize) throw new Error('The list of classes in this global-metadata.dat could not be read. The file may be encrypted or from an unusual Unity build.');
const types = parseTypeDefinitions(typeTable, strings, typeSize);
const methodTable = slice(header.methods);
// A method's second field is the type that declares it.
const methodSize = recordSize(methodTable, strings, METHOD_SIZES, 12, 0x06, (dv, at) => {
const owner = dv.getInt32(at + 4, true);
return owner >= 0 && owner < types.length;
});
const fieldTable = slice(header.fields);
const fieldSize = recordSize(fieldTable, strings, FIELD_SIZES, 4, 0x04);
const methodDv = view(methodTable);
const fieldDv = view(fieldTable);
const methodTotal = methodSize ? methodTable.length / methodSize : 0;
const fieldTotal = fieldSize ? fieldTable.length / fieldSize : 0;
return {
version,
header,
types,
typeDefinitionSize: typeSize,
methodDefinitionSize: methodSize,
fieldDefinitionSize: fieldSize,
string: (index) => metadataString(strings, index) ?? '',
fieldNames(type) {
const out: string[] = [];
if (!fieldSize || type.fieldStart < 0) return out;
for (let i = type.fieldStart; i < type.fieldStart + type.fieldCount && i < fieldTotal; i++) {
out.push(metadataString(strings, fieldDv.getInt32(i * fieldSize, true)) ?? '');
}
return out;
},
methods(type) {
const out: MetadataMethod[] = [];
if (!methodSize || type.methodStart < 0) return out;
for (let i = type.methodStart; i < type.methodStart + type.methodCount && i < methodTotal; i++) {
const at = i * methodSize;
out.push({
name: metadataString(strings, methodDv.getInt32(at, true)) ?? '',
declaringType: methodDv.getInt32(at + 4, true),
token: methodDv.getUint32(at + methodSize - 12, true),
flags: methodDv.getUint16(at + methodSize - 8, true),
parameterCount: methodDv.getUint16(at + methodSize - 2, true),
});
}
return out;
},
findTypes: (query, limit) => matchTypes(types, query, limit),
};
}
+2035
View File
File diff suppressed because it is too large. Load diff
+79
View File
@@ -0,0 +1,79 @@
// A record of raw writes to the game's memory, so they can be undone.
//
// Cheats in the table undo themselves when switched off. Values typed straight
// into memory from the Find and Memory tabs have no switch, so each such write
// goes through here: what was there before is read first and kept, and
// "Restore everything" puts it all back, last write first.
import { bytesEqual } from './bytes.ts';
export interface JournalEntry {
address: bigint;
/** What the game held there before the write. */
before: Uint8Array;
/** When it was written (ms since 1970). */
time: number;
}
export interface MemoryAccess {
read: (address: bigint, length: number) => Promise<Uint8Array>;
write: (address: bigint, bytes: Uint8Array) => Promise<void>;
}
export interface RestoreResult {
/** Writes undone. */
restored: JournalEntry[];
/** Writes that could not be undone, with the reason. They stay in the journal. */
failed: { entry: JournalEntry; error: string }[];
}
/** More than this and the oldest are forgotten; a session of hand edits is nowhere near it. */
const LIMIT = 2000;
export class WriteJournal {
private entries: JournalEntry[] = [];
get size(): number {
return this.entries.length;
}
list(): JournalEntry[] {
return [...this.entries];
}
/** Reads what is there, remembers it, then writes. If the read fails nothing is written. */
async write(mem: MemoryAccess, address: bigint, bytes: Uint8Array): Promise<void> {
const before = Uint8Array.from(await mem.read(address, bytes.length));
await mem.write(address, bytes);
// Writing the same spot again adds nothing: the first record already holds the true original.
if (this.entries.some((e) => e.address === address && e.before.length === before.length)) return;
// A write that changes nothing has nothing to undo.
if (bytesEqual(before, bytes)) return;
this.entries.push({ address, before, time: Date.now() });
if (this.entries.length > LIMIT) this.entries.shift();
}
/**
* Puts everything back, newest first, so that where two writes overlapped
* the older original wins. One failure does not stop the rest.
*/
async restore(mem: MemoryAccess): Promise<RestoreResult> {
const result: RestoreResult = { restored: [], failed: [] };
const kept: JournalEntry[] = [];
for (const entry of [...this.entries].reverse()) {
try {
await mem.write(entry.address, entry.before);
result.restored.push(entry);
} catch (e) {
result.failed.push({ entry, error: e instanceof Error ? e.message : String(e) });
kept.unshift(entry);
}
}
this.entries = kept;
return result;
}
clear(): void {
this.entries = [];
}
}
+226
View File
@@ -0,0 +1,226 @@
// "Keep on": cheats the console applies by itself.
//
// The CheatSpecter payload on the PS5 has an engine that reads the table
// saved there and applies the entries marked `keep` whenever that game runs,
// with no browser involved. This is the page's side of it: asking the engine
// what it is doing, and telling it to read the file again after a save.
//
// payload/README.md ("Cheats that stay on by themselves") is the contract
// both sides follow: what the engine runs, what it turns down, and every
// field of the answer read here.
import { consoleApiUrl } from './library.ts';
import type { Target } from './session.ts';
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. "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' | '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 {
/** False when the engine cannot reach the debugger on the console. */
running: boolean;
/** The game the engine sees running, if any. */
game: { titleId: string; version: string; pid: number } | null;
/** The table file it is applying, e.g. "PPSA18605-01.001.008.cspector"; "" when none. */
file: string;
/**
* False when that file was saved for another version of the game (going by
* its name or by what it says inside). The engine then leaves alone every
* cheat it has nothing to check against.
*/
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', 'held'];
/** Reads an answer from `api/engine`. Returns null when it is not one. */
export function parseEngineStatus(doc: unknown): EngineStatus | null {
if (!doc || typeof doc !== 'object') return null;
const d = doc as Record<string, unknown>;
if (typeof d.running !== 'boolean') return null;
const g = d.game as Record<string, unknown> | null | undefined;
const cheats: KeptCheat[] = [];
for (const c of Array.isArray(d.cheats) ? (d.cheats as Record<string, unknown>[]) : []) {
if (!c || typeof c.name !== 'string') continue;
cheats.push({
name: c.name,
// 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 {
running: d.running,
game: g && typeof g === 'object' && typeof g.titleId === 'string' ? { titleId: g.titleId, version: String(g.version ?? ''), pid: Number(g.pid ?? 0) } : null,
file: typeof d.file === 'string' ? d.file : '',
// 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,
};
}
/**
* What the engine on the console is doing. Returns null when this console
* has no engine: no payload loaded, or one from before it had one (it
* answers 404; the bridge answers 502 when nothing is listening).
*/
export async function fetchEngineStatus(target: Target | null): Promise<EngineStatus | null> {
try {
const res = await fetch(consoleApiUrl(target, 'api/engine'), { cache: 'no-store' });
if (!res.ok) return null;
return parseEngineStatus(await res.json());
} catch {
return null;
}
}
/** Tells the engine to read the saved table again now. Returns false when there is no engine to tell. */
export async function reloadEngine(target: Target | null): Promise<boolean> {
try {
const res = await fetch(consoleApiUrl(target, 'api/engine/reload'), { method: 'POST', cache: 'no-store' });
return res.ok;
} catch {
return false;
}
}
/**
* The engine's word on one kept cheat. The engine knows cheats by name, so
* two kept cheats with the same name are told apart by their order.
* `index` is the cheat's position among the kept cheats of that name, as
* keptEntries() in table.ts lists them. Cheats the engine refused are not
* counted: it refuses exactly what keptEntries() leaves out.
*/
export function keptStatus(status: EngineStatus | null, titleId: string, name: string, index = 0): KeptCheat | null {
if (!status) return null;
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' && !c.start)[index] ?? null;
}
export interface KeptOverview {
/** "good": everything kept is on. "info": nothing wrong, but not all on (yet). "warn": something needs a look. */
kind: 'good' | 'info' | 'warn';
/** One or two sentences saying how the kept cheats are doing as a whole. */
text: string;
/** One line for each thing that needs a look. */
lines: string[];
}
const count = (n: number, one: string, many: string): string => `${n} ${n === 1 ? one : many}`;
/**
* How the cheats kept on by the PS5 are doing as a whole, in words for the
* top of the cheats list. `kept` is how many cheats of this game are marked
* to keep. Returns null when there is nothing to say: nothing is kept and
* the engine has no complaint.
*/
export function keptOverview(status: EngineStatus | null, game: { titleId: string; version: string }, kept: number): KeptOverview | null {
if (!status) {
if (kept === 0) return null;
return {
kind: 'warn',
text: 'Your PS5 is not answering about the cheats it keeps on. It needs the CheatSpecter payload running: send payload/cheatspecter.elf to the PS5 again.',
lines: [],
};
}
if (!status.running) {
if (kept === 0) return null;
return {
kind: 'warn',
text: 'Your PS5 cannot reach its debugger right now, so no cheats are being kept on. Load ps5debug on the PS5 again; the cheats come back on by themselves.',
lines: [],
};
}
if (!status.game || status.game.titleId.toUpperCase() !== game.titleId.toUpperCase()) {
if (kept === 0) return null;
return {
kind: 'info',
text: status.game
? `Your PS5 sees a different game running (${status.game.titleId}). The cheats kept on for this one switch on by themselves when it runs.`
: 'Your PS5 does not see a game running right now. Kept cheats switch on by themselves when this one does.',
lines: [],
};
}
const lines: string[] = [];
// 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. 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. 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}`);
if (status.busy) lines.push('It is holding back searches of its own until the search running here is finished.');
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 (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.`);
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 };
}
+884
View File
@@ -0,0 +1,884 @@
// The Learn section: lessons as data.
//
// Lessons are not part of the app. They live in the repository's learn/
// folder (an index plus one JSON file per lesson) and are downloaded from
// GitHub when the user asks for them, so they cost nothing on the console.
//
// A downloaded lesson is untrusted input. Nothing in one is ever executed or
// put into the page as HTML: text is a small markup parsed here into plain
// data, practice games and their goals are descriptions interpreted by code
// that ships with the app (src/lib/practice), and every file is checked
// against the shapes below and refused whole if anything does not fit.
// learn/README.md describes the format for people writing lessons.
import { loadJson, saveJson } from './storage.ts';
// Set at build time from package.json ("cheatspecter": { "learnUrl": ... }); see vite.config.ts.
declare const __LEARN_URL__: string | undefined;
/** Where lessons are downloaded from. Ends with a slash. */
export const LEARN_URL: string =
typeof __LEARN_URL__ === 'string' && __LEARN_URL__ ? __LEARN_URL__ : 'https://raw.githubusercontent.com/TiagoVal97/cheatspecter/main/learn/';
/** Served next to the app by the bridge in demo mode and by the dev server, so lessons can be tried before they are published. */
const LOCAL_URL = 'learn/';
export const LESSON_FORMAT = 'cheatspecter-lesson';
export const INDEX_FORMAT = 'cheatspecter-lessons';
export const FORMAT_VERSION = 1;
// ---- shapes -------------------------------------------------------------------
export type FieldType = 'u8' | 'u16' | 'u32' | 'float' | 'double' | 'pointer';
export interface FieldDef {
id: string;
type: FieldType;
/** Starting value. Not for pointers. */
value?: number;
/** Distance from the start of the object, in bytes. Left out, fields follow one another from 16. */
offset?: number;
/** For a pointer: the object it points at. */
to?: string;
}
export interface ObjectDef {
id: string;
/** Kept in the game's own data, at the same place every start. Everything else moves on restart. */
static?: boolean;
fields: FieldDef[];
}
export interface EffectDef {
/** "object.field" */
field: string;
op: 'add' | 'sub';
by: number;
/** A subtraction that would go below this refills instead (or stops here without `refill`). */
min?: number;
refill?: number;
/** An addition never goes above this. */
max?: number;
/** Effects that name the same site are done by the same piece of game code. */
site?: string;
}
export interface ActionDef {
id: string;
/** The button's words. */
label: string;
/** Runs by itself this often (milliseconds) instead of having a button. */
every?: number;
hidden?: boolean;
effects: EffectDef[];
}
export interface HudDef {
label: string;
field: string;
show: 'number' | 'bar' | 'time';
/** For a bar: the value that fills it. */
max?: number;
/** For a number: decimals shown. */
digits?: number;
}
/** A practice game: what it keeps in memory, what the player can do, and what it shows. */
export interface GameDef {
name: string;
objects: ObjectDef[];
actions: ActionDef[];
hud: HudDef[];
/** Unrelated places that happen to hold a number. */
decoys?: { type: 'u32' | 'float' | 'double'; value: number; count: number }[];
/** Places that follow a value for a few changes and then stop, as a game's own copies do. */
copies?: { of: string; count: number; follow: 'fade' }[];
/** How many values change by themselves. */
drifting?: number;
/** The game has an update that can be installed, which moves its code. */
update?: boolean;
}
export type Goal =
| { check: 'all'; of: Exclude<Goal, { check: 'all' }>[] }
/** The learner's list of search results contains the value and is this short. */
| { check: 'narrowed'; label: string; field: string; atMost: number }
| { check: 'value'; label: string; field: string; is: 'atLeast' | 'atMost' | 'exactly'; value: number }
/** The value is the same after each of this many uses of an action: frozen, or its code is switched off. */
| { check: 'held'; label: string; field: string; action: string; times: number }
/** A value the game changes by itself has not moved for this long. */
| { check: 'steady'; label: string; field: string; seconds: number }
| { check: 'pressed'; label: string; action: string; times: number }
| { check: 'restarted'; label: string }
| { check: 'updated'; label: string }
/** The debugger has reported the code that writes the value. */
| { check: 'caught'; label: string; field: string }
/** The code behind an action's first effect is replaced with no-ops. */
| { check: 'off'; label: string; action: string }
/** A cheat of this kind is in "Your cheats". */
| { check: 'saved'; label: string; type: 'value' | 'patch'; field?: string; action?: string; named?: boolean; grouped?: boolean; location?: 'pointer' };
export type LeafGoal = Exclude<Goal, { check: 'all' }>;
export type Tool = 'find' | 'memory' | 'code' | 'cheats';
export type Step =
| { kind: 'read'; title: string; text: string }
| { kind: 'choice'; title: string; text?: string; question: string; options: string[]; answer: number; explain: string }
| { kind: 'answer'; title: string; text?: string; question: string; accept: string[]; explain: string }
| {
kind: 'practice';
title: string;
text: string;
/** Which of the lesson's games it is played in. */
game: string;
/** The tool tab shown first. */
tool: Tool;
goal: Goal;
hints: string[];
/** Shown when the goal is reached. */
done: string;
/** Start from a newly started game with no cheats. */
fresh?: boolean;
/** The learner does it alone: no steps given, only the goal. */
challenge?: boolean;
}
/** Practice that the app cannot offer yet. */
| { kind: 'soon'; title: string; text: string };
export type PracticeStep = Extract<Step, { kind: 'practice' }>;
export interface Lesson {
format: typeof LESSON_FORMAT;
version: number;
id: string;
title: string;
summary: string;
minutes: number;
games: Record<string, GameDef>;
steps: Step[];
}
export interface LessonRef {
id: string;
title: string;
summary: string;
minutes: number;
/** False for lessons that are reading and questions only. */
practice: boolean;
}
export interface Course {
id: string;
title: string;
summary: string;
lessons: LessonRef[];
}
export interface LessonIndex {
format: typeof INDEX_FORMAT;
version: number;
/** Which printing of the lessons this is, e.g. "2026-10". */
edition: string;
courses: Course[];
}
// ---- checking a downloaded file ------------------------------------------------
/** A lesson file that does not have the expected shape. `where` says which part. */
export class LessonFormatError extends Error {
constructor(where: string, problem: string) {
super(`${where}: ${problem}`);
this.name = 'LessonFormatError';
}
}
const ID = /^[a-z][a-z0-9-]{0,39}$/;
const NAME = /^[a-z][A-Za-z0-9]{0,23}$/;
type Rec = Record<string, unknown>;
function bad(where: string, problem: string): never {
throw new LessonFormatError(where, problem);
}
/** An object with only the keys listed (`?` marks optional ones). */
function rec(v: unknown, where: string, keys: string): Rec {
if (!v || typeof v !== 'object' || Array.isArray(v)) bad(where, 'should be an object');
const o = v as Rec;
const known = keys.split(' ');
for (const k of Object.keys(o)) if (!known.includes(k) && !known.includes(k + '?')) bad(where, `has an unknown part "${k.slice(0, 40)}"`);
for (const k of known) if (!k.endsWith('?') && o[k] === undefined) bad(where, `is missing "${k}"`);
return o;
}
function str(v: unknown, where: string, max: number, pattern?: RegExp): string {
if (typeof v !== 'string' || v.length === 0) bad(where, 'should be text');
if (v.length > max) bad(where, `is longer than ${max} characters`);
if (pattern && !pattern.test(v)) bad(where, `"${v.slice(0, 40)}" is not a valid name here`);
return v;
}
function num(v: unknown, where: string, min: number, max: number, whole = false): number {
if (typeof v !== 'number' || !Number.isFinite(v)) bad(where, 'should be a number');
if (v < min || v > max || (whole && !Number.isInteger(v))) bad(where, `should be ${whole ? 'a whole number ' : ''}between ${min} and ${max}`);
return v;
}
function list<T>(v: unknown, where: string, min: number, max: number, each: (item: unknown, where: string) => T): T[] {
if (!Array.isArray(v)) bad(where, 'should be a list');
if (v.length < min || v.length > max) bad(where, `should have between ${min} and ${max} items`);
return v.map((item, i) => each(item, `${where}[${i}]`));
}
function oneOf<T extends string>(v: unknown, where: string, options: readonly T[]): T {
if (typeof v !== 'string' || !options.includes(v as T)) bad(where, `should be one of: ${options.join(', ')}`);
return v as T;
}
function flag(v: unknown, where: string): boolean | undefined {
if (v !== undefined && typeof v !== 'boolean') bad(where, 'should be true or false');
return v as boolean | undefined;
}
/** Drops keys that are undefined, so a checked file compares equal to what was written. */
function tidy<T extends object>(o: T): T {
for (const k of Object.keys(o) as (keyof T)[]) if (o[k] === undefined) delete o[k];
return o;
}
const SIZE: Record<FieldType, number> = { u8: 1, u16: 2, u32: 4, float: 4, double: 8, pointer: 8 };
/** Bytes a field of this type takes. */
export function fieldSize(type: FieldType): number {
return SIZE[type];
}
/** Checks one practice game and returns it as typed data. Throws LessonFormatError. */
export function checkGame(doc: unknown, where = 'game'): GameDef {
const g = rec(doc, where, 'name objects actions hud decoys? copies? drifting? update?');
const fields = new Map<string, FieldDef>();
const objects = list(g.objects, `${where}.objects`, 1, 8, (v, w): ObjectDef => {
const o = rec(v, w, 'id static? fields');
const id = str(o.id, `${w}.id`, 24, NAME);
const used: [number, number][] = [];
let next = 16;
const fs = list(o.fields, `${w}.fields`, 1, 16, (fv, fw): FieldDef => {
const f = rec(fv, fw, 'id type value? offset? to?');
const type = oneOf(f.type, `${fw}.type`, ['u8', 'u16', 'u32', 'float', 'double', 'pointer'] as const);
const size = SIZE[type];
const def: FieldDef = { id: str(f.id, `${fw}.id`, 24, NAME), type };
if (type === 'pointer') {
def.to = str(f.to, `${fw}.to`, 24, NAME);
if (f.value !== undefined) bad(fw, 'a pointer has no value of its own; say what it points "to"');
} else {
if (f.to !== undefined) bad(fw, 'only a pointer has "to"');
def.value = num(f.value, `${fw}.value`, type === 'float' || type === 'double' ? -1e9 : 0, type === 'u8' ? 255 : type === 'u16' ? 65535 : type === 'u32' ? 0xffffffff : 1e9, !(type === 'float' || type === 'double'));
}
const offset = f.offset === undefined ? Math.ceil(next / size) * size : num(f.offset, `${fw}.offset`, 0, 1016, true);
if (offset % size !== 0) bad(`${fw}.offset`, `should be a multiple of ${size} for this type`);
if (used.some(([a, b]) => offset < b && a < offset + size)) bad(`${fw}.offset`, 'overlaps another field');
used.push([offset, offset + size]);
next = offset + size;
def.offset = offset;
if (fields.has(`${id}.${def.id}`)) bad(fw, 'is listed twice');
fields.set(`${id}.${def.id}`, def);
return def;
});
return tidy({ id, static: flag(o.static, `${w}.static`), fields: fs });
});
if (new Set(objects.map((o) => o.id)).size !== objects.length) bad(`${where}.objects`, 'two objects share an id');
for (const o of objects) for (const f of o.fields) if (f.to !== undefined && !objects.some((x) => x.id === f.to)) bad(`${where}.objects`, `"${o.id}.${f.id}" points to an object that is not there`);
const numeric = (v: unknown, w: string): string => {
const name = str(v, w, 50);
const f = fields.get(name);
if (!f) bad(w, `there is no field "${name.slice(0, 50)}"`);
if (f.type !== 'u32' && f.type !== 'float' && f.type !== 'double') bad(w, 'should be a 4-byte whole number, a float or a double');
return name;
};
const sites = new Map<string, string>();
const actions = list(g.actions, `${where}.actions`, 0, 8, (v, w): ActionDef => {
const a = rec(v, w, 'id label every? hidden? effects');
const id = str(a.id, `${w}.id`, 24, NAME);
const effects = list(a.effects, `${w}.effects`, 1, 4, (ev, ew): EffectDef => {
const e = rec(ev, ew, 'field op by min? refill? max? site?');
const def: EffectDef = tidy({
field: numeric(e.field, `${ew}.field`),
op: oneOf(e.op, `${ew}.op`, ['add', 'sub'] as const),
by: num(e.by, `${ew}.by`, 0.001, 1e6),
min: e.min === undefined ? undefined : num(e.min, `${ew}.min`, -1e9, 1e9),
refill: e.refill === undefined ? undefined : num(e.refill, `${ew}.refill`, -1e9, 1e9),
max: e.max === undefined ? undefined : num(e.max, `${ew}.max`, -1e9, 1e9),
site: e.site === undefined ? undefined : str(e.site, `${ew}.site`, 24, NAME),
});
const f = fields.get(def.field)!;
if (f.type === 'u32' && (!Number.isInteger(def.by) || def.by > 0x7fffffff)) bad(`${ew}.by`, 'should be a whole number for a whole-number field');
if (def.site) {
// One piece of code does one thing: the same field offset, type and amount for everyone who uses it.
const shape = `${def.field.split('.')[1]}/${f.type}/${f.offset}/${def.op}/${def.by}`;
if (sites.has(def.site) && sites.get(def.site) !== shape) bad(`${ew}.site`, 'effects sharing a site must do the same thing to the same field of their object');
sites.set(def.site, shape);
}
return def;
});
return tidy({
id,
label: str(a.label, `${w}.label`, 60),
every: a.every === undefined ? undefined : num(a.every, `${w}.every`, 250, 60000, true),
hidden: flag(a.hidden, `${w}.hidden`),
effects,
});
});
if (new Set(actions.map((a) => a.id)).size !== actions.length) bad(`${where}.actions`, 'two actions share an id');
const game: GameDef = {
name: str(g.name, `${where}.name`, 40),
objects,
actions,
hud: list(g.hud, `${where}.hud`, 0, 6, (v, w): HudDef => {
const h = rec(v, w, 'label field show max? digits?');
const field = str(h.field, `${w}.field`, 50);
if (!fields.has(field) || fields.get(field)!.type === 'pointer') bad(`${w}.field`, 'should name a field that holds a number');
return tidy({
label: str(h.label, `${w}.label`, 40),
field,
show: oneOf(h.show, `${w}.show`, ['number', 'bar', 'time'] as const),
max: h.max === undefined ? undefined : num(h.max, `${w}.max`, 0.001, 1e9),
digits: h.digits === undefined ? undefined : num(h.digits, `${w}.digits`, 0, 3, true),
});
}),
};
if (g.decoys !== undefined) {
game.decoys = list(g.decoys, `${where}.decoys`, 0, 8, (v, w) => {
const d = rec(v, w, 'type value count');
return { type: oneOf(d.type, `${w}.type`, ['u32', 'float', 'double'] as const), value: num(d.value, `${w}.value`, -1e9, 0xffffffff), count: num(d.count, `${w}.count`, 1, 200, true) };
});
}
if (g.copies !== undefined) {
game.copies = list(g.copies, `${where}.copies`, 0, 4, (v, w) => {
const c = rec(v, w, 'of count follow');
return { of: numeric(c.of, `${w}.of`), count: num(c.count, `${w}.count`, 1, 8, true), follow: oneOf(c.follow, `${w}.follow`, ['fade'] as const) };
});
}
if (g.drifting !== undefined) game.drifting = num(g.drifting, `${where}.drifting`, 0, 200, true);
if (g.update !== undefined) game.update = flag(g.update, `${where}.update`);
return game;
}
function checkGoal(doc: unknown, where: string, game: GameDef, nested = false): Goal {
if (!doc || typeof doc !== 'object') bad(where, 'should be an object');
const kind = (doc as Rec).check;
const field = (v: unknown, w: string): string => {
const name = str(v, w, 50);
const [o, f] = name.split('.');
const def = game.objects.find((x) => x.id === o)?.fields.find((x) => x.id === f);
if (!def || def.type === 'pointer') bad(w, `the game has no number called "${name}"`);
return name;
};
const action = (v: unknown, w: string): string => {
const id = str(v, w, 24);
if (!game.actions.some((a) => a.id === id)) bad(w, `the game has no action called "${id}"`);
return id;
};
const label = (g: Rec) => str(g.label, `${where}.label`, 160);
switch (kind) {
case 'all': {
if (nested) bad(where, '"all" cannot be inside another "all"');
const g = rec(doc, where, 'check of');
return { check: 'all', of: list(g.of, `${where}.of`, 2, 5, (v, w) => checkGoal(v, w, game, true) as LeafGoal) };
}
case 'narrowed': {
const g = rec(doc, where, 'check label field atMost');
return { check: kind, label: label(g), field: field(g.field, `${where}.field`), atMost: num(g.atMost, `${where}.atMost`, 1, 1e7, true) };
}
case 'value': {
const g = rec(doc, where, 'check label field is value');
return { check: kind, label: label(g), field: field(g.field, `${where}.field`), is: oneOf(g.is, `${where}.is`, ['atLeast', 'atMost', 'exactly'] as const), value: num(g.value, `${where}.value`, -1e9, 0xffffffff) };
}
case 'held': {
const g = rec(doc, where, 'check label field action times');
const goal = { check: kind, label: label(g), field: field(g.field, `${where}.field`), action: action(g.action, `${where}.action`), times: num(g.times, `${where}.times`, 1, 10, true) } as const;
if (!game.actions.find((a) => a.id === goal.action)!.effects.some((e) => e.field === goal.field)) bad(where, 'that action does not change that value, so there would be nothing to hold');
return goal;
}
case 'steady': {
const g = rec(doc, where, 'check label field seconds');
const goal = { check: kind, label: label(g), field: field(g.field, `${where}.field`), seconds: num(g.seconds, `${where}.seconds`, 1, 30, true) } as const;
if (!game.actions.some((a) => a.every && a.effects.some((e) => e.field === goal.field))) bad(where, 'nothing in the game changes that value by itself');
return goal;
}
case 'pressed': {
const g = rec(doc, where, 'check label action times');
return { check: kind, label: label(g), action: action(g.action, `${where}.action`), times: num(g.times, `${where}.times`, 1, 20, true) };
}
case 'restarted':
return { check: kind, label: label(rec(doc, where, 'check label')) };
case 'updated':
if (!game.update) bad(where, 'the game has no update to install ("update": true)');
return { check: kind, label: label(rec(doc, where, 'check label')) };
case 'caught': {
const g = rec(doc, where, 'check label field');
return { check: kind, label: label(g), field: field(g.field, `${where}.field`) };
}
case 'off': {
const g = rec(doc, where, 'check label action');
return { check: kind, label: label(g), action: action(g.action, `${where}.action`) };
}
case 'saved': {
const g = rec(doc, where, 'check label type field? action? named? grouped? location?');
const type = oneOf(g.type, `${where}.type`, ['value', 'patch'] as const);
if (type === 'value' ? g.field === undefined || g.action !== undefined : g.action === undefined || g.field !== undefined) {
bad(where, 'a saved value names a "field"; a saved patch names an "action"');
}
return tidy({
check: kind,
label: label(g),
type,
field: g.field === undefined ? undefined : field(g.field, `${where}.field`),
action: g.action === undefined ? undefined : action(g.action, `${where}.action`),
named: flag(g.named, `${where}.named`),
grouped: flag(g.grouped, `${where}.grouped`),
location: g.location === undefined ? undefined : oneOf(g.location, `${where}.location`, ['pointer'] as const),
});
}
default:
bad(`${where}.check`, 'is not a kind of goal this version of CheatSpecter knows');
}
}
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', NEWER);
return version;
}
const TEXT_MAX = 6000;
/** Checks a lesson file and returns it as typed data. Throws LessonFormatError (or LearnError for a newer format). */
export function checkLesson(doc: unknown): Lesson {
const l = rec(doc, 'lesson', 'format version id title summary minutes games steps');
if (l.format !== LESSON_FORMAT) bad('lesson.format', `should be "${LESSON_FORMAT}"`);
const version = checkVersion(l.version, 'lesson');
if (!l.games || typeof l.games !== 'object' || Array.isArray(l.games)) bad('lesson.games', 'should be an object');
const gamesDoc = l.games as Rec;
const names = Object.keys(gamesDoc);
if (names.length > 6) bad('lesson.games', 'should have at most 6 games');
const games: Record<string, GameDef> = {};
for (const name of names) games[str(name, 'lesson.games', 24, NAME)] = checkGame(gamesDoc[name], `lesson.games.${name}`);
const steps = list(l.steps, 'lesson.steps', 1, 40, (v, w): Step => {
const kind = (v as Rec | null)?.kind;
const title = (s: Rec) => str(s.title, `${w}.title`, 120);
const optional = (s: Rec) => (s.text === undefined || s.text === '' ? undefined : str(s.text, `${w}.text`, TEXT_MAX));
switch (kind) {
case 'read':
case 'soon': {
const s = rec(v, w, 'kind title text');
return { kind, title: title(s), text: str(s.text, `${w}.text`, TEXT_MAX) };
}
case 'choice': {
const s = rec(v, w, 'kind title text? question options answer explain');
const options = list(s.options, `${w}.options`, 2, 6, (o, ow) => str(o, ow, 300));
return tidy({ kind, title: title(s), text: optional(s), question: str(s.question, `${w}.question`, 600), options, answer: num(s.answer, `${w}.answer`, 0, options.length - 1, true), explain: str(s.explain, `${w}.explain`, 1200) });
}
case 'answer': {
const s = rec(v, w, 'kind title text? question accept explain');
return tidy({ kind, title: title(s), text: optional(s), question: str(s.question, `${w}.question`, 600), accept: list(s.accept, `${w}.accept`, 1, 12, (o, ow) => str(o, ow, 60)), explain: str(s.explain, `${w}.explain`, 1200) });
}
case 'practice': {
const s = rec(v, w, 'kind title text game tool goal hints done fresh? challenge?');
const game = str(s.game, `${w}.game`, 24);
if (!games[game]) bad(`${w}.game`, `the lesson has no game called "${game}"`);
return tidy({
kind,
title: title(s),
text: str(s.text, `${w}.text`, TEXT_MAX),
game,
tool: oneOf(s.tool, `${w}.tool`, ['find', 'memory', 'code', 'cheats'] as const),
goal: checkGoal(s.goal, `${w}.goal`, games[game]),
hints: list(s.hints, `${w}.hints`, 1, 6, (h, hw) => str(h, hw, 500)),
done: str(s.done, `${w}.done`, 1200),
fresh: flag(s.fresh, `${w}.fresh`),
challenge: flag(s.challenge, `${w}.challenge`),
});
}
default:
bad(`${w}.kind`, 'is not a kind of step this version of CheatSpecter knows');
}
});
return {
format: LESSON_FORMAT,
version,
id: str(l.id, 'lesson.id', 40, ID),
title: str(l.title, 'lesson.title', 120),
summary: str(l.summary, 'lesson.summary', 300),
minutes: num(l.minutes, 'lesson.minutes', 1, 120, true),
games,
steps,
};
}
/** Checks the lesson index and returns it as typed data. */
export function checkIndex(doc: unknown): LessonIndex {
const d = rec(doc, 'index', 'format version edition courses');
if (d.format !== INDEX_FORMAT) bad('index.format', `should be "${INDEX_FORMAT}"`);
const version = checkVersion(d.version, 'index');
const seen = new Set<string>();
const courses = list(d.courses, 'index.courses', 1, 12, (v, w): Course => {
const c = rec(v, w, 'id title summary lessons');
return {
id: str(c.id, `${w}.id`, 40, ID),
title: str(c.title, `${w}.title`, 120),
summary: str(c.summary, `${w}.summary`, 300),
lessons: list(c.lessons, `${w}.lessons`, 1, 40, (lv, lw): LessonRef => {
const l = rec(lv, lw, 'id title summary minutes practice');
const id = str(l.id, `${lw}.id`, 40, ID);
if (seen.has(id)) bad(`${lw}.id`, 'is listed twice');
seen.add(id);
return { id, title: str(l.title, `${lw}.title`, 120), summary: str(l.summary, `${lw}.summary`, 300), minutes: num(l.minutes, `${lw}.minutes`, 1, 120, true), practice: flag(l.practice, `${lw}.practice`) === true };
}),
};
});
return { format: INDEX_FORMAT, version, edition: str(d.edition, 'index.edition', 20), courses };
}
// ---- lesson text ----------------------------------------------------------------
//
// The whole markup: paragraphs separated by a blank line, "# " headings,
// "- " and "1. " lists, "> note: ", "> tip: " and "> warning: " callouts,
// **bold** and `code`. Anything else is shown as the characters it is.
/** A run of text. `b` bold, `c` code. */
export interface Span {
s: string;
b?: true;
c?: true;
}
export type Block =
| { t: 'p' | 'h' | 'note' | 'tip' | 'warning'; spans: Span[] }
| { t: 'ul' | 'ol'; items: Span[][] };
function spans(text: string): Span[] {
const out: Span[] = [];
const push = (s: string, style?: 'b' | 'c') => {
if (s) out.push(style ? { s, [style]: true } : { s });
};
let rest = text;
for (;;) {
const m = /\*\*([^*]+)\*\*|`([^`]+)`/.exec(rest);
if (!m) break;
push(rest.slice(0, m.index));
if (m[1] !== undefined) push(m[1], 'b');
else push(m[2], 'c');
rest = rest.slice(m.index + m[0].length);
}
push(rest);
return out;
}
/** Turns lesson text into blocks of plain data. Never produces anything but the block and span kinds above. */
export function parseText(text: string): Block[] {
const out: Block[] = [];
for (const chunk of text.replace(/\r/g, '').split(/\n\s*\n/)) {
const lines = chunk.split('\n').map((l) => l.trim()).filter(Boolean);
if (lines.length === 0) continue;
const callout = /^> ?(note|tip|warning): ?(.*)$/.exec(lines[0]);
if (lines.length === 1 && lines[0].startsWith('# ')) out.push({ t: 'h', spans: spans(lines[0].slice(2)) });
else if (lines.every((l) => l.startsWith('- '))) out.push({ t: 'ul', items: lines.map((l) => spans(l.slice(2))) });
else if (lines.every((l) => /^\d+\. /.test(l))) out.push({ t: 'ol', items: lines.map((l) => spans(l.replace(/^\d+\. /, ''))) });
else if (callout) out.push({ t: callout[1] as 'note' | 'tip' | 'warning', spans: spans([callout[2], ...lines.slice(1).map((l) => l.replace(/^> ?/, ''))].join(' ')) });
else out.push({ t: 'p', spans: spans(lines.join(' ')) });
}
return out;
}
/** Whether what was typed is one of the accepted answers: case, spacing and a final period do not matter. */
export function answerMatches(accept: string[], typed: string): boolean {
const norm = (s: string) => s.trim().toLowerCase().replace(/\s+/g, ' ').replace(/[.!]$/, '');
return typed.trim() !== '' && accept.some((a) => norm(a) === norm(typed));
}
/** All the leaf goals of a goal, in the order they have to be reached. */
export function goalParts(goal: Goal): LeafGoal[] {
return goal.check === 'all' ? goal.of : [goal];
}
// ---- downloading ----------------------------------------------------------------
export type LearnProblem =
/** The download answered 404: the lessons are not on GitHub yet. */
| 'unpublished'
/** No answer at all: this device is offline, or GitHub cannot be reached. */
| 'offline'
/** Something came back that is not a lesson. */
| 'invalid'
/** The lessons need a newer app. */
| 'newer';
export class LearnError extends Error {
readonly problem: LearnProblem;
constructor(problem: LearnProblem, message: string) {
super(message);
this.name = 'LearnError';
this.problem = problem;
}
}
/** Larger than any real lesson; a file beyond it is not read. */
const MAX_FILE_BYTES = 256 * 1024;
type Fetch = (url: string, init?: RequestInit) => Promise<Response>;
/** 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', ...(signal ? { signal } : {}) });
} catch {
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 (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', '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.');
}
}
function checked<T>(check: (doc: unknown) => T, doc: unknown): T {
try {
return check(doc);
} catch (e) {
if (e instanceof LearnError) throw e;
throw new LearnError('invalid', `The download does not look like a CheatSpecter lesson, so it was not opened. (${e instanceof Error ? e.message : String(e)})`);
}
}
export interface LoadedIndex {
index: LessonIndex;
/** 'local': served next to the app (development and demo mode). */
from: 'github' | 'local';
}
/**
* Downloads lessons, and remembers what it has downloaded for as long as the
* page stays open. Nothing is kept beyond that: a reload asks again.
*/
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; 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> {
if (!this.index) {
this.index = (async (): Promise<LoadedIndex> => {
if (this.preferLocal) {
try {
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', false, this.patienceMs));
this.base = LEARN_URL;
return { index, from: 'github' };
})();
// A failed download is not remembered, so "Try again" really tries again.
this.index.catch(() => (this.index = null));
}
return this.index;
}
loadLesson(id: string): Promise<Lesson> {
let lesson = this.lessons.get(id);
if (!lesson) {
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`, 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;
})();
this.lessons.set(id, lesson);
lesson.catch(() => this.lessons.delete(id));
}
return lesson;
}
}
// ---- what the learner has done ---------------------------------------------------
export interface LessonProgress {
/** How many of the lesson's steps are done, counted from the start. */
steps: number;
finished?: boolean;
}
export interface Progress {
v: 1;
/** The user's choice about downloading lessons: null until they have made one. */
lessons: 'on' | 'off' | null;
done: Record<string, LessonProgress>;
/** When this was last changed, so the newer of two copies can win where they disagree. */
at: number;
}
export function emptyProgress(): Progress {
return { v: 1, lessons: null, done: {}, at: 0 };
}
/** Makes sense of whatever came out of storage. */
export function readProgress(doc: unknown): Progress {
const out = emptyProgress();
if (!doc || typeof doc !== 'object') return out;
const d = doc as Rec;
if (d.lessons === 'on' || d.lessons === 'off') out.lessons = d.lessons;
if (typeof d.at === 'number' && Number.isFinite(d.at)) out.at = d.at;
if (d.done && typeof d.done === 'object') {
for (const [id, v] of Object.entries(d.done as Rec).slice(0, 500)) {
const p = v as Rec | null;
if (!ID.test(id) || !p || typeof p.steps !== 'number' || !Number.isFinite(p.steps)) continue;
out.done[id] = p.finished === true ? { steps: Math.max(0, Math.floor(p.steps)), finished: true } : { steps: Math.max(0, Math.floor(p.steps)) };
}
}
return out;
}
/** Two copies of the progress put together: nothing done is ever lost, and the newer choice about downloading wins. */
export function mergeProgress(a: Progress, b: Progress): Progress {
const newer = b.at > a.at ? b : a;
const out: Progress = { v: 1, lessons: newer.lessons ?? a.lessons ?? b.lessons, done: {}, at: Math.max(a.at, b.at) };
for (const id of new Set([...Object.keys(a.done), ...Object.keys(b.done)])) {
const x = a.done[id];
const y = b.done[id];
const steps = Math.max(x?.steps ?? 0, y?.steps ?? 0);
out.done[id] = x?.finished || y?.finished ? { steps, finished: true } : { steps };
}
return out;
}
const PREFS_URL = 'api/prefs/learn-progress';
const STORAGE_KEY = 'learn';
/**
* Keeps the progress. Browser storage is tied to the page's address, and a
* console's address can change, so the copy that counts is kept by whatever
* serves the page (`api/prefs/learn-progress`) when it offers that; browser
* storage is the fallback and a second copy.
*/
export class ProgressStore {
private fetcher: Fetch;
/** 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; patienceMs?: number } = {}) {
this.fetcher = opts.fetch ?? ((url, init) => fetch(url, init));
this.patienceMs = opts.patienceMs ?? 4000;
}
/** `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 {
// 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()));
}
// 404 from a server that has the store only means nothing is saved yet;
// from one that lacks it, the first save finds out.
if (res.status !== 404) this.remote = false;
} catch {
this.remote = false;
}
return local;
}
/** Saves now in the browser, and a moment later with the server (so a burst of changes is one request). */
save(progress: Progress): void {
saveJson(STORAGE_KEY, progress);
if (this.remote === false) return;
clearTimeout(this.timer);
this.timer = setTimeout(() => void this.push(progress), 400);
}
private async push(progress: Progress): Promise<void> {
try {
const res = await this.fetcher(PREFS_URL, { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify(progress) });
this.remote = res.ok;
} catch {
this.remote = false;
}
}
}
+10
View File
@@ -0,0 +1,10 @@
// The one thing the rest of the app needs to know about the Learn section:
// how to send the user there. Kept apart from learn.ts so that asking for it
// does not pull the lesson code into the first download.
export const LEARN_EVENT = 'cheatspecter:learn';
/** Opens the Learn section, at one lesson (its id in learn/index.json) when given. The App listens for this. */
export function openLearn(lesson?: string): void {
window.dispatchEvent(new CustomEvent(LEARN_EVENT, { detail: { lesson } }));
}
+229 -26
View File
@@ -9,16 +9,21 @@ import { parsePattern } from './x86.ts';
import type { AsyncX86 } from './x86.ts';
export type Location =
/** Offset from the start of the game's executable. Breaks when the game is updated. */
| { kind: 'module'; offset: string }
/**
* Offset from the start of a module. Breaks when the game is updated.
* `module` is the module's name as the memory map shows it (for example
* "Il2CppUserAssemblies.prx"); left out, it means the game's own executable.
*/
| { kind: 'module'; offset: string; module?: string }
/** A byte pattern found in the game's code, plus a distance from where it matched. Survives most updates. */
| { kind: 'signature'; pattern: string; offset: number }
/**
* A pointer path, Cheat Engine style: read the pointer stored at
* executable+base, add offsets[0], read again, ... and add the last offset
* without reading. For values on the heap, which move every launch.
* With `module`, base is an offset into that module instead of the executable.
*/
| { kind: 'pointer'; base: string; offsets: string[] }
| { kind: 'pointer'; base: string; offsets: string[]; module?: string }
/** A raw address. Only good until the game restarts. */
| { kind: 'address'; address: string };
@@ -75,26 +80,145 @@ export async function imageBase(ctx: GameContext): Promise<bigint> {
return base.address;
}
export interface AddressRange {
start: bigint;
end: bigint;
}
/** The run of touching maps that carry the same name as the one starting at `start`. */
function runOfMaps(maps: MapEntry[], start: bigint, name: string): AddressRange {
const sorted = maps.filter((m) => m.name === name).sort((a, b) => (a.start < b.start ? -1 : 1));
let end = start;
for (const m of sorted) {
if (m.end <= end) continue;
if (m.start > end) {
if (end === start && m.start !== start) continue;
break;
}
end = m.end;
}
return { start, end };
}
/**
* The address range of the game's own executable: its code and its static
* data. Used to recognise "this pointer is stored in the game image", which
* data. Used to recognize "this pointer is stored in the game image", which
* is what makes a pointer path stable.
*/
export async function imageRange(ctx: GameContext): Promise<{ start: bigint; end: bigint }> {
export async function imageRange(ctx: GameContext): Promise<AddressRange> {
const maps = await ctx.maps();
const base = findImageBase(maps, ctx.processName);
if (!base) throw new Error('Could not work out where the game is loaded');
const first = maps.find((m) => m.start === base.address);
const name = first?.name ?? '';
// The image is the run of touching maps that carry the same name as its first one.
const sorted = [...maps].sort((a, b) => (a.start < b.start ? -1 : 1));
let end = first ? first.end : base.address;
for (const m of sorted) {
if (m.start < end || m.name !== name) continue;
if (m.start === end) end = m.end;
else break;
return first ? runOfMaps(maps, base.address, first.name) : { start: base.address, end: base.address };
}
// Names the memory map gives to things that are loaded code with their own
// static data: libraries (.prx, .sprx). Stacks, heaps and system buffers have
// names too, but nothing in them stays at a fixed offset.
const MODULE_NAME = /\.s?prx$/i;
/** Names that mean the game's own executable when someone types them. */
const MAIN_ALIASES = new Set(['game', 'base', 'eboot', 'eboot.bin', 'executable']);
function isMainName(ctx: GameContext, name: string): boolean {
const n = name.toLowerCase();
return MAIN_ALIASES.has(n) || n === ctx.processName.toLowerCase();
}
/**
* The address range of a loaded module (code and static data), by the name
* the memory map gives it, ignoring case. `undefined`, or any of the usual
* names for the game itself, gives the game's executable. Null when no such
* module is loaded.
*/
export async function moduleRange(ctx: GameContext, name?: string): Promise<AddressRange | null> {
if (name === undefined || isMainName(ctx, name)) return imageRange(ctx);
const wanted = name.toLowerCase();
const maps = await ctx.maps();
const mine = maps.filter((m) => m.name.toLowerCase() === wanted && m.end > m.start);
if (mine.length === 0) return null;
const first = mine.reduce((a, b) => (b.start < a.start ? b : a));
return runOfMaps(maps, first.start, first.name);
}
async function moduleBase(ctx: GameContext, name: string | undefined): Promise<bigint> {
if (name === undefined) return imageBase(ctx);
let range = await moduleRange(ctx, name);
// Libraries can be loaded while the game runs; look again before giving up.
if (!range) {
await ctx.maps(true);
range = await moduleRange(ctx, name);
}
return { start: base.address, end };
if (!range) throw new LocationError(`The module "${name}" is not loaded in this game right now.`);
return range.start;
}
export interface AddressOwner {
/** Module name as in the memory map; undefined for the game's own executable. */
module?: string;
/** Distance from the start of that module. */
offset: bigint;
}
export interface LoadedModule extends AddressRange {
/** Name as in the memory map; undefined for the game's own executable. */
module?: string;
}
// The module list is worked out once per memory map (ctx.maps() hands back
// the same array until it is refreshed); a pointer scan asks about hundreds of
// thousands of addresses.
const modulesCache = new WeakMap<MapEntry[], LoadedModule[]>();
/** Every loaded module with its address range, lowest first: the game's executable and each named library. */
export async function loadedModules(ctx: GameContext): Promise<LoadedModule[]> {
const maps = await ctx.maps();
let list = modulesCache.get(maps);
if (!list) {
list = [];
const image = await imageRange(ctx);
if (image.end > image.start) list.push(image);
const firstOf = new Map<string, bigint>();
for (const m of maps) {
if (!MODULE_NAME.test(m.name) || m.end <= m.start) continue;
const seen = firstOf.get(m.name);
if (seen === undefined || m.start < seen) firstOf.set(m.name, m.start);
}
for (const [name, start] of firstOf) {
if (start >= image.start && start < image.end) continue;
list.push({ ...runOfMaps(maps, start, name), module: name });
}
list.sort((a, b) => (a.start < b.start ? -1 : 1));
modulesCache.set(maps, list);
}
return list;
}
/**
* Which module an address belongs to (the game's executable or a named
* library), or null when it is somewhere without a fixed layout: heap, stack.
*/
export async function addressOwner(ctx: GameContext, address: bigint): Promise<AddressOwner | null> {
const list = await loadedModules(ctx);
let lo = 0;
let hi = list.length - 1;
while (lo <= hi) {
const mid = (lo + hi) >> 1;
const m = list[mid];
if (address < m.start) hi = mid - 1;
else if (address >= m.end) lo = mid + 1;
else return m.module === undefined ? { offset: address - m.start } : { module: m.module, offset: address - m.start };
}
return null;
}
/** "game+0x1A2B", "libc.prx+0x40" or, outside any module, the plain address. */
export async function describeAddress(ctx: GameContext, address: bigint): Promise<string> {
const owner = await addressOwner(ctx, address);
if (!owner) return fmtAddr(address);
return `${owner.module ?? 'game'}+${fmtAddr(owner.offset)}`;
}
export interface PatternMatch {
@@ -130,6 +254,35 @@ export async function findPattern(ctx: GameContext, text: string, where: 'code'
return first === null ? null : { address: first, unique: true };
}
/**
* "What refers to this address?": every instruction in a module's code whose
* RIP-relative memory operand resolves to `target`, i.e. the loads, stores
* and address-takings of a global variable or static pointer. The console
* does the disassembling, so only addresses come back; a whole game
* executable takes several seconds. Calls and jumps to `target` are not
* found (they are not memory operands).
*
* `module` picks whose code is searched: the game's own by default, or a
* library by name. Needs ps5debug-NG (check `session.extended` first; the
* original ps5debug never answers this command).
*/
export async function findReferences(ctx: GameContext, target: bigint, module?: string): Promise<bigint[]> {
const range = await moduleRange(ctx, module);
if (!range) throw new LocationError(`The module "${module}" is not loaded in this game right now.`);
const code = (await ctx.maps()).filter((m) => m.prot & PROT.EXEC && m.start >= range.start && m.end <= range.end && m.end > m.start);
const out: bigint[] = [];
for (const m of code) {
// The console decodes linearly from where it is told to start, so each code region is searched whole.
for (let start = m.start; start < m.end; ) {
const left = m.end - start;
const length = left > 0xf0000000n ? 0xf0000000 : Number(left);
out.push(...(await ctx.scanDbg().findXrefsTo({ pid: ctx.pid, address: start, length, target })));
start += BigInt(length);
}
}
return out;
}
export class LocationError extends Error {
constructor(message: string) {
super(message);
@@ -143,7 +296,7 @@ export async function resolveLocation(ctx: GameContext, loc: Location): Promise<
case 'address':
return hex(loc.address);
case 'module':
return (await imageBase(ctx)) + hex(loc.offset);
return (await moduleBase(ctx, loc.module)) + hex(loc.offset);
case 'signature': {
let at = ctx.signatureCache.get(loc.pattern);
if (at === undefined) {
@@ -157,7 +310,7 @@ export async function resolveLocation(ctx: GameContext, loc: Location): Promise<
}
case 'pointer': {
if (loc.offsets.length === 0) throw new LocationError('A pointer path needs at least one offset');
let addr = (await imageBase(ctx)) + hex(loc.base);
let addr = (await moduleBase(ctx, loc.module)) + hex(loc.base);
for (let i = 0; i < loc.offsets.length; i++) {
const raw = await ctx.dbg().read(ctx.pid, addr, 8);
const ptr = view(raw).getBigUint64(0, true);
@@ -182,11 +335,11 @@ export function describeLocation(loc: Location): string {
case 'address':
return fmtAddr(hex(loc.address));
case 'module':
return `game+${fmtAddr(hex(loc.offset))}`;
return `${loc.module ?? 'game'}+${fmtAddr(hex(loc.offset))}`;
case 'signature':
return `pattern ${loc.pattern}${loc.offset ? (loc.offset > 0 ? ` +${loc.offset}` : ` ${loc.offset}`) : ''}`;
case 'pointer': {
let s = `[game+${fmtAddr(hex(loc.base))}]`;
let s = `[${loc.module ?? 'game'}+${fmtAddr(hex(loc.base))}]`;
loc.offsets.forEach((o, i) => {
s = i === loc.offsets.length - 1 ? `${s}+${o}` : `[${s}+${o}]`;
});
@@ -196,20 +349,70 @@ export function describeLocation(loc: Location): string {
}
/**
* Parses what someone types into an address box: a hex address, or
* "game+1A2B" for an offset into the executable.
* Parses what someone types into an address box: a hex address, "game+1A2B"
* for an offset into the executable, or "libSomething.prx+1A2B" for an offset
* into another loaded module.
*/
export async function parseAddress(ctx: GameContext, text: string): Promise<bigint> {
const t = text.trim().replace(/\s+/g, '');
if (!t) throw new Error('Enter an address');
const m = /^(?:game|base|eboot(?:\.bin)?)\+(.+)$/i.exec(t);
if (m) return (await imageBase(ctx)) + hex(m[1]);
return hex(t);
const plus = t.lastIndexOf('+');
if (plus <= 0) return inRange(explained(t));
const name = t.slice(0, plus);
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 inRange(range.start + offset);
}
/** The best lasting description of an address inside the game's executable, or null if it is elsewhere. */
/** `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';
}
/**
* The best lasting description of an address inside a module: the game's
* executable (no `module` field, as always) or a named library. Null if the
* address is elsewhere.
*/
export async function moduleLocation(ctx: GameContext, address: bigint): Promise<Location | null> {
const range = await imageRange(ctx);
if (address < range.start || address >= range.end) return null;
return { kind: 'module', offset: (address - range.start).toString(16).toUpperCase() };
const owner = await addressOwner(ctx, address);
if (!owner) return null;
const offset = owner.offset.toString(16).toUpperCase();
return owner.module === undefined ? { kind: 'module', offset } : { kind: 'module', offset, module: owner.module };
}
+55 -8
View File
@@ -1,19 +1,27 @@
// Turning something found in the game into a saved cheat.
import { bytesToHex } from './bytes.ts';
import { coverInsns } from './codecheat.ts';
import { findPattern, moduleLocation } from './locations.ts';
import type { GameContext, Location } from './locations.ts';
import { newId } from './table.ts';
import type { PatchEntry, ValueEntry } from './table.ts';
import type { CodeSite, InjectEntry, PatchEntry, PatchSite, ValueEntry } from './table.ts';
import { proofSite } from './proof.ts';
import { signatureFromCode } from './x86.ts';
import type { Insn } from './x86.ts';
import type { ValueKind } from './scanner.ts';
/**
* The most durable way to refer to a spot in the game's code. A byte
* signature is tried first, growing until it matches in exactly one place;
* that keeps working after most game updates. Failing that, an offset into
* the executable (good until the next update), then the raw address.
* The most durable way to refer to a spot in the game's code that is only
* read or hooked. A byte signature is tried first, growing until it matches
* in exactly one place; that keeps working after most game updates. Failing
* that, an offset into the executable (good until the next update), then the
* raw address.
*
* The signature starts at the instruction itself, so it is no use for a
* patch: the pattern would stop matching the moment the patch overwrote
* those bytes. Patches go through proofSite (proof.ts), which builds the
* pattern from the code around the patched bytes instead.
*/
export async function codeLocation(ctx: GameContext, address: bigint): Promise<Location> {
try {
@@ -32,15 +40,54 @@ export async function codeLocation(ctx: GameContext, address: bigint): Promise<L
return (await moduleLocation(ctx, address)) ?? { kind: 'address', address: address.toString(16).toUpperCase() };
}
/**
* Where a patch of `on` bytes at `address` should say it lives: a pattern of
* the surrounding code with the fixed position as fallback when one can be
* made, else the fixed position, else the raw address.
*/
export async function patchSite(ctx: GameContext, address: bigint, on: string, off?: string): Promise<PatchSite> {
const fixed = await moduleLocation(ctx, address);
if (!fixed) return { at: { kind: 'address', address: address.toString(16).toUpperCase() }, on, off };
return proofSite(ctx, { at: fixed, on, off });
}
/**
* Where a code cheat hooking the instruction at `address` should say it
* lives: a byte pattern when one can be made, with the fixed position kept
* beside it (while the cheat is on, its own jump sits on the pattern's first
* bytes, so the pattern alone could not find the place again), and the bytes
* of the instructions the jump replaces, to recognize a game build the cheat
* does not fit.
*/
export async function codeSite(ctx: GameContext, address: bigint): Promise<Pick<CodeSite, 'at' | 'off' | 'fallback'>> {
const at = await codeLocation(ctx, address);
const site: Pick<CodeSite, 'at' | 'off' | 'fallback'> = { at };
if (at.kind === 'signature') {
const fixed = await moduleLocation(ctx, address);
if (fixed) site.fallback = fixed;
}
try {
const head = await ctx.dbg().read(ctx.pid, address, 48);
const covered = coverInsns(await ctx.x86.disassemble(head, address, 12), 5);
site.off = bytesToHex(head.subarray(0, covered.reduce((n, i) => n + i.length, 0)), ' ');
} catch {
// Not a spot a jump fits on; the editor's check says so in words.
}
return site;
}
/** An empty code cheat for the instruction at `address`, ready for the editor. */
export async function codeCheatEntry(ctx: GameContext, address: bigint, name = ''): Promise<InjectEntry> {
return { id: newId(), type: 'inject', name, ...(await codeSite(ctx, address)), code: '', placement: 'code' };
}
/** A switch that removes one instruction by overwriting it with no-ops. */
export async function nopEntry(ctx: GameContext, insn: Insn, name?: string): Promise<PatchEntry> {
return {
id: newId(),
type: 'patch',
name: name ?? `Skip "${insn.text}"`,
at: await codeLocation(ctx, insn.address),
on: '90'.repeat(insn.length),
off: bytesToHex(insn.bytes),
...(await patchSite(ctx, insn.address, '90'.repeat(insn.length), bytesToHex(insn.bytes))),
};
}
+125
View File
@@ -0,0 +1,125 @@
// Before/after comparison of one block of memory: copy it, let something
// happen in the game, copy it again and list what changed. Unlike a scan this
// keeps both copies here, so it suits a small, known area (an object found
// earlier, a module's data) rather than the whole game.
import type { Ps5Debug } from './ps5debug.ts';
import { view } from './bytes.ts';
import type { ValueKind } from './scanner.ts';
/** Largest block one snapshot may cover. Two of them are kept in the browser at once. */
export const SNAPSHOT_MAX_BYTES = 16 * 1024 * 1024;
/** Bytes per read. The console answers in 64 KB pieces, so this only bounds how long one exchange takes. */
const READ_BYTES = 1024 * 1024;
export interface MemSnapshot {
start: bigint;
bytes: Uint8Array;
/** Milliseconds since the epoch. */
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,
start: bigint,
length: number,
onProgress?: (fraction: number) => void,
signal?: AbortSignal,
): Promise<MemSnapshot> {
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 SnapshotCanceledError();
const take = Math.min(READ_BYTES, length - off);
bytes.set(await dbg().read(pid, start + BigInt(off), take), off);
onProgress?.((off + take) / length);
}
return { start, bytes, takenAt: Date.now() };
}
export type DiffFilter = 'changed' | 'up' | 'down';
export interface DiffRow {
address: bigint;
before: Uint8Array;
after: Uint8Array;
}
function numberAt(kind: ValueKind, dv: DataView, at: number): number | bigint {
if (kind.float) return kind.size === 4 ? dv.getFloat32(at, true) : dv.getFloat64(at, true);
switch (kind.size) {
case 1:
return kind.signed ? dv.getInt8(at) : dv.getUint8(at);
case 2:
return kind.signed ? dv.getInt16(at, true) : dv.getUint16(at, true);
case 4:
return kind.signed ? dv.getInt32(at, true) : dv.getUint32(at, true);
default:
return kind.signed ? dv.getBigInt64(at, true) : dv.getBigUint64(at, true);
}
}
/**
* Walks two snapshots of the same block and calls `each` for every value of
* the given type that differs in the way asked for. Values are read at
* addresses that are a multiple of the type's size.
*/
function walk(before: MemSnapshot, after: MemSnapshot, kind: ValueKind, filter: DiffFilter, each: (offset: number) => boolean | void): void {
if (before.start !== after.start || before.bytes.length !== after.bytes.length) throw new Error('The two snapshots are of different areas');
const size = kind.size;
const a = before.bytes;
const b = after.bytes;
const da = view(a);
const db = view(b);
const first = Number((BigInt(size) - (before.start % BigInt(size))) % BigInt(size));
for (let off = first; off + size <= a.length; off += size) {
let same = true;
for (let i = 0; i < size; i++) {
if (a[off + i] !== b[off + i]) {
same = false;
break;
}
}
if (same) continue;
if (filter !== 'changed') {
const x = numberAt(kind, da, off);
const y = numberAt(kind, db, off);
// A value that is not a number (NaN) neither went up nor down.
if (filter === 'up' ? !(y > x) : !(y < x)) continue;
}
if (each(off) === false) return;
}
}
/** What changed between two snapshots: the total, and the first `limit` rows for display. */
export function diffSnapshots(before: MemSnapshot, after: MemSnapshot, kind: ValueKind, filter: DiffFilter, limit: number): { total: number; rows: DiffRow[] } {
const rows: DiffRow[] = [];
let total = 0;
walk(before, after, kind, filter, (off) => {
total++;
if (rows.length < limit) {
rows.push({ address: before.start + BigInt(off), before: before.bytes.slice(off, off + kind.size), after: after.bytes.slice(off, off + kind.size) });
}
});
return { total, rows };
}
/** Every changed spot with its newer value, for carrying on as a normal candidate list (`ScanSession.fromHits`). */
export function diffHits(before: MemSnapshot, after: MemSnapshot, kind: ValueKind, filter: DiffFilter): { address: bigint; value: Uint8Array }[] {
const out: { address: bigint; value: Uint8Array }[] = [];
walk(before, after, kind, filter, (off) => {
out.push({ address: before.start + BigInt(off), value: after.bytes.slice(off, off + kind.size) });
});
return out;
}
+146
View File
@@ -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;
}
}
+224 -39
View File
@@ -1,17 +1,24 @@
// Finding a pointer path to a value.
//
// Game values usually sit on the heap, at an address that changes every
// launch. What stays put is a chain: a pointer at a fixed spot in the game's
// executable leads to an object, which holds a pointer to another, and so on
// until the value. This works backwards from the value: look for anything
// that points at (or just below) it, check whether that pointer is stored in
// the executable, and if not, repeat one level up.
// launch. What stays put is a chain: a pointer at a fixed spot in a module's
// static data (the game's executable, or a library it loads) leads to an
// object, which holds a pointer to another, and so on until the value. This
// works backward from the value: look for anything that points at (or just
// below) it, check whether that pointer is stored in a module, and if not,
// repeat one level up.
//
// A path found once is only a candidate: plenty of them work by coincidence
// until the game is restarted. `recheckPointerPaths` resolves saved candidates
// again, so the ones that still lead to the value after a restart can be told
// from the ones that do not.
import { COMPARE, VALUE_TYPE } from './ps5debug.ts';
import { COMPARE, PROT, VALUE_TYPE } from './ps5debug.ts';
import type { MapEntry } from './ps5debug.ts';
import { view } from './bytes.ts';
import { imageRange } from './locations.ts';
import { addressOwner, describeLocation, moduleRange } from './locations.ts';
import type { GameContext, Location } from './locations.ts';
import { scannableRegions } from './scanner.ts';
import { decodeValue, scannableRegions, valueKind } from './scanner.ts';
export interface PointerPath {
location: Location & { kind: 'pointer' };
@@ -27,6 +34,18 @@ export interface PointerScanOptions {
fanout?: number;
/** Stop once this many paths are found. */
maxResults?: number;
/**
* Objects this close together in memory are looked for in one pass over
* memory instead of one pass each (default 16 MB). A pass takes over a
* 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 {
@@ -36,6 +55,13 @@ interface Candidate {
offset: bigint;
}
interface Node {
addr: bigint;
tail: string[];
}
const hexOf = (n: bigint) => '0x' + n.toString(16).toUpperCase();
export async function findPointerPaths(
ctx: GameContext,
target: bigint,
@@ -46,53 +72,86 @@ export async function findPointerPaths(
const maxOffset = BigInt(opts.maxOffset ?? 0x1000);
const fanout = opts.fanout ?? 6;
const maxResults = opts.maxResults ?? 20;
const clusterSpan = BigInt(opts.clusterSpan ?? 0x1000000);
const dbg = ctx.scanDbg();
if (!dbg.authed) await dbg.auth();
const image = await imageRange(ctx);
const regions = scannableRegions(await ctx.maps(true));
const results: PointerPath[] = [];
const seen = new Set<bigint>();
/** Everything in writable memory that points into [addr - maxOffset, addr]. */
const pointersTo = async (addr: bigint): Promise<Candidate[]> => {
const lo = addr > maxOffset ? addr - maxOffset : 0n;
const value = new Uint8Array(16);
view(value).setBigUint64(0, lo, true);
view(value).setBigUint64(8, addr, true);
const found: Candidate[] = [];
for (const region of regions) {
await dbg.scanStart(
{ pid: ctx.pid, address: region.start, length: region.length, valueType: VALUE_TYPE.U64, compareType: COMPARE.BETWEEN, alignment: 8, value },
(block) => {
const dv = view(block.records);
// A generous cap; the list is sorted by offset afterwards, so the best ones are kept.
for (let i = 0; i < block.count && found.length < 200_000; i++) {
const at = region.start + BigInt(dv.getUint32(i * 12, true));
found.push({ at, offset: addr - dv.getBigUint64(i * 12 + 4, true) });
}
},
);
/**
* For each node, everything in writable memory that points into
* [node - maxOffset, node]. Nodes that sit close together share one pass:
* the console is asked for every pointer into the whole stretch they cover,
* and the hits are handed out to the nodes here.
*/
const pointersTo = async (nodes: Node[], describe: (pass: number, passes: number) => string): Promise<Map<Node, Candidate[]>> => {
const sorted = [...nodes].sort((a, b) => (a.addr < b.addr ? -1 : a.addr > b.addr ? 1 : 0));
const clusters: Node[][] = [];
for (const node of sorted) {
const last = clusters[clusters.length - 1];
if (last && node.addr - last[0].addr <= clusterSpan) last.push(node);
else clusters.push([node]);
}
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;
const value = new Uint8Array(16);
view(value).setBigUint64(0, lo, true);
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) => {
const dv = view(block.records);
// A generous cap; the lists are sorted by offset afterward, so the best ones are kept.
for (let i = 0; i < block.count && kept < 400_000; i++) {
const at = region.start + BigInt(dv.getUint32(i * 12, true));
const points = dv.getBigUint64(i * 12 + 4, true);
for (const node of cluster) {
if (node.addr < points) continue;
if (node.addr - points > maxOffset) break; // nodes are in address order: the rest are further still
found.get(node)!.push({ at, offset: node.addr - points });
kept++;
}
}
},
);
}
}
// Small offsets first: a field near the start of an object is the likelier real path.
return found.sort((a, b) => (a.offset < b.offset ? -1 : a.offset > b.offset ? 1 : 0));
for (const list of found.values()) list.sort((a, b) => (a.offset < b.offset ? -1 : a.offset > b.offset ? 1 : 0));
return found;
};
const hexOf = (n: bigint) => '0x' + n.toString(16).toUpperCase();
// Breadth first, so the shortest paths come out first.
let frontier: { addr: bigint; tail: string[] }[] = [{ addr: target, tail: [] }];
let frontier: Node[] = [{ addr: target, tail: [] }];
for (let depth = 1; depth <= maxDepth && frontier.length && results.length < maxResults; depth++) {
const next: { addr: bigint; tail: string[] }[] = [];
let n = 0;
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) {
onProgress(`Level ${depth} of ${maxDepth}: checking ${++n} of ${frontier.length}`);
const cands = await pointersTo(node.addr);
let followed = 0;
for (const c of cands) {
for (const c of found.get(node) ?? []) {
const tail = [hexOf(c.offset), ...node.tail];
if (c.at >= image.start && c.at < image.end) {
results.push({ location: { kind: 'pointer', base: (c.at - image.start).toString(16).toUpperCase(), offsets: tail }, depth });
// A pointer stored inside a module is the fixed starting point a path needs.
const owner = await addressOwner(ctx, c.at);
if (owner) {
const base = owner.offset.toString(16).toUpperCase();
results.push({
location: owner.module === undefined ? { kind: 'pointer', base, offsets: tail } : { kind: 'pointer', base, offsets: tail, module: owner.module },
depth,
});
if (results.length >= maxResults) break;
} else if (followed < fanout && !seen.has(c.at)) {
seen.add(c.at);
@@ -106,3 +165,129 @@ export async function findPointerPaths(
}
return results;
}
/** What a saved path is expected to lead to, for `recheckPointerPaths`. */
export interface ExpectedValue {
/** A value type id from scanner.ts: 'u8', 'i32', 'float', 'double', ... */
type: string;
/** The exact value, as text or a number ("100", 2.5). Floats match when they are equal to about six digits. */
value?: string | number;
/** Lowest acceptable value (inclusive). */
min?: number;
/** Highest acceptable value (inclusive). */
max?: number;
}
export interface PointerRecheck {
path: PointerPath;
/** The path as people read it, e.g. "[game+0x10100]+0x340". */
text: string;
/** Where the path leads now; null when it breaks on the way (a null pointer, or its module is not loaded). */
address: bigint | null;
/** True when it leads to memory the game can actually read. */
readable: boolean;
/** The value there now as text, in the expected type; null without `expected` or when unreadable. */
value: string | null;
/** Whether that value is the expected one; null when nothing was expected. */
matches: boolean | null;
/** Why the path could not be followed, when it could not. */
problem?: string;
}
function inReadableMap(maps: MapEntry[], address: bigint, length: number): boolean {
return maps.some((m) => m.prot & PROT.READ && address >= m.start && address + BigInt(length) <= m.end);
}
/**
* Follows each saved path as the game is now and says which still lead
* somewhere readable and, when `expected` is given, which lead to a matching
* value. Meant for after a game restart: paths that pass again are the
* keepers.
*
* Results come back in the order given. Reads are shared between paths that
* start the same way, so checking hundreds of candidates costs far fewer
* round trips than resolving each alone.
*/
export async function recheckPointerPaths(ctx: GameContext, paths: PointerPath[], expected?: ExpectedValue): Promise<PointerRecheck[]> {
const maps = await ctx.maps(true);
const kind = expected ? valueKind(expected.type) : null;
const pointers = new Map<bigint, bigint | null>();
const bases = new Map<string, bigint | null>();
/** The pointer stored at an address, or null when nothing readable is there. */
const pointerAt = async (address: bigint): Promise<bigint | null> => {
const cached = pointers.get(address);
if (cached !== undefined) return cached;
let value: bigint | null = null;
if (inReadableMap(maps, address, 8)) value = view(await ctx.dbg().read(ctx.pid, address, 8)).getBigUint64(0, true);
pointers.set(address, value);
return value;
};
const out: PointerRecheck[] = [];
for (const path of paths) {
const loc = path.location;
const res: PointerRecheck = { path, text: describeLocation(loc), address: null, readable: false, value: null, matches: expected ? false : null };
out.push(res);
const key = loc.module ?? '';
let base = bases.get(key);
if (base === undefined) {
base = (await moduleRange(ctx, loc.module))?.start ?? null;
bases.set(key, base);
}
if (base === null) {
res.problem = `The module "${loc.module}" is not loaded.`;
continue;
}
if (loc.offsets.length === 0) {
res.problem = 'The path has no offsets.';
continue;
}
let addr: bigint | null = base + BigInt('0x' + (loc.base.replace(/^0x/i, '') || '0'));
for (const off of loc.offsets) {
const ptr: bigint | null = await pointerAt(addr);
if (ptr === null || ptr < 0x10000n) {
res.problem = ptr === null ? 'A step of the path is in memory that no longer exists.' : 'A pointer on the path is empty.';
addr = null;
break;
}
const t = off.trim();
addr = ptr + (t.startsWith('-') ? -BigInt('0x' + t.slice(1).replace(/^0x/i, '')) : BigInt('0x' + t.replace(/^0x/i, '')));
}
if (addr === null) continue;
res.address = addr;
const size = kind ? kind.size : 1;
res.readable = inReadableMap(maps, addr, size);
if (!res.readable) {
res.problem = 'The path leads to memory the game is not using.';
continue;
}
if (!kind || !expected) continue;
const bytes = await ctx.dbg().read(ctx.pid, addr, kind.size);
res.value = decodeValue(kind, bytes);
const n = Number(res.value);
let ok = true;
if (expected.value !== undefined) {
const want = Number(expected.value);
// Whole numbers must be exact; floats are compared the way the scanner does, to about six digits.
ok = kind.float ? n === want || Math.abs(n - want) <= Math.max(Math.abs(n), Math.abs(want)) * 1e-6 : res.value === String(expected.value).trim() || n === want;
}
if (expected.min !== undefined && !(n >= expected.min)) ok = false;
if (expected.max !== undefined && !(n <= expected.max)) ok = false;
res.matches = ok;
}
return out;
}
/**
* Of re-checked candidates, the ones that lead exactly to `address`: where
* the value has been found again after a restart. Stricter than matching the
* value alone, which a path can do by landing on some other 100.
*/
export function pathsLeadingTo(rechecks: PointerRecheck[], address: bigint): PointerPath[] {
return rechecks.filter((r) => r.address === address).map((r) => r.path);
}
+73
View File
@@ -0,0 +1,73 @@
// Candidate pointer paths, kept in the browser per game between restarts.
//
// A pointer search gives candidates, and most of them only work by accident.
// The reliable way to tell is the two-run method: keep the candidates,
// restart the game, find the value again (it has moved), and see which
// candidates lead to its new address. This is where they wait in between.
import { loadJson, removeKey, saveJson } from './storage.ts';
import type { PointerPath } from './pointerscan.ts';
export interface SavedPaths {
id: string;
/** What the user is tracking, in their words or a default ("Value 100 at 0x880012340"). */
name: string;
/** Value type id from scanner.ts. */
type: string;
/** When the candidates were found (ms). */
foundAt: number;
/** The game process they were found in or last checked against. A different pid now means the game has been restarted since. */
pid: number;
/** How many restarts the remaining candidates have survived. */
checks: number;
paths: PointerPath[];
}
/** Candidate sets kept per game, and paths per set; old sets fall off the end. */
export const MAX_SAVED_SETS = 8;
export const MAX_SAVED_PATHS = 200;
const key = (titleId: string) => `pointers.${titleId}`;
function isPath(p: unknown): p is PointerPath {
const loc = (p as PointerPath | null)?.location;
return !!loc && loc.kind === 'pointer' && typeof loc.base === 'string' && Array.isArray(loc.offsets) && loc.offsets.every((o) => typeof o === 'string');
}
/** The saved candidate sets for a game, newest first. Anything unreadable in storage is dropped rather than trusted. */
export function loadSavedPaths(titleId: string): SavedPaths[] {
const raw = loadJson<unknown>(key(titleId), []);
if (!Array.isArray(raw)) return [];
const out: SavedPaths[] = [];
for (const s of raw as Partial<SavedPaths>[]) {
if (!s || typeof s.id !== 'string' || !Array.isArray(s.paths)) continue;
const paths = s.paths.filter(isPath);
if (paths.length === 0) continue;
out.push({
id: s.id,
name: typeof s.name === 'string' ? s.name : '',
type: typeof s.type === 'string' ? s.type : 'u32',
foundAt: Number(s.foundAt) || 0,
pid: Number(s.pid) || 0,
checks: Number(s.checks) || 0,
paths,
});
}
return out;
}
/**
* Adds a set, or replaces the one with the same id, and puts it first.
* Returns false when the browser would not store it.
*/
export function savePaths(titleId: string, set: SavedPaths): boolean {
const rest = loadSavedPaths(titleId).filter((s) => s.id !== set.id);
const next = [{ ...set, paths: set.paths.slice(0, MAX_SAVED_PATHS) }, ...rest].slice(0, MAX_SAVED_SETS);
return saveJson(key(titleId), next);
}
export function forgetPaths(titleId: string, id: string): void {
const rest = loadSavedPaths(titleId).filter((s) => s.id !== id);
if (rest.length === 0) removeKey(key(titleId));
else saveJson(key(titleId), rest);
}
+538
View File
@@ -0,0 +1,538 @@
// Goals and "Show me" for practice steps.
//
// A lesson says what the learner should achieve as data (a Goal, see
// learn.ts). GoalTracker watches the practice game and the learner's cheats
// and says when each part of a goal has been reached. showMe reaches one part
// the way the learner would, with the same library the tools use (the
// scanner, the tracer, the table), so "Show me" is a demonstration and not a
// shortcut around the game. The tests run showMe over every step of every
// lesson, which is what proves each goal can be reached.
import { fieldSize, goalParts } 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';
import { findPointerPaths } from '../pointerscan.ts';
import type { Ps5Debug } from '../ps5debug.ts';
import { ScanSession, SnapshotScan, compareKind, encodeValue, scannableRegions, valueKind } from '../scanner.ts';
import type { Scan } from '../scanner.ts';
import { resolveSite } from '../table.ts';
import type { Entry, PatchEntry, ValueEntry } from '../table.ts';
import { Tracer } from '../tracer.ts';
import type { ByteStream } from '../transport.ts';
import type { Insn } from '../x86.ts';
import type { PracticeConsole } from './console.ts';
export interface Cheat {
entry: Entry;
on: boolean;
}
/** What goal checking needs to see. `ctx` is read afresh each time: it is replaced when the game restarts. */
export interface Bench {
con: PracticeConsole;
readonly ctx: GameContext;
/** "Your cheats", and whether each is switched on. */
cheats(): Cheat[];
}
/** What "Show me" needs on top of that: the learner's own tools. */
export interface CoachBench extends Bench {
openDebugger(): Promise<Ps5Debug>;
openEvents(): Promise<ByteStream>;
/** Takes the one debugger attach for the coach, or throws when something else holds it. Returns the function that gives it back. */
claimDebugger?(): () => void;
add(entry: Entry, opts?: { enable?: boolean }): Promise<void>;
replace(entry: Entry): Promise<void>;
toggle(entry: Entry, on: boolean): Promise<void>;
/** A write by hand, which "Restore everything" can take back. */
write(address: bigint, bytes: Uint8Array): Promise<void>;
/** "Restore everything", when there is something to restore. */
restore?(): Promise<void>;
/** One line telling the learner what was just done. */
say(line: string): void;
}
/** How long after an action a held value is looked at: longer than one round of the freeze timers (250 ms). */
export const SETTLE_MS = 600;
const KIND: Record<FieldType, string> = { u8: 'u8', u16: 'u16', u32: 'u32', float: 'float', double: 'double', pointer: 'u64' };
const AUTO_NAME = /^(Value at 0x|Skip ")/;
const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
/** Shows a game value the way a person would type it. */
function shown(n: number): string {
return String(Math.round(n * 1000) / 1000);
}
/** The cheat in "Your cheats" that a `saved` goal is about, with every condition of the goal met, or null. */
async function cheatFor(bench: Bench, goal: Extract<LeafGoal, { check: 'saved' }>, strict = true): Promise<Cheat | null> {
const { game } = bench.con;
for (const cheat of bench.cheats()) {
const e = cheat.entry;
if (e.type !== goal.type) continue;
if (strict && goal.named && (AUTO_NAME.test(e.name) || !e.name.trim())) continue;
if (strict && goal.grouped && !e.group) continue;
try {
if (e.type === 'value') {
if (goal.location === 'pointer' && e.at.kind !== 'pointer') continue;
if ((await resolveLocation(bench.ctx, e.at)) === game.address(goal.field!)) return cheat;
} else if (e.type === 'patch') {
const range = game.siteRange(game.siteOf(goal.action!));
const { address } = await resolveSite(bench.ctx, e);
if (address >= range.start && address < range.end) return cheat;
}
} catch {
// A cheat that leads nowhere in this game is not the one.
}
}
return null;
}
export interface GoalItem {
goal: LeafGoal;
done: boolean;
}
/**
* Follows one goal. The parts of an "all" goal count in order: a part is only
* looked at once the ones before it are done, and what it counts (restarts,
* uses of an action) is counted from that moment. A part that is done stays
* done.
*/
export class GoalTracker {
readonly items: GoalItem[];
/** Called when a part is newly done, or progress toward one may have changed. */
onChange: (() => void) | null = null;
private bench: Bench;
private settleMs: number;
private index = 0;
private unsubscribe: () => void;
private timers = new Set<ReturnType<typeof setTimeout>>();
private looking: Promise<boolean> = Promise.resolve(false);
// Counted from the moment the current part became current:
private since = { restarts: 0, updates: 0, presses: 0 };
/** For held and steady: the value being compared against, and how many uses in a row left it alone. */
private held = { baseline: 0, count: 0 };
constructor(goal: Goal, bench: Bench, opts: { settleMs?: number } = {}) {
this.items = goalParts(goal).map((g) => ({ goal: g, done: false }));
this.bench = bench;
this.settleMs = opts.settleMs ?? SETTLE_MS;
this.activate();
this.unsubscribe = bench.con.subscribe((e) => {
const g = this.current?.goal;
if (!g) return;
if (e.type === 'restart' || e.type === 'update') {
// A held streak belongs to the game that just closed.
this.held = { baseline: this.readField(g), count: 0 };
}
if (e.type === 'press' && (g.check === 'held' || g.check === 'steady') && e.action === this.heldAction(g)) {
// Give whatever is holding the value time to put it back, then look.
const timer = setTimeout(() => {
this.timers.delete(timer);
if (this.current?.goal !== g) return;
const now = this.readField(g);
if (now === this.held.baseline) this.held.count++;
else this.held = { baseline: now, count: 0 };
void this.check();
}, this.settleMs);
this.timers.add(timer);
}
void this.check();
});
}
get current(): GoalItem | null {
return this.items[this.index] ?? null;
}
get done(): boolean {
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();
for (const t of this.timers) clearTimeout(t);
this.timers.clear();
}
private readField(g: LeafGoal): number {
return 'field' in g && g.field ? this.bench.con.game.get(g.field) : 0;
}
/** The action whose uses a held or steady goal counts. */
private heldAction(g: LeafGoal): string {
if (g.check === 'held') return g.action;
const field = (g as { field: string }).field;
return this.bench.con.game.def.actions.find((a) => a.every && a.effects.some((e) => e.field === field))?.id ?? '';
}
private activate(): void {
const g = this.current?.goal;
const { con } = this.bench;
this.since = { restarts: con.restarts, updates: con.updates, presses: g && 'action' in g && g.action ? (con.presses.get(g.action) ?? 0) : 0 };
this.held = { baseline: g ? this.readField(g) : 0, count: 0 };
}
private async reached(g: LeafGoal): Promise<boolean> {
const { con } = this.bench;
const { game } = con;
switch (g.check) {
case 'narrowed':
return con.lastScan.size <= g.atMost && con.lastScan.has(game.address(g.field));
case 'value': {
const v = game.get(g.field);
return g.is === 'atLeast' ? v >= g.value : g.is === 'atMost' ? v <= g.value : v === g.value;
}
case 'held':
return this.held.count >= g.times;
case 'steady': {
const every = game.def.actions.find((a) => a.id === this.heldAction(g))?.every ?? 1000;
return this.held.count >= Math.ceil((g.seconds * 1000) / every);
}
case 'pressed':
return (con.presses.get(g.action) ?? 0) - this.since.presses >= g.times;
case 'restarted':
// An update restarts the game too.
return con.restarts + con.updates > this.since.restarts + this.since.updates;
case 'updated':
return con.updates > this.since.updates;
case 'caught':
return con.caught.has(game.address(g.field));
case 'off':
return game.siteState(game.siteOf(g.action)) === 'off';
case 'saved':
return (await cheatFor(this.bench, g)) !== null;
}
}
/** Looks at the game now and moves on past every part that is reached. Returns true when the whole goal is. */
check(): Promise<boolean> {
// One look at a time, in turn: a look can involve reading the game.
this.looking = this.looking.then(() => this.look());
return this.looking;
}
private async look(): Promise<boolean> {
try {
// Between a restart and the tools catching up with it there is nothing sensible to look at.
while (!this.done && this.bench.ctx.pid === this.bench.con.game.pid && !this.bench.con.game.crashed) {
const item = this.current!;
if (!(await this.reached(item.goal))) break;
item.done = true;
this.index++;
this.activate();
this.onChange?.();
}
} catch {
// A check that could not be made is simply not passed yet.
}
return this.done;
}
}
// ---- "Show me" -----------------------------------------------------------------------
/** The button that changes a value: what the learner would press to see it move. */
function actionFor(bench: Bench, field: string): string | null {
const actions = bench.con.game.def.actions.filter((a) => a.effects.some((e) => e.field === field));
return (actions.find((a) => !a.every) ?? actions[0])?.id ?? null;
}
function labelOf(bench: Bench, field: string): string {
return bench.con.game.def.hud.find((h) => h.field === field)?.label.toLowerCase() ?? field.split('.')[1];
}
async function waitFor(cond: () => boolean, ms: number): Promise<boolean> {
for (const end = Date.now() + ms; Date.now() < end; ) {
if (cond()) return true;
await sleep(20);
}
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;
const { game } = con;
const action = actionFor(bench, field);
const release = bench.claimDebugger?.();
const tracer = new Tracer(ctx, { openDebugger: () => bench.openDebugger(), openEvents: () => bench.openEvents() });
let insn: Insn | null = null;
try {
await tracer.start(game.address(field), fieldSize(game.type(field)) === 8 ? 8 : 4, 'write', (hits) => {
insn = hits.find((h) => h.insn && !h.insn.invalid)?.insn ?? insn;
});
if (action && !game.action(action).every) con.press(action);
await waitFor(() => insn !== null, 3000);
} finally {
await tracer.stop();
release?.();
}
if (!insn) throw new Error('Nothing wrote to the value while it was being watched.');
return insn;
}
/** Searches the way the lessons teach until the list is short enough. */
async function narrow(bench: CoachBench, field: string, atMost: number): Promise<void> {
const { con, ctx } = bench;
const { game } = con;
const kind = valueKind(KIND[game.type(field)]);
const label = labelOf(bench, field);
const action = actionFor(bench, field);
const regions = scannableRegions(await ctx.maps(true));
const quiet = () => undefined;
const unknown = game.def.hud.find((h) => h.field === field)?.show === 'bar';
const scan: Scan = unknown ? new SnapshotScan(ctx.scanDbg, game.pid, kind) : new ScanSession(ctx.scanDbg, game.pid, kind);
const count = () => `${con.lastScan.size.toLocaleString('en-US')} ${con.lastScan.size === 1 ? 'match' : 'matches'}`;
const exact = () => encodeValue(kind, shown(game.get(field)));
try {
if (unknown) {
await scan.first(regions, compareKind('unknown'), new Uint8Array(0), quiet);
bench.say(`Searched with "Unknown": ${count()}, which is every value there is.`);
} else {
await scan.first(regions, compareKind('exact'), exact(), quiet);
bench.say(`Searched for ${shown(game.get(field))}: ${count()}.`);
}
for (let round = 0; con.lastScan.size > atMost && round < 12 && action; round++) {
if (unknown && round % 3 === 2) {
await sleep(50);
await scan.next(compareKind('unchanged'), new Uint8Array(0), quiet);
bench.say(`Did nothing, then narrowed down with "Stayed the same": ${count()}.`);
continue;
}
const before = game.get(field);
con.press(action);
const after = game.get(field);
const what = `Pressed "${game.action(action).label}"`;
if (unknown) {
const how = after < before ? 'decreased' : after > before ? 'increased' : 'unchanged';
await scan.next(compareKind(how), new Uint8Array(0), quiet);
bench.say(`${what}, then narrowed down with "${compareKind(how).label}": ${count()}.`);
} else {
await scan.next(compareKind('exact'), exact(), quiet);
bench.say(`${what}, then narrowed down with ${shown(after)}: ${count()}.`);
}
}
} finally {
await scan.end().catch(() => undefined);
}
}
/** A cheat that holds a value where it is, as the padlock in Find does. */
async function hold(bench: CoachBench, field: string): Promise<void> {
const { game } = bench.con;
const address = game.address(field);
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)) !== address) continue;
if (!c.on) await bench.toggle(c.entry, true);
return;
}
const entry = await valueEntry(bench.ctx, address, valueKind(KIND[game.type(field)]), shown(game.get(field)));
entry.name = `Hold my ${labelOf(bench, field)}`;
await bench.add(entry);
bench.say(`Found the ${labelOf(bench, field)} and froze it at ${entry.value}. The cheat is under Your cheats.`);
}
/** Switches off the code behind an action's first effect, making the cheat first if there is none. */
async function switchOff(bench: CoachBench, action: string, opts: { named?: boolean; grouped?: boolean } = {}): Promise<void> {
const { game } = bench.con;
const def = game.action(action);
const field = def.effects[0].field;
const tidy = (e: PatchEntry): PatchEntry => ({
...e,
name: opts.named && AUTO_NAME.test(e.name) ? `No ${labelOf(bench, field)} lost` : e.name,
group: opts.grouped && !e.group ? 'Practice' : e.group,
});
const have = await cheatFor(bench, { check: 'saved', label: '', type: 'patch', action }, false);
if (have) {
const entry = tidy(have.entry as PatchEntry);
if (entry.name !== have.entry.name || entry.group !== have.entry.group) await bench.replace(entry);
if (game.siteState(game.siteOf(action)) !== 'off') {
await bench.toggle(entry, true);
bench.say(`Switched "${entry.name}" on in Your cheats.`);
}
return;
}
const insn = await findWriter(bench, field);
bench.say(`Watched the ${labelOf(bench, field)} and pressed "${def.label}". The instruction that wrote to it: ${insn.text}`);
await bench.add(tidy(await nopEntry(bench.ctx, insn)));
bench.say('Replaced that instruction with no-ops, and saved the change as a cheat under Your cheats.');
}
/**
* Reaches one part of a goal the way the learner would. May return before
* the goal's tracker has seen the result; the caller waits for that.
*/
export async function showMe(goal: LeafGoal, bench: CoachBench, opts: { settleMs?: number } = {}): Promise<void> {
const { con } = bench;
const { game } = con;
const settle = (opts.settleMs ?? SETTLE_MS) + 80;
switch (goal.check) {
case 'narrowed':
return narrow(bench, goal.field, goal.atMost);
case 'value': {
const reached = () => (goal.is === 'atMost' ? game.get(goal.field) <= goal.value : goal.is === 'atLeast' ? game.get(goal.field) >= goal.value : game.get(goal.field) === goal.value);
if (goal.is === 'atMost') {
// Lower it the way the game does, if the game has a way; else take back what was changed by hand.
const lower = game.def.actions.find((a) => !a.every && a.effects.some((e) => e.field === goal.field && e.op === 'sub' && e.refill === undefined));
for (let i = 0; lower && !reached() && i < 80; i++) con.press(lower.id);
if (lower && reached()) return bench.say(`Pressed "${lower.label}" until the ${labelOf(bench, goal.field)} was down to ${shown(game.get(goal.field))}.`);
if (bench.restore) {
await bench.restore();
if (reached()) return bench.say('Pressed "Restore everything": each value changed by hand is back to what the game had.');
}
}
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.`);
}
case 'held':
case 'steady': {
const action = goal.check === 'held' ? game.action(goal.action) : game.def.actions.find((a) => a.every && a.effects.some((e) => e.field === goal.field))!;
// Nothing to hold when the code that changes it is already switched off.
if (game.siteState(game.siteOf(action.id, goal.field)) !== 'off') await hold(bench, goal.field);
if (goal.check === 'steady') return bench.say('Now the clock is rewritten four times a second with the number it had.');
for (let i = 0; i <= goal.times; i++) {
con.press(action.id);
await sleep(settle);
}
return bench.say(`Pressed "${action.label}" ${goal.times + 1} times. The ${labelOf(bench, goal.field)} is still ${shown(game.get(goal.field))}.`);
}
case 'pressed':
for (let i = 0; i < goal.times; i++) {
con.press(goal.action);
await sleep(60);
}
return bench.say(`Pressed "${game.action(goal.action).label}"${goal.times > 1 ? ` ${goal.times} times` : ''}.`);
case 'restarted':
case 'updated': {
if (goal.check === 'updated') con.update();
else con.restart();
// The tools reconnect to the new game by themselves; wait until they have.
await waitFor(() => bench.ctx.pid === game.pid, 5000);
return bench.say(goal.check === 'updated' ? `Installed the update. The game is now version ${game.version}, and its code has moved.` : 'Restarted the game. Everything it keeps in memory is somewhere new.');
}
case 'caught': {
const insn = await findWriter(bench, goal.field);
return bench.say(`Watched the ${labelOf(bench, goal.field)} while the game changed it. The instruction that wrote to it: ${insn.text}`);
}
case 'off':
return switchOff(bench, goal.action);
case 'saved': {
if (goal.type === 'patch') return switchOff(bench, goal.action!, goal);
const field = goal.field!;
const address = game.address(field);
let at: Location | undefined;
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.');
// 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);
const entry: ValueEntry = {
...base,
name: goal.named && AUTO_NAME.test(base.name) ? `Infinite ${labelOf(bench, field)}` : base.name,
group: goal.grouped && !base.group ? 'Practice' : base.group,
};
if (have) await bench.replace(entry);
else await bench.add(entry, { enable: false });
return bench.say(have ? `Changed the cheat: it is now called "${entry.name}"${entry.group ? `, in the group "${entry.group}"` : ''}.` : `Saved the ${labelOf(bench, field)} to Your cheats as "${entry.name}".`);
}
}
}
/** How long reaching a goal can take once "Show me" has done its part. */
export function patience(goal: LeafGoal, settleMs = SETTLE_MS): number {
return goal.check === 'steady' ? goal.seconds * 1000 + 4000 : settleMs + 3000;
}
+776
View File
@@ -0,0 +1,776 @@
// A pretend console for the practice game: the debugger's wire protocol,
// answered from inside the page.
//
// The real tools talk to a PS5 through byte streams (transport.ts). This file
// hands out streams of the same kind whose other end is the practice game, so
// the scanner, the table runner, the tracer and every screen built on them run
// unchanged, with no console, no network and no bridge. It follows the same
// command formats as mock/mock-ps5.mjs (which follows PROTOCOL.md of
// ps5debug-NG), for the commands the app uses on a game; anything else is
// refused the way a real console refuses it, with an error and a hang-up.
//
// It also keeps the few notes the lessons' goal checks need and the real
// tools have no reason to expose: which addresses the last search left, and
// which values the debugger has reported a writer for.
import { Packer, view } from '../bytes.ts';
import { DEBUG_EVENT_SIZE, STATUS, authKeystream } from '../ps5debug.ts';
import { Session } from '../session.ts';
import { StreamClosedError } from '../transport.ts';
import type { ByteStream } from '../transport.ts';
import { GameCrash } from './game.ts';
import type { GameWrite, PracticeGame } from './game.ts';
const MAGIC = 0xffaabbcc;
const SENTINEL = new Uint8Array(8).fill(0xff);
const VALUE_SIZES = [1, 1, 2, 2, 4, 4, 8, 8, 4, 8];
const LWPID = 100200;
/** Bytes waiting to be read, and at most one reader waiting for them. */
class Inbox {
private chunks: Uint8Array[] = [];
private have = 0;
private waiter: { n: number; resolve: (b: Uint8Array) => void; reject: (e: Error) => void } | null = null;
private ended: string | null = null;
push(data: Uint8Array): void {
if (this.ended !== null || data.length === 0) return;
this.chunks.push(data);
this.have += data.length;
this.pump();
}
read(n: number): Promise<Uint8Array> {
if (this.waiter) return Promise.reject(new Error('Overlapping reads on one stream'));
if (n === 0) return Promise.resolve(new Uint8Array(0));
return new Promise((resolve, reject) => {
this.waiter = { n, resolve, reject };
this.pump();
});
}
end(reason: string): void {
if (this.ended === null) this.ended = reason;
this.pump();
}
private pump(): void {
const w = this.waiter;
if (!w) return;
if (this.have < w.n) {
if (this.ended !== null) {
this.waiter = null;
w.reject(new StreamClosedError(this.ended));
}
return;
}
const out = new Uint8Array(w.n);
let filled = 0;
while (filled < w.n) {
const head = this.chunks[0];
const take = Math.min(head.length, w.n - filled);
out.set(head.subarray(0, take), filled);
filled += take;
if (take === head.length) this.chunks.shift();
else this.chunks[0] = head.subarray(take);
}
this.have -= w.n;
this.waiter = null;
w.resolve(out);
}
}
/** One connection: the tool's end is the ByteStream, the console's end is `toConsole` and `send`. */
class Link implements ByteStream {
onClose: ((reason: string) => void) | null = null;
closed = false;
readonly toConsole = new Inbox();
private toTool = new Inbox();
/** Called once when either side hangs up. */
onEnd: (() => void) | null = null;
read(n: number): Promise<Uint8Array> {
return this.toTool.read(n);
}
write(data: Uint8Array): void {
if (this.closed) throw new StreamClosedError('Connection closed');
this.toConsole.push(data.slice());
}
/** The console's side of write(). */
send(data: Uint8Array): void {
this.toTool.push(data);
}
close(reason = 'Closed'): void {
if (this.closed) return;
this.closed = true;
this.toTool.end(reason);
this.toConsole.end(reason);
const end = this.onEnd;
this.onEnd = null;
end?.();
const cb = this.onClose;
this.onClose = null;
cb?.(reason);
}
}
// ---- comparing values the way the console's scanner does (scan_compare.c) -------
function numberAt(valueType: number, dv: DataView, at: number): number | bigint {
switch (valueType) {
case 0:
return dv.getUint8(at);
case 1:
return dv.getInt8(at);
case 2:
return dv.getUint16(at, true);
case 3:
return dv.getInt16(at, true);
case 4:
return dv.getUint32(at, true);
case 5:
return dv.getInt32(at, true);
case 6:
return dv.getBigUint64(at, true);
case 7:
return dv.getBigInt64(at, true);
case 8:
return dv.getFloat32(at, true);
default:
return dv.getFloat64(at, true);
}
}
function sameBytes(a: DataView, aAt: number, b: DataView, bAt: number, size: number): boolean {
for (let i = 0; i < size; i++) if (a.getUint8(aAt + i) !== b.getUint8(bAt + i)) return false;
return true;
}
function fuzzyEqual(a: number, b: number): boolean {
if (a === b) return true;
const diff = Math.abs(a - b);
const sum = Math.abs(a) + Math.abs(b);
return a === 0 || b === 0 ? diff < 1e-12 : diff / sum < 1e-6;
}
/** `scan`: the value sent with the request. `prev`: the candidate's last value, or the upper bound of a "between". */
function matches(cmp: number, type: number, size: number, scan: DataView | null, mem: DataView, memAt: number, prev: DataView | null, prevAt: number): boolean {
const m = () => numberAt(type, mem, memAt);
const s = () => numberAt(type, scan!, 0);
const p = () => numberAt(type, prev!, prevAt);
if (cmp >= 4 && cmp <= 10 && !prev) return false;
if ([0, 2, 3, 4, 6, 8].includes(cmp) && !scan) return false;
switch (cmp) {
case 0:
return type >= 8 ? fuzzyEqual(s() as number, m() as number) : sameBytes(scan!, 0, mem, memAt, size);
case 2:
return m() > s();
case 3:
return m() < s();
case 4:
return m() >= s() && m() <= p();
case 5:
return m() > p();
case 7:
return m() < p();
case 6:
case 8: {
// Went up (6) or down (8) by exactly the scan value.
const sign = cmp === 6 ? 1 : -1;
if (type >= 8) return fuzzyEqual(m() as number, (p() as number) + sign * (s() as number));
return BigInt.asUintN(size * 8, BigInt(m())) === BigInt.asUintN(size * 8, BigInt(p()) + BigInt(sign) * BigInt(s()));
}
case 9:
return !sameBytes(mem, memAt, prev!, prevAt, size);
case 10:
return sameBytes(mem, memAt, prev!, prevAt, size);
default:
return false;
}
}
const NEEDS_VALUE = new Set([0, 1, 2, 3, 4, 6, 8, 12]);
const NEEDS_PREVIOUS = new Set([5, 6, 7, 8, 9, 10]);
export type PracticeEvent =
/** An action ran (a button, or one the game does by itself). */
| { type: 'press'; action: string }
| { type: 'restart' }
| { type: 'update' }
| { type: 'crash'; reason: string }
/** Something else changed that a screen showing the game may care about. */
| { type: 'change' };
interface Watch {
address: bigint;
size: number;
/** 0 execute, 1 write, 3 read or write. */
kind: number;
}
export class PracticeConsole {
readonly game: PracticeGame;
/** How often each action has run. */
presses = new Map<string, number>();
restarts = 0;
updates = 0;
/** Addresses of values the debugger has reported a write to. */
caught = new Set<bigint>();
private links = new Set<Link>();
private listeners = new Set<(e: PracticeEvent) => void>();
private timers: ReturnType<typeof setInterval>[] = [];
private authed = false;
private shut = false;
/** Search results by (range start, value type), so a pass over several ranges or types adds up. */
private scans = new Map<string, Set<bigint>>();
private scanUnion: Set<bigint> | null = null;
// The one debugging session, as on a console.
private owner: Link | null = null;
private events: Link | null = null;
private waitingEvents: Link[] = [];
private watch: (Watch | null)[] = [null, null, null, null];
private stopped = false;
/** Effects still to run: an action is several, and a watchpoint can stop the game between them. */
private queue: { action: string; index: number }[] = [];
constructor(game: PracticeGame) {
this.game = game;
}
// ---- what the lesson screen uses ---------------------------------------------------
/** Calls back for everything that happens to the game. Returns a function that stops it. */
subscribe(cb: (e: PracticeEvent) => void): () => void {
this.listeners.add(cb);
return () => void this.listeners.delete(cb);
}
private emit(e: PracticeEvent): void {
for (const cb of [...this.listeners]) cb(e);
}
/** Starts the things the game does by itself: its clocks, and values that drift. */
start(): void {
if (this.timers.length) return;
for (const a of this.game.def.actions) if (a.every) this.timers.push(setInterval(() => this.press(a.id), a.every));
if (this.game.def.drifting) this.timers.push(setInterval(() => this.game.drift(), 700));
for (const t of this.timers) (t as { unref?: () => void }).unref?.();
}
/** The player does something. While the debugger has the game stopped, it waits its turn. */
press(action: string): void {
if (this.game.crashed) return;
const def = this.game.action(action);
def.effects.forEach((_, index) => this.queue.push({ action, index }));
this.presses.set(action, (this.presses.get(action) ?? 0) + 1);
this.pump();
this.emit({ type: 'press', action });
}
restart(): void {
this.leaveGame();
this.game.restart();
this.restarts++;
this.emit({ type: 'restart' });
}
update(): void {
this.leaveGame();
this.game.update();
this.updates++;
this.emit({ type: 'update' });
}
/** Everything tied to the process that just went away. */
private leaveGame(): void {
this.queue = [];
this.teardown();
this.scans.clear();
this.scanUnion = null;
this.caught.clear();
}
/** The addresses the most recent search (first search or narrowing) left. */
get lastScan(): Set<bigint> {
if (!this.scanUnion) {
this.scanUnion = new Set();
for (const set of this.scans.values()) for (const a of set) this.scanUnion.add(a);
}
return this.scanUnion;
}
/** Stops the game's timers and hangs up on everything. */
close(): void {
this.shut = true;
for (const t of this.timers) clearInterval(t);
this.timers = [];
for (const link of [...this.links, ...this.waitingEvents]) link.close('The practice game was closed');
this.listeners.clear();
}
// ---- connections -------------------------------------------------------------------
/** Opens a connection, as `openWebSocketStream` does for a real console. A url ending in /events is the stop-report channel. */
open = async (url: string): Promise<ByteStream> => {
if (this.shut) throw new Error('The practice game was closed');
const link = new Link();
if (/\/events(\?|$)/.test(url)) {
// The console connects back here when a debugger attaches; until then it just waits.
this.waitingEvents.push(link);
link.onEnd = () => {
this.waitingEvents = this.waitingEvents.filter((l) => l !== link);
};
return link;
}
this.links.add(link);
link.onEnd = () => {
this.links.delete(link);
if (this.owner === link) this.teardown();
};
void this.serve(link).catch(() => undefined);
return link;
};
private teardown(): void {
const { owner, events } = this;
this.owner = null;
this.events = null;
this.watch = [null, null, null, null];
this.stopped = false;
if (events && !events.closed) events.close('The console ended the debugging session');
// A real console drops the debugger's connection when its game goes away.
if (owner && !owner.closed && owner.onEnd) owner.close('The debugging session ended');
this.pump();
}
// ---- the game loop, with the debugger in it ------------------------------------------
private pump(): void {
while (!this.stopped && this.queue.length) {
const next = this.queue.shift()!;
let wrote: GameWrite | null;
try {
wrote = this.game.run(next.action, next.index);
} catch (e) {
if (!(e instanceof GameCrash)) throw e;
this.queue = [];
this.teardown();
this.emit({ type: 'crash', reason: e.message });
return;
}
if (!wrote || !this.owner) continue;
const w = wrote;
const slot = this.watch.findIndex((wp) => wp && wp.kind !== 0 && w.address < wp.address + BigInt(wp.size) && wp.address < w.address + BigInt(w.size));
if (slot >= 0) this.report(w, slot);
}
}
/** The 1184-byte stop report (PROTOCOL.md 1.9). A data watchpoint fires after the instruction, so RIP is on the next one. */
private report(w: GameWrite, slot: number): void {
this.stopped = true;
this.caught.add(this.watch[slot]!.address);
const pkt = new Uint8Array(DEBUG_EVENT_SIZE);
const dv = view(pkt);
dv.setUint32(0, LWPID, true);
dv.setUint32(4, (5 << 8) | 0x7f, true);
pkt.set(new TextEncoder().encode('MainThread'), 8);
const regs = new Uint8Array(176);
const r = view(regs);
// struct reg offsets: rdi 64, rbp 80, rbx 88, rax 112, rip 136, cs 144, rflags 152, rsp 160, ss 168.
r.setBigUint64(64, w.object, true);
r.setBigUint64(80, 0x7ffc1f40n, true);
r.setBigUint64(88, w.object, true);
r.setBigUint64(112, BigInt(w.tag), true);
r.setBigUint64(136, w.rip, true);
r.setBigUint64(144, 0x43n, true);
r.setBigUint64(152, 0x202n, true);
r.setBigUint64(160, 0x7ffc1f40n, true);
r.setBigUint64(168, 0x3bn, true);
pkt.set(regs, 0x30);
let dr7 = 0x400n;
this.watch.forEach((wp, i) => {
if (!wp) return;
dv.setBigUint64(0x420 + i * 8, wp.address, true);
dr7 |= (3n << BigInt(i * 2)) | (BigInt(([0, 1, 3, 2][[1, 2, 4, 8].indexOf(wp.size)] << 2) | wp.kind) << BigInt(16 + i * 4));
});
dv.setBigUint64(0x420 + 48, 0xffff0ff0n | (1n << BigInt(slot)), true);
dv.setBigUint64(0x420 + 56, dr7, true);
this.events?.send(pkt);
}
// ---- the protocol ----------------------------------------------------------------------
/**
* Keeps the result of one search request. A search (a first one, or a
* narrowing) arrives as several requests, one per range and value type,
* and they add up; `begins` says this request starts a new one, which
* replaces everything before it.
*/
private noteScan(begins: boolean, key: string, hits: Set<bigint>): void {
if (begins) this.scans.clear();
this.scans.set(key, hits);
this.scanUnion = null;
}
private async serve(link: Link): Promise<void> {
const game = this.game;
const inbox = link.toConsole;
const status = (s: number) => link.send(new Packer().u32(s).done());
const ok = () => status(STATUS.SUCCESS);
// A handler that fails early ends the connection, as on the console.
const fatal = (s: number = STATUS.ERROR) => {
status(s);
link.close('The console closed the connection');
};
const u32 = async () => view(await inbox.read(4)).getUint32(0, true);
const text = (s: string, size: number) => {
const out = new Uint8Array(size);
out.set(new TextEncoder().encode(s).subarray(0, size - 1));
return out;
};
// Telling one search from the next: requests of the same search are of one
// kind, follow each other at once, and never repeat a range.
let run = '';
let ranAt = 0;
const begins = (kind: string, key: string): boolean => {
const fresh = run !== kind || this.scans.has(key) || Date.now() - ranAt > 100;
run = kind;
return fresh;
};
/** A snapshot scan held for this connection: address and last value of every candidate. */
let turbo: { type: number; size: number; at: bigint[]; values: Uint8Array } | null = null;
for (;;) {
const hdr = view(await inbox.read(12));
if (hdr.getUint32(0, true) !== MAGIC) continue;
const cmd = hdr.getUint32(4, true);
const len = hdr.getUint32(8, true);
if (len > 0x100000) {
fatal();
return;
}
const body = len ? await inbox.read(len) : new Uint8Array(0);
const d = view(body);
// Every command on a process names it first; the practice game is the only one there is.
const wrongPid = () => len < 4 || d.getUint32(0, true) !== game.pid || game.crashed !== null;
if (cmd !== 0xbdaacc01 && cmd !== 0xbdaacc02) run = '';
switch (cmd) {
case 0xbd000001: // VERSION
link.send(new Packer().u32(3).bytes(new TextEncoder().encode('1.3')).done());
break;
case 0xbd000500: // FW_VERSION: none, it is not a console
link.send(new Packer().u16(0).done());
break;
case 0xbd000501: {
// BRANDING: a name, a NUL, then the capability level that switches on the ps5debug-NG extras.
const s = new TextEncoder().encode('CheatSpecter practice game\0' + '1.1');
link.send(new Packer().u32(s.length).bytes(s).done());
break;
}
case 0xbdaacc06: // NOP
case 0xbdaa0008: // PROC_PROTECT
ok();
break;
case 0xbddd0006: {
// CONSOLE_FOREGROUND_APP
ok();
const p = new Packer();
if (game.crashed) p.bytes(new Uint8Array(140));
else p.u32(game.pid).bytes(text(game.titleId, 16)).bytes(text(game.contentId, 64)).bytes(text('eboot.bin', 40)).bytes(text(game.version, 16));
link.send(p.done());
break;
}
case 0xbdaa0004: {
// PROC_MAPS
if (wrongPid()) return fatal();
ok();
const p = new Packer().u32(game.regions.length);
for (const r of game.regions) p.bytes(text(r.name, 32)).u64(r.start).u64(r.start + BigInt(r.bytes.length)).u64(0n).u16(r.prot);
link.send(p.done());
break;
}
case 0xbdaa0002: // PROC_READ
if (wrongPid()) return fatal();
ok();
link.send(game.read(d.getBigUint64(4, true), d.getUint32(12, true)));
break;
case 0xbdaa0003: {
// PROC_WRITE: two status words
if (wrongPid()) return fatal();
ok();
const data = await inbox.read(d.getUint32(12, true));
status(game.write(d.getBigUint64(4, true), data) ? STATUS.SUCCESS : STATUS.ERROR);
this.emit({ type: 'change' });
break;
}
case 0xbdaacc04: {
// PROC_WRITE_MULTI
if (wrongPid()) return fatal();
ok();
let failed = false;
for (let i = d.getUint32(4, true); i > 0; i--) {
const h = view(await inbox.read(12));
if (!game.write(h.getBigUint64(0, true), await inbox.read(h.getUint32(8, true)))) failed = true;
}
status(failed ? STATUS.ERROR : STATUS.SUCCESS);
this.emit({ type: 'change' });
break;
}
case 0xbdaaccff: {
// PROC_AUTH: the same challenge and answer as the console's
if (len < 8 || d.getUint32(0, true) !== 0xbb40e64d) return fatal(STATUS.DATA_NULL);
ok();
const challenge = new Uint8Array(64);
for (let i = 0; i < 64; i++) challenge[i] = (i * 73 + 41) & 0xff;
link.send(new Packer().u16(64).bytes(challenge).done());
const answer = await inbox.read(64);
const key = authKeystream();
if (answer.some((b, i) => b !== (challenge[i] ^ key[i]))) return fatal(STATUS.DATA_NULL);
this.authed = true;
ok();
break;
}
case 0xbdaacc01: {
// PROC_SCAN_START
if (!this.authed || wrongPid()) return fatal(STATUS.DATA_NULL);
const address = d.getBigUint64(4, true);
const length = d.getUint32(12, true);
const type = body[16];
const cmp = body[17];
const lenData = d.getUint32(19, true);
const size = VALUE_SIZES[type];
if (!size || cmp > 12 || lenData === 0) return fatal(STATUS.DATA_NULL);
ok();
const value = NEEDS_VALUE.has(cmp) ? view(await inbox.read(lenData)) : null;
ok();
const step = body[18] || size;
const mem = view(game.read(address, length));
const hits = new Set<bigint>();
const out = new Packer();
let count = 0;
for (let j = 0; j + size <= length; j += step) {
if (!matches(cmp, type, size, value, mem, j, cmp === 4 ? value : null, size)) continue;
out.u32(j).bytes(new Uint8Array(mem.buffer, j, size));
hits.add(address + BigInt(j));
count++;
}
if (count) link.send(new Packer().u64(BigInt(count * (4 + size))).bytes(out.done()).done());
link.send(SENTINEL);
ok();
// A search for pointers (8-byte "between") is the pointer finder at work, not the learner's list.
if (!(type === 6 && cmp === 4)) this.noteScan(begins('first', `${address}/${type}`), `${address}/${type}`, hits);
ranAt = Date.now();
break;
}
case 0xbdaacc02: {
// PROC_SCAN_COUNT
if (!this.authed || wrongPid()) return fatal(STATUS.DATA_NULL);
const base = d.getBigUint64(4, true);
const type = body[12];
const cmp = body[13];
const lenData = d.getUint32(14, true);
const size = VALUE_SIZES[type];
if (!size || cmp > 12) return fatal(STATUS.DATA_NULL);
ok();
const value = NEEDS_VALUE.has(cmp) && lenData ? view(await inbox.read(lenData)) : null;
const withPrev = NEEDS_PREVIOUS.has(cmp);
const entry = withPrev ? 4 + size : 4;
const hits = new Set<bigint>();
for (;;) {
const chunkLen = await u32();
if (chunkLen === 0xffffffff) break;
if (chunkLen > 0x40000) return fatal();
const chunk = view(await inbox.read(chunkLen));
const out = new Packer();
let count = 0;
for (let off = 0; off + entry <= chunkLen; off += entry) {
const at = chunk.getUint32(off, true);
const mem = game.read(base + BigInt(at), size);
const prev = withPrev ? chunk : cmp === 4 ? value : null;
if (!matches(cmp, type, size, value, view(mem), 0, prev, withPrev ? off + 4 : size)) continue;
out.u32(at).bytes(mem);
hits.add(base + BigInt(at));
count++;
}
if (count) link.send(new Packer().u64(BigInt(count * (4 + size))).bytes(out.done()).done());
link.send(SENTINEL);
}
ok();
this.noteScan(begins('next', `${base}/${type}`), `${base}/${type}`, hits);
ranAt = Date.now();
break;
}
case 0xbdaacc03: {
// PROC_SCAN_GET (bulk read): the pieces, then the marker, no status at the end
if (!this.authed || wrongPid()) return fatal(STATUS.DATA_NULL);
const count = d.getUint32(4, true);
ok();
const entries = view(await inbox.read(count * 12));
for (let i = 0; i < count; i++) link.send(game.read(entries.getBigUint64(i * 12, true), entries.getUint32(i * 12 + 8, true)));
link.send(SENTINEL);
break;
}
case 0xbdaa0501: {
// PROC_SCAN_AOB
if (wrongPid()) return fatal(STATUS.DATA_NULL);
const address = d.getBigUint64(4, true);
const length = d.getUint32(12, true);
const nth = body[16];
const plen = d.getUint32(18, true);
ok();
const pattern = await inbox.read(plen);
const mask = await inbox.read(plen);
ok();
const mem = game.read(address, length);
let found = 0n;
for (let i = 0, seen = 0; i + plen <= length && found === 0n; i++) {
let same = true;
for (let j = 0; j < plen && same; j++) if (mask[j] === 1 && mem[i + j] !== pattern[j]) same = false;
if (same && ++seen === nth) found = address + BigInt(i);
}
link.send(new Packer().u64(found).done());
ok();
break;
}
case 0xbdaacc10: // TURBOSCAN_CAPS: the snapshot engine, which "Unknown" searches need
ok();
link.send(new Packer().u32(1).u32(0x04 | 0x08 | 0x10).u32(1).u32(0).done());
break;
case 0xbdaacc11: {
// TURBOSCAN_START: a snapshot of every value in the listed ranges
if (!this.authed || wrongPid()) return fatal(STATUS.DATA_NULL);
const type = body[16];
const flags = d.getUint32(23, true);
const size = VALUE_SIZES[type];
if (!size || !(flags & 4) || !(flags & 0x10)) return fatal(STATUS.DATA_NULL);
ok();
ok();
const segs = view(await inbox.read((await u32()) * 12));
const at: bigint[] = [];
const parts: Uint8Array[] = [];
let total = 0;
for (let i = 0; i < segs.byteLength; i += 12) {
const address = segs.getBigUint64(i, true);
const length = Math.floor(segs.getUint32(i + 8, true) / size) * size;
total += length;
parts.push(game.read(address, length));
for (let j = 0; j < length; j += size) at.push(address + BigInt(j));
}
const values = new Uint8Array(total);
parts.reduce((o, p) => (values.set(p, o), o + p.length), 0);
turbo = { type, size, at, values };
link.send(new Packer().u64(BigInt(at.length)).u64(BigInt(total)).u64(BigInt(total)).bytes(SENTINEL).u32(1).u64(BigInt(at.length)).done());
ok();
this.noteScan(true, 'snapshot', new Set(at));
break;
}
case 0xbdaacc12: {
// TURBOSCAN_COUNT: narrow the snapshot held here
if (!this.authed || wrongPid()) return fatal(STATUS.DATA_NULL);
const type = body[12];
const cmp = body[13];
const lenData = d.getUint32(14, true);
if (cmp > 12) return fatal(STATUS.DATA_NULL);
ok();
const value = NEEDS_VALUE.has(cmp) && lenData ? view(await inbox.read(lenData)) : null;
if (turbo && turbo.type === type) {
const size: number = turbo.size;
const old = view(turbo.values);
const at: bigint[] = [];
const kept: Uint8Array[] = [];
turbo.at.forEach((address, i) => {
const mem = game.read(address, size);
const prev = NEEDS_PREVIOUS.has(cmp) ? old : cmp === 4 ? value : null;
if (!matches(cmp, type, size, value, view(mem), 0, prev, NEEDS_PREVIOUS.has(cmp) ? i * size : size)) return;
at.push(address);
kept.push(mem);
});
const values = new Uint8Array(at.length * size);
kept.forEach((m, i) => values.set(m, i * size));
turbo = { type, size, at, values };
}
link.send(new Packer().bytes(SENTINEL).u64(BigInt(turbo?.at.length ?? 0)).done());
ok();
this.noteScan(true, 'snapshot', new Set(turbo?.at ?? []));
break;
}
case 0xbdaacc13: {
// TURBOSCAN_GET
if (!this.authed) return fatal(STATUS.DATA_NULL);
const start = d.getUint32(0, true);
const n = turbo ? Math.max(0, Math.min(d.getUint32(4, true), turbo.at.length - start)) : 0;
ok();
const p = new Packer().u32(n);
for (let i = start; i < start + n; i++) {
const v = turbo!.values.subarray(i * turbo!.size, (i + 1) * turbo!.size);
p.u64(turbo!.at[i]).bytes(v).bytes(v);
}
link.send(p.done());
ok();
break;
}
case 0xbdaacc14: // TURBOSCAN_END
turbo = null;
ok();
break;
// ---- the debugger: attach, one kind of stop (a data watchpoint), let go ----
case 0xbdbb0001: {
// DEBUG_ATTACH
if (this.owner) return fatal(STATUS.ALREADY_DEBUG);
const events = this.waitingEvents.pop();
if (wrongPid() || !events) return fatal();
events.onEnd = null;
this.owner = link;
this.events = events;
this.stopped = false;
ok();
break;
}
case 0xbdbb0002: // DEBUG_DETACH
if (this.owner === link) {
this.owner = null; // so letting go does not hang up on the one who asked
const events = this.events;
this.events = null;
this.watch = [null, null, null, null];
this.stopped = false;
events?.close('The console ended the debugging session');
this.pump();
this.emit({ type: 'change' });
}
ok();
break;
case 0xbdbb0004: {
// DEBUG_SET_WATCHPOINT
if (this.owner !== link) return fatal();
const index = d.getUint32(0, true);
if (index > 3) return fatal(STATUS.INVALID_INDEX);
this.watch[index] = d.getUint32(4, true) ? { address: d.getBigUint64(16, true), size: [1, 2, 8, 4][d.getUint32(8, true) & 3], kind: d.getUint32(12, true) } : null;
ok();
break;
}
case 0xbdbb0010: // DEBUG_CONTINUE: 0 lets the game run, 1 holds it
if (this.owner !== link) return fatal();
this.stopped = d.getUint32(0, true) === 1;
ok();
this.pump();
this.emit({ type: 'change' });
break;
default:
// Breakpoints, stepping, registers on request, the assembler, calls and allocation: not in the practice game.
return fatal();
}
}
}
}
/** A Session on the practice game: everything the app does with a console works on it. */
export function connectPractice(con: PracticeConsole): Promise<Session> {
return Session.connect({ host: 'practice', port: 0, label: 'Practice game' }, 'practice:/ws', con.open);
}
+490
View File
@@ -0,0 +1,490 @@
// The practice game of the Learn section: a pretend game that lives in the
// page, small enough to search in an instant and real enough that the
// ordinary tools work on it.
//
// It is a block of "memory" laid out like a real process (the game's code,
// its static data, a heap) and a description of what the player can do. Each
// thing an action does to a value is carried out by a few bytes of real x86
// code in the code region, and only happens while those bytes are what the
// game shipped with: overwrite the instruction with no-ops and the effect
// stops; overwrite it with nonsense and the game "crashes". Objects are put
// somewhere new on every start, which is what lessons about restarts,
// pointers and patterns need.
//
// This file knows nothing about the debugger protocol; console.ts serves this
// memory to the real tools.
import { fieldSize } from '../learn.ts';
import type { ActionDef, EffectDef, FieldDef, FieldType, GameDef } from '../learn.ts';
export interface Region {
name: string;
start: bigint;
bytes: Uint8Array;
/** 1 read, 2 write, 4 execute, as the debugger reports them. */
prot: number;
}
/** One instruction of a site, as an offset into its function. */
interface Piece {
at: number;
length: number;
}
/** The game code behind an effect. */
export interface Site {
id: string;
/** Where its function starts, as an offset into the code region. */
offset: number;
/** The function's bytes as shipped. */
bytes: Uint8Array;
/** The instructions that do the work. Each must be intact for the effect to happen, or all no-ops for it to be skipped. */
code: Piece[];
/** The one that writes the value: what "find what writes to this" reports. */
store: Piece;
/** Marks this function among the others, and is in RAX when it runs. */
tag: number;
}
/** A write the game's code just made, for the debugger's watchpoints. */
export interface GameWrite {
address: bigint;
size: number;
/** Address of the instruction after the one that wrote. */
rip: bigint;
/** The object the code was working on (in RBX and RDI). */
object: bigint;
tag: number;
}
const IMAGE_BASE = 0x400000n;
const CODE_SIZE = 0x4000;
const DATA_SIZE = 0x1000;
const HEAP_SIZE = 0x20000;
const FIRST_FUNCTION = 0x1000;
const FUNCTION_STRIDE = 0x40;
/** How far an update pushes the code along. Not a multiple of the stride, so old positions land mid-function. */
const UPDATE_SHIFT = 0x34;
/** A small repeatable random number generator (mulberry32), so a game is the same for a given seed. */
function rng(seed: number): () => number {
let a = seed >>> 0;
return () => {
a = (a + 0x6d2b79f5) >>> 0;
let t = a;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function hash(text: string): number {
let h = 2166136261;
for (let i = 0; i < text.length; i++) h = Math.imul(h ^ text.charCodeAt(i), 16777619);
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':
return dv.getUint8(at);
case 'u16':
return dv.getUint16(at, true);
case 'u32':
return dv.getUint32(at, true);
case 'float':
return dv.getFloat32(at, true);
case 'double':
return dv.getFloat64(at, true);
default:
return Number(dv.getBigUint64(at, true));
}
}
function writeNumber(dv: DataView, at: number, type: FieldType, v: number): void {
switch (type) {
case 'u8':
dv.setUint8(at, v);
break;
case 'u16':
dv.setUint16(at, v, true);
break;
case 'u32':
dv.setUint32(at, v >>> 0, true);
break;
case 'float':
dv.setFloat32(at, v, true);
break;
case 'double':
dv.setFloat64(at, v, true);
break;
default:
dv.setBigUint64(at, BigInt(v), true);
}
}
/** The machine code for one effect: what a compiler would emit for `object->field -= by`. */
function assemble(field: FieldDef, effect: EffectDef, tag: number): { bytes: number[]; code: Piece[]; store: Piece } {
const off = field.offset!;
const near = off < 0x80;
// ModRM for [rbx+off] with the given register/opcode-extension field.
const mem = (reg: number) => (near ? [0x43 | (reg << 3), off] : [0x83 | (reg << 3), off & 0xff, (off >> 8) & 0xff, 0, 0]);
// push rbp; mov rbp, rsp; mov rbx, rdi; mov eax, tag
const bytes = [0x55, 0x48, 0x89, 0xe5, 0x48, 0x89, 0xfb, 0xb8, tag & 0xff, (tag >> 8) & 0xff, (tag >> 16) & 0xff, (tag >>> 24) & 0xff];
const code: Piece[] = [];
const emit = (...insn: number[]): Piece => {
const piece = { at: bytes.length, length: insn.length };
bytes.push(...insn);
code.push(piece);
return piece;
};
let store: Piece;
if (field.type === 'u32') {
// add/sub dword ptr [rbx+off], by
const ext = effect.op === 'sub' ? 5 : 0;
store = effect.by < 0x80 ? emit(0x83, ...mem(ext), effect.by) : emit(0x81, ...mem(ext), effect.by & 0xff, (effect.by >> 8) & 0xff, (effect.by >> 16) & 0xff, (effect.by >>> 24) & 0xff);
} else {
// movss/movsd xmm0, [rbx+off]; subss/addss xmm0, xmm1; movss/movsd [rbx+off], xmm0
const prefix = field.type === 'float' ? 0xf3 : 0xf2;
emit(prefix, 0x0f, 0x10, ...mem(0));
emit(prefix, 0x0f, effect.op === 'sub' ? 0x5c : 0x58, 0xc1);
store = emit(prefix, 0x0f, 0x11, ...mem(0));
}
bytes.push(0x5d, 0xc3); // pop rbp; ret
return { bytes, code, store };
}
interface Placed {
def: FieldDef;
/** The region it is in, and where. */
region: Region;
at: number;
object: string;
}
interface Copy {
of: string;
at: number;
/** Changes it will still follow. */
left: number;
}
/** Thrown into the open when game code that was overwritten with nonsense is run. */
export class GameCrash extends Error {}
export class PracticeGame {
readonly def: GameDef;
readonly titleId: string;
readonly contentId: string;
pid: number;
version = '01.00';
/** How many times the game has been started. */
launch = 0;
/** How many updates are installed. */
updates = 0;
/** Why the game is no longer running, when it crashed. */
crashed: string | null = null;
regions: Region[] = [];
heapBase = 0n;
/** Where each copy of a value sits, as heap offsets (for tests). */
copyAt: number[] = [];
private code!: Region;
private data!: Region;
private heap!: Region;
private fields = new Map<string, Placed>();
private objects = new Map<string, bigint>();
private sites = new Map<string, Site>();
private copies: Copy[] = [];
private drifters: number[] = [];
private random: () => number = Math.random;
private seed: number;
constructor(def: GameDef, titleId: string) {
this.def = def;
this.titleId = titleId;
this.contentId = `UP0000-${titleId}_00-PRACTICEGAME0000`;
this.seed = hash(titleId + def.name);
this.pid = 100 + (this.seed % 400);
this.start();
}
get imageBase(): bigint {
return IMAGE_BASE;
}
// ---- starting ------------------------------------------------------------------
/** Loads the game afresh: code as shipped, values at their start, everything on the heap somewhere new. */
private start(): void {
this.launch++;
this.crashed = null;
const rand = (this.random = rng(this.seed + this.launch * 0x9e3779b9));
const pick = (n: number) => Math.floor(rand() * n);
const { def } = this;
this.heapBase = 0x200000000n + BigInt(pick(0x4000)) * 0x4000n;
this.code = { name: 'executable', start: IMAGE_BASE, bytes: new Uint8Array(CODE_SIZE).fill(0xcc), prot: 5 };
this.data = { name: 'executable', start: IMAGE_BASE + BigInt(CODE_SIZE), bytes: new Uint8Array(DATA_SIZE), prot: 3 };
this.heap = { name: '', start: this.heapBase, bytes: new Uint8Array(HEAP_SIZE), prot: 3 };
this.regions = [this.code, this.data, this.heap];
const heap = new DataView(this.heap.bytes.buffer);
// Ordinary clutter: a real heap is full of other things' numbers.
for (let i = 0; i < 1800; i++) heap.setUint32(pick(HEAP_SIZE / 4) * 4, 1 + pick(i % 3 === 0 ? 60 : 4000), true);
for (let i = 0; i < 300; i++) heap.setFloat32(pick(HEAP_SIZE / 4) * 4, Math.round(rand() * 4000) / 4, true);
// Places taken, in 64-byte slots, so nothing lands on top of anything else.
const taken = new Set<number>();
const place = (bytes: number): number => {
const slots = Math.ceil(bytes / 64);
for (;;) {
const slot = 16 + pick(HEAP_SIZE / 64 - 32 - slots);
let free = true;
for (let i = 0; i < slots; i++) if (taken.has(slot + i)) free = false;
if (!free) continue;
for (let i = 0; i < slots; i++) taken.add(slot + i);
this.heap.bytes.fill(0, slot * 64, (slot + slots) * 64);
return slot * 64;
}
};
this.fields.clear();
this.objects.clear();
let staticAt = 0x40;
for (const o of def.objects) {
const size = Math.max(64, ...o.fields.map((f) => f.offset! + fieldSize(f.type)));
const region = o.static ? this.data : this.heap;
let at: number;
if (o.static) {
at = staticAt;
staticAt += Math.ceil(size / 16) * 16;
} else {
at = place(size);
}
this.objects.set(o.id, region.start + BigInt(at));
for (const f of o.fields) this.fields.set(`${o.id}.${f.id}`, { def: f, region, at: at + f.offset!, object: o.id });
}
for (const p of this.fields.values()) {
const dv = new DataView(p.region.bytes.buffer);
if (p.def.type === 'pointer') dv.setBigUint64(p.at, this.objects.get(p.def.to!)!, true);
else writeNumber(dv, p.at, p.def.type, p.def.value!);
}
const scatter = (type: FieldType, value: number): number => {
const at = place(8) + pick(6) * 8;
writeNumber(heap, at, type, value);
return at;
};
for (const d of def.decoys ?? []) for (let i = 0; i < d.count; i++) scatter(d.type, d.value);
this.copies = [];
for (const c of def.copies ?? []) {
const f = this.fields.get(c.of)!;
for (let i = 0; i < c.count; i++) this.copies.push({ of: c.of, at: scatter(f.def.type, f.def.value!), left: i + 1 });
}
this.copyAt = this.copies.map((c) => c.at);
this.drifters = [];
for (let i = 0; i < (def.drifting ?? 0); i++) this.drifters.push(scatter(i % 2 ? 'float' : 'u32', 50 + pick(900)));
// The code: one small function per site, in the order the actions list them.
this.sites.clear();
let offset = FIRST_FUNCTION + this.updates * UPDATE_SHIFT;
for (const a of def.actions) {
a.effects.forEach((e, i) => {
const id = e.site ?? `${a.id}.${i}`;
if (this.sites.has(id)) return;
const tag = (0x5ec70000 | (hash(id) & 0xffff)) >>> 0;
const built = assemble(this.fields.get(e.field)!.def, e, tag);
const bytes = Uint8Array.from(built.bytes);
this.code.bytes.set(bytes, offset);
this.sites.set(id, { id, offset, bytes, code: built.code, store: built.store, tag });
offset += FUNCTION_STRIDE;
});
}
}
/** Closes the game and starts it again, as a new process. */
restart(): void {
this.pid++;
this.start();
}
/** Installs the game's update, which moves its code, and starts the new version. */
update(): void {
this.updates++;
this.version = `01.${String(this.updates).padStart(2, '0')}`;
this.restart();
}
// ---- looking inside (for the screen, the goal checks and tests) ------------------
private placed(field: string): Placed {
const p = this.fields.get(field);
if (!p) throw new Error(`The practice game has no value called "${field}"`);
return p;
}
/** Where a value is right now. */
address(field: string): bigint {
const p = this.placed(field);
return p.region.start + BigInt(p.at);
}
type(field: string): FieldType {
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);
}
set(field: string, value: number): void {
const p = this.placed(field);
writeNumber(new DataView(p.region.bytes.buffer), p.at, p.def.type, value);
}
action(id: string): ActionDef {
const a = this.def.actions.find((x) => x.id === id);
if (!a) throw new Error(`The practice game has no action called "${id}"`);
return a;
}
private siteFor(action: ActionDef, index: number): Site {
return this.sites.get(action.effects[index].site ?? `${action.id}.${index}`)!;
}
/**
* The code behind an action's first effect: the one a lesson means by "the
* code that spends ammo". With `field`, the code behind its effect on that value.
*/
siteOf(actionId: string, field?: string): Site {
const action = this.action(actionId);
return this.siteFor(action, field ? Math.max(0, action.effects.findIndex((e) => e.field === field)) : 0);
}
/** Address range of a site's working instructions. */
siteRange(site: Site): { start: bigint; end: bigint; store: bigint } {
const first = site.code[0];
const last = site.code[site.code.length - 1];
const base = IMAGE_BASE + BigInt(site.offset);
return { start: base + BigInt(first.at), end: base + BigInt(last.at + last.length), store: base + BigInt(site.store.at) };
}
/**
* intact: as shipped, the effect happens. off: its working instructions are
* replaced by no-ops, so it is skipped. broken: anything else, which a real
* processor would trip over.
*/
siteState(site: Site): 'intact' | 'off' | 'broken' {
const now = this.code.bytes.subarray(site.offset, site.offset + site.bytes.length);
let off = false;
for (let i = 0; i < site.bytes.length; i++) {
if (now[i] === site.bytes[i]) continue;
const piece = site.code.find((p) => i >= p.at && i < p.at + p.length);
// A changed byte is only survivable as part of an instruction replaced whole by no-ops.
if (!piece) return 'broken';
for (let j = piece.at; j < piece.at + piece.length; j++) if (now[j] !== 0x90) return 'broken';
off = true;
}
return off ? 'off' : 'intact';
}
// ---- running --------------------------------------------------------------------
/**
* Runs the code for one effect of an action. Returns the write it made, or
* null when that code is switched off. Throws GameCrash when the code has
* been overwritten with something that is not code.
*/
run(actionId: string, index: number): GameWrite | null {
if (this.crashed) return null;
const action = this.action(actionId);
const effect = action.effects[index];
const site = this.siteFor(action, index);
const state = this.siteState(site);
if (state === 'broken') {
this.crashed = `The game ran code that had been overwritten with something that is not an instruction (at game+0x${site.offset.toString(16).toUpperCase()}).`;
throw new GameCrash(this.crashed);
}
if (state === 'off') return null;
const p = this.placed(effect.field);
const dv = new DataView(p.region.bytes.buffer);
const before = readNumber(dv, p.at, p.def.type);
let next = effect.op === 'sub' ? before - effect.by : before + effect.by;
if (effect.op === 'sub' && effect.min !== undefined && next < effect.min) next = effect.refill ?? effect.min;
if (effect.op === 'add' && effect.max !== undefined && next > effect.max) next = effect.max;
if (p.def.type === 'u32') next = Math.max(0, Math.min(0xffffffff, Math.round(next)));
writeNumber(dv, p.at, p.def.type, next);
// The game's own copies of the number (what it last drew, what it will save) follow for a while.
const heap = new DataView(this.heap.bytes.buffer);
for (const c of this.copies) {
if (c.of !== effect.field || c.left <= 0) continue;
c.left--;
writeNumber(heap, c.at, p.def.type, next);
}
const base = IMAGE_BASE + BigInt(site.offset);
return {
address: p.region.start + BigInt(p.at),
size: fieldSize(p.def.type),
rip: base + BigInt(site.store.at + site.store.length),
object: this.objects.get(p.object)!,
tag: site.tag,
};
}
/** Lets the values that change by themselves move on a little. */
drift(): void {
if (this.crashed) return;
const heap = new DataView(this.heap.bytes.buffer);
this.drifters.forEach((at, i) => {
const step = 1 + Math.floor(this.random() * 5);
const up = this.random() < 0.5;
if (i % 2) heap.setFloat32(at, heap.getFloat32(at, true) + (up ? step : -step) * 0.25, true);
else heap.setUint32(at, Math.max(1, heap.getUint32(at, true) + (up ? step : -step)), true);
});
}
// ---- memory, as the debugger sees it ----------------------------------------------
/** Like the console: memory that is not there reads as zeros. */
read(address: bigint, length: number): Uint8Array {
const out = new Uint8Array(length);
const end = address + BigInt(length);
for (const r of this.regions) {
const rEnd = r.start + BigInt(r.bytes.length);
const lo = address > r.start ? address : r.start;
const hi = end < rEnd ? end : rEnd;
if (lo < hi) out.set(r.bytes.subarray(Number(lo - r.start), Number(hi - r.start)), Number(lo - address));
}
return out;
}
write(address: bigint, data: Uint8Array): boolean {
for (const r of this.regions) {
if (address >= r.start && address + BigInt(data.length) <= r.start + BigInt(r.bytes.length)) {
r.bytes.set(data, Number(address - r.start));
return true;
}
}
return false;
}
}
+8
View File
@@ -0,0 +1,8 @@
// 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, practiceTitle } from './game.ts';
export { PracticeConsole, connectPractice } from './console.ts';
export type { PracticeEvent } from './console.ts';
export { GoalTracker, patience, showMe } from './coach.ts';
export type { CoachBench, GoalItem } from './coach.ts';
+59
View File
@@ -0,0 +1,59 @@
// Small settings kept on the console.
//
// What a browser stores belongs to the page's address, and a PS5's address
// can change whenever it restarts, taking everything "remembered" with it.
// The CheatSpecter payload therefore keeps small named JSON files
// (/data/cheatspecter/prefs/<name>.json on the console) that any device
// reaches the same way: GET and PUT api/prefs/<name>. The dev bridge passes
// both on to the console, and stands in for them in demo mode.
import { consoleApiUrl } from './library.ts';
import type { Target } from './session.ts';
/** What a settings file may be called: lower-case letters, digits and dashes, at most 32 of them. */
export const PREF_NAME = /^[a-z0-9-]{1,32}$/;
/** The most the console keeps under one name, in bytes of JSON text. */
export const PREF_MAX_BYTES = 64 * 1024;
function checkName(name: string): void {
if (!PREF_NAME.test(name)) throw new Error(`"${name}" cannot be used as a settings name (lower-case letters, digits and dashes only, at most 32)`);
}
/**
* Reads what was saved under `name`. Returns null when nothing was, and also
* when the console cannot be asked (no payload loaded, or one from before it
* kept settings): a setting that cannot be read is a setting at its default.
*/
export async function loadPref<T = unknown>(target: Target | null, name: string): Promise<T | null> {
checkName(name);
try {
const res = await fetch(consoleApiUrl(target, `api/prefs/${name}`), { cache: 'no-store' });
if (!res.ok) return null;
return (await res.json()) as T;
} catch {
return null;
}
}
/**
* Saves `value` (anything JSON can hold) under `name`, replacing what was
* there. Returns false when the console did not keep it; callers that care
* should keep a copy in the browser as well.
*/
export async function savePref(target: Target | null, name: string, value: unknown): Promise<boolean> {
checkName(name);
const body = JSON.stringify(value);
if (body === undefined) throw new Error('That value cannot be saved (it is not something JSON can hold)');
if (new TextEncoder().encode(body).length > PREF_MAX_BYTES) throw new Error('That is too much to save under one settings name (the limit is 64 KB)');
try {
const res = await fetch(consoleApiUrl(target, `api/prefs/${name}`), {
method: 'PUT',
cache: 'no-store',
headers: { 'content-type': 'application/json' },
body,
});
return res.ok;
} catch {
return false;
}
}
+439
View File
@@ -0,0 +1,439 @@
// Update-proofing for patches.
//
// A patch made at a fixed position ("game+0x1A2B3C") stops working the moment
// the game is updated, because the code moves. Two things are done about that
// when a patch is saved:
//
// 1. Its location becomes a byte pattern built from the code just before and
// just after the patched bytes. The patched bytes themselves are left out
// of the pattern, so it matches the same with the cheat on or off. The
// fixed position is kept next to it as a fallback.
// 2. A copy of the code around the patch is stored with it. If an update
// changes the code enough that the pattern no longer matches, "re-find"
// searches for that code again, more and more loosely.
import { bytesEqual, bytesToHex, hexToBytes } from './bytes.ts';
import { LocationError, describeLocation, findPattern, moduleLocation, resolveLocation } from './locations.ts';
import type { GameContext, Location } from './locations.ts';
import { PROT } from './ps5debug.ts';
import { patchSites, resolveSite } from './table.ts';
import type { OwnTable, PatchEntry, PatchSite, SiteContext } from './table.ts';
import { formatPattern } from './x86.ts';
import type { Insn } from './x86.ts';
/** How much code is looked at on each side of a patch. */
const READ_BEFORE = 48;
const READ_AFTER = 64;
/** How much of it is stored with the cheat. */
const KEEP_BEFORE = 24;
const KEEP_AFTER = 24;
/** A pattern with fewer fixed bytes than this says too little to be worth searching for. */
const MIN_FIXED = 4;
interface CodeWindow {
/** Original (cheat off) bytes. */
bytes: Uint8Array;
/** One character per byte; see SiteContext.mask. */
mask: string;
/** Index of the first patched byte. */
site: number;
/** How many bytes the patch covers. */
length: number;
}
const PREFIXES = new Set([0x66, 0x67, 0xf2, 0xf3, 0xf0, 0x2e, 0x36, 0x3e, 0x26, 0x64, 0x65]);
/**
* Splits an instruction into the part that says what it does (prefixes,
* opcode and the byte choosing its registers) and the rest, which is offsets
* and constants. A rough cut made from the bytes alone, which is all a loose
* search needs.
*/
function insnMask(insn: Insn): string[] {
const b = insn.bytes;
const out = new Array<string>(insn.length).fill('.');
let head = 0;
while (head < b.length - 1 && PREFIXES.has(b[head])) head++;
if (head < b.length - 1 && b[head] >= 0x40 && b[head] <= 0x4f) head++; // REX
if (b[head] === 0x0f) head += b[head + 1] === 0x38 || b[head + 1] === 0x3a ? 3 : 2;
else head += 1;
head = Math.min(head + 1, insn.length);
for (let i = 0; i < head; i++) out[i] = 'x';
for (const v of insn.variable) for (let i = 0; i < v.size; i++) out[v.offset + i] = '?';
return out;
}
/**
* Reads the code around a patch and works out where its instructions start
* and end. `on` is what the patch writes and `off` what was there before, if
* known; when the patch is applied right now, the original bytes are put back
* into the copy so the result always describes the game with the cheat off.
* Returns null when the place is not code, or does not hold what the cheat
* expects.
*/
async function readCodeWindow(ctx: GameContext, address: bigint, on: Uint8Array, off: Uint8Array | null): Promise<CodeWindow | null> {
const length = on.length;
if (length === 0) return null;
const map = (await ctx.maps()).find((m) => address >= m.start && address + BigInt(length) <= m.end && (m.prot & PROT.EXEC) !== 0);
if (!map) return null;
const lo = address - BigInt(READ_BEFORE) > map.start ? address - BigInt(READ_BEFORE) : map.start;
const wantEnd = address + BigInt(length + READ_AFTER);
const hi = wantEnd < map.end ? wantEnd : map.end;
const raw = Uint8Array.from(await ctx.dbg().read(ctx.pid, lo, Number(hi - lo)));
const site = Number(address - lo);
const current = raw.subarray(site, site + length);
if (off) {
const fits = off.length >= length ? bytesEqual(current, off.subarray(0, length)) : bytesEqual(current.subarray(0, off.length), off);
if (!fits) {
// Applied right now: describe the game as it is without the cheat.
if (!bytesEqual(current, on) || off.length < length) return null;
raw.set(off.subarray(0, length), site);
}
} else if (bytesEqual(current, on)) {
// Applied, and nothing says what was there before.
return null;
}
const after: Insn[] = [];
let covered = 0;
for (const insn of await ctx.x86.disassemble(raw.subarray(site), address, 40)) {
if (insn.invalid) break;
after.push(insn);
covered += insn.length;
}
if (covered < length) return null;
// x86 cannot be read backward, so try each earlier starting point and take
// the furthest one from which the instructions end exactly at the patch.
let before: Insn[] = [];
for (let start = 0; start < site; start++) {
const list = await ctx.x86.disassemble(raw.subarray(start, site), lo + BigInt(start));
if (list.length === 0 || list.some((i) => i.invalid)) continue;
if (list.reduce((n, i) => n + i.length, 0) !== site - start) continue;
before = list;
break;
}
const lead = before.reduce((n, i) => n + i.length, 0);
const bytes = raw.slice(site - lead, site + covered);
const mask = [...before, ...after].flatMap(insnMask).join('');
return { bytes, mask, site: lead, length };
}
/** A search pattern from part of a window. Returns null when too little of it is fixed. */
function windowPattern(
w: CodeWindow,
before: number,
after: number,
opts: { loose?: boolean; hole?: boolean } = {},
): { text: string; offset: number } | null {
let from = Math.max(0, w.site - before);
let to = Math.min(w.bytes.length, w.site + w.length + after);
const wild = (i: number): boolean =>
w.mask[i] === '?' || (!!opts.loose && w.mask[i] === '.') || (!!opts.hole && i >= w.site && i < w.site + w.length);
while (from < to && wild(from)) from++;
while (to > from && wild(to - 1)) to--;
let fixed = 0;
let nonZero = false;
const flags: boolean[] = [];
for (let i = from; i < to; i++) {
flags.push(wild(i));
if (!wild(i)) {
fixed++;
if (w.bytes[i] !== 0) nonZero = true;
}
}
if (fixed < MIN_FIXED || !nonZero) return null;
return { text: formatPattern(w.bytes.subarray(from, to), flags), offset: w.site - from };
}
function contextOf(w: CodeWindow): SiteContext {
const from = Math.max(0, w.site - KEEP_BEFORE);
const to = Math.min(w.bytes.length, w.site + w.length + KEEP_AFTER);
return { bytes: bytesToHex(w.bytes.subarray(from, to)), mask: w.mask.slice(from, to), at: w.site - from };
}
function windowOf(context: SiteContext, length: number): CodeWindow {
return { bytes: hexToBytes(context.bytes), mask: context.mask, site: context.at, length };
}
/** Context sizes tried, smallest first, until the pattern matches in exactly one place: [bytes before, bytes after]. */
const LADDER: [number, number][] = [
[8, 8],
[16, 16],
[24, 24],
[READ_BEFORE, READ_AFTER],
[0, 32],
[32, 0],
];
/**
* Makes one place of a patch survive game updates: a fixed position in code
* becomes a pattern of the code around it, with the fixed position kept as
* `fallback` and the surrounding code stored as `around`. Returns the site
* unchanged when it is not at a fixed position, is not code, or does not hold
* what the cheat expects; and with only `around` added when no pattern
* matches in exactly one place. Never throws for those reasons.
*/
export async function proofSite(ctx: GameContext, site: PatchSite): Promise<PatchSite> {
if (site.at.kind !== 'module') return site;
let window: CodeWindow | null;
let address: bigint;
try {
address = await resolveLocation(ctx, site.at);
window = await readCodeWindow(ctx, address, hexToBytes(site.on), site.off ? hexToBytes(site.off) : null);
} catch {
return site;
}
if (!window) return site;
const around = contextOf(window);
const tried = new Set<string>();
for (const [before, after] of LADDER) {
const candidate = windowPattern(window, before, after, { hole: true });
if (!candidate || tried.has(candidate.text)) continue;
tried.add(candidate.text);
try {
const match = await findPattern(ctx, candidate.text, 'code');
if (match && match.unique && match.address + BigInt(candidate.offset) === address) {
ctx.signatureCache.set(candidate.text, match.address);
return { ...site, at: { kind: 'signature', pattern: candidate.text, offset: candidate.offset }, fallback: site.at, around };
}
} catch {
// This size cannot be searched for; a larger one may.
}
}
return { ...site, around };
}
function withSites(entry: PatchEntry, sites: PatchSite[]): PatchEntry {
const [first, ...rest] = sites;
const out: PatchEntry = { ...entry, at: first.at, on: first.on, off: first.off, fallback: first.fallback, around: first.around };
if (rest.length) out.more = rest;
else delete out.more;
return out;
}
/** proofSite for every place of a patch. Returns the same object when nothing changed. */
export async function proofEntry(ctx: GameContext, entry: PatchEntry): Promise<PatchEntry> {
const sites = patchSites(entry);
const next: PatchSite[] = [];
for (const site of sites) next.push(await proofSite(ctx, site));
return next.every((s, i) => s === sites[i]) ? entry : withSites(entry, next);
}
/**
* How a patch is found, for the small note on its row:
* "pattern" survives most updates, "fixed" breaks with the next one,
* "session" is a raw address that is gone when the game restarts.
*/
export function patchLocating(entry: PatchEntry): 'pattern' | 'fixed' | 'session' {
const sites = patchSites(entry);
if (sites.some((s) => s.at.kind === 'address')) return 'session';
return sites.every((s) => s.at.kind === 'signature') ? 'pattern' : 'fixed';
}
// ---- finding cheats again after a game update ----------------------------------
type Health = 'ok' | 'broken' | 'unknown';
/** Whether a place of a patch still points at the code the cheat was made for. Reads only. */
async function siteHealth(ctx: GameContext, site: PatchSite): Promise<Health> {
if (site.at.kind === 'address' || site.at.kind === 'pointer') return 'unknown';
let address: bigint;
let via: string;
try {
({ address, via } = await resolveSite(ctx, site));
} catch (e) {
if (e instanceof LocationError) return 'broken';
throw e;
}
const on = hexToBytes(site.on);
const current = await ctx.dbg().read(ctx.pid, address, on.length);
if (bytesEqual(current, on)) return 'ok';
if (site.off) {
const off = hexToBytes(site.off);
return bytesEqual(current.subarray(0, off.length), off) ? 'ok' : 'broken';
}
if (via === 'pattern') return 'ok';
// The pattern is gone and nothing says what should be at the fixed position.
if (via === 'fallback') return 'broken';
if (site.around) {
const w = windowOf(site.around, on.length);
return bytesEqual(current, w.bytes.subarray(w.site, w.site + on.length)) ? 'ok' : 'broken';
}
return 'unknown';
}
/** The searches for a lost place, strictest first. */
function searchPatterns(site: PatchSite): { text: string; offset: number }[] {
const out: { text: string; offset: number }[] = [];
const add = (p: { text: string; offset: number } | null) => {
if (p && !out.some((x) => x.text === p.text)) out.push(p);
};
const length = hexToBytes(site.on).length;
if (site.around) {
const w = windowOf(site.around, length);
const all = w.bytes.length;
add(windowPattern(w, all, all));
// The same, not caring what the patched bytes are: finds a place where the cheat is already applied.
add(windowPattern(w, all, all, { hole: true }));
add(windowPattern(w, all, all, { loose: true }));
add(windowPattern(w, 12, 12));
add(windowPattern(w, 12, 12, { loose: true }));
} else if (site.off) {
// Nothing stored around it (a cheat from an older file): the original bytes alone, if there are enough of them.
const off = hexToBytes(site.off);
if (off.length >= 5 && off.some((b) => b !== 0)) add({ text: bytesToHex(off, ' '), offset: 0 });
}
return out;
}
/** True when an instruction ends exactly `length` bytes into this code. */
function endsAt(insns: Insn[], length: number): boolean {
let n = 0;
for (const insn of insns) {
if (insn.invalid) return false;
n += insn.length;
if (n >= length) return n === length;
}
return false;
}
type SiteSearch =
| { outcome: 'found'; site: PatchSite; from: Location; to: Location; changed: boolean }
| { outcome: 'several' }
| { outcome: 'missing'; why: string };
async function searchSite(ctx: GameContext, site: PatchSite): Promise<SiteSearch> {
const patterns = searchPatterns(site);
if (patterns.length === 0) return { outcome: 'missing', why: 'Nothing was saved with this cheat to search with.' };
let address: bigint | null = null;
for (const p of patterns) {
let match;
try {
match = await findPattern(ctx, p.text, 'code');
} catch (e) {
if (e instanceof Error && /only of 00/.test(e.message)) continue;
throw e;
}
if (!match) continue;
if (!match.unique) return { outcome: 'several' };
address = match.address + BigInt(p.offset);
break;
}
if (address === null) return { outcome: 'missing', why: 'The code this cheat changes is not in this version of the game any more, or was rewritten.' };
const to = await moduleLocation(ctx, address);
if (!to) return { outcome: 'missing', why: 'Similar code was found, but outside the game itself.' };
const on = hexToBytes(site.on);
const there = await ctx.dbg().read(ctx.pid, address, on.length + 16);
const current = there.subarray(0, on.length);
let off = site.off;
let changed = false;
const applied = bytesEqual(current, on);
if (!applied && !(off && bytesEqual(current.subarray(0, hexToBytes(off).length), hexToBytes(off)))) {
// Close enough to be recognized, but the bytes the cheat replaces differ
// (a changed offset, say). Only safe to take over when the instructions
// still end where the patch ends, as they did before.
if (site.around) {
const w = windowOf(site.around, on.length);
const was = endsAt(await ctx.x86.disassemble(w.bytes.subarray(w.site), 0n, 16), on.length);
const now = endsAt(await ctx.x86.disassemble(there, address, 16), on.length);
if (was && !now) return { outcome: 'missing', why: 'The code was found, but it changed shape in the update, so the old cheat would not fit. Make this cheat again.' };
}
off = bytesToHex(current);
changed = true;
}
const next = await proofSite(ctx, { at: to, on: site.on, off });
return { outcome: 'found', site: next, from: site.fallback ?? site.at, to, changed };
}
export type RefindOutcome =
/** Still where it was; nothing to do. */
| 'ok'
/** Found in exactly one new place. `entry` is the cheat pointing there. */
| 'found'
/** Matches in more than one place; nothing is picked. */
| 'several'
| 'missing'
/** Cannot be checked (a pointer path, a raw address, or nothing to compare with). */
| 'skipped';
export interface RefindResult {
id: string;
name: string;
outcome: RefindOutcome;
/** One plain sentence for the user. */
detail: string;
/** For "found": the cheat with its new location, to be saved if the user agrees. */
entry?: PatchEntry;
}
/**
* Checks one patch against the running game and, where a place no longer
* works, searches the game's code for it. Reads memory only; nothing is
* written to the game and the table is not touched.
*/
export async function refindEntry(ctx: GameContext, entry: PatchEntry, madeOn = ''): Promise<RefindResult> {
const base = { id: entry.id, name: entry.name };
const sites = patchSites(entry);
const next: PatchSite[] = [];
const moved: string[] = [];
let anyChecked = false;
let changed = false;
let several = false;
for (const site of sites) {
const health = await siteHealth(ctx, site);
if (health !== 'broken') {
if (health === 'ok') anyChecked = true;
next.push(site);
continue;
}
anyChecked = true;
const found = await searchSite(ctx, site);
if (found.outcome === 'missing') return { ...base, outcome: 'missing', detail: found.why };
if (found.outcome === 'several') {
several = true;
continue;
}
next.push(found.site);
moved.push(`${describeLocation(found.from)} to ${describeLocation(found.to)}`);
if (found.changed) changed = true;
}
if (several) {
return { ...base, outcome: 'several', detail: 'Code like this is in more than one place in this version, and picking the wrong one could crash the game. Make this cheat again.' };
}
if (moved.length === 0) {
return anyChecked
? { ...base, outcome: 'ok', detail: 'Still in the same place.' }
: { ...base, outcome: 'skipped', detail: 'Cannot be checked: it has no fixed place or pattern in the game\'s code.' };
}
const was = `Re-found after a game update${madeOn ? ` (made on v${madeOn})` : ''}: moved from ${moved.join('; ')}.`;
const updated = withSites({ ...entry, note: entry.note ? `${entry.note}\n${was}` : was }, next);
return {
...base,
outcome: 'found',
detail:
`Moved from ${moved.join('; ')}.` +
(changed ? ' The code there is slightly different from before, so try it at a quiet moment first.' : ''),
entry: updated,
};
}
/** refindEntry for every patch in a table, in order. */
export async function refindTable(ctx: GameContext, table: OwnTable, onProgress?: (done: number, total: number) => void): Promise<RefindResult[]> {
const patches = table.entries.filter((e): e is PatchEntry => e.type === 'patch');
const out: RefindResult[] = [];
for (const [i, entry] of patches.entries()) {
onProgress?.(i, patches.length);
try {
out.push(await refindEntry(ctx, entry, table.version));
} catch (e) {
out.push({ id: entry.id, name: entry.name, outcome: 'skipped', detail: `Could not be checked: ${e instanceof Error ? e.message : String(e)}` });
}
}
onProgress?.(patches.length, patches.length);
return out;
}
+95
View File
@@ -0,0 +1,95 @@
// Wrappers for two more ps5debug-NG commands, kept apart from ps5debug.ts so
// that file stays small. Section numbers refer to PROTOCOL.md in
// https://github.com/Pharaoh2k/ps5debug-NG (v1.3.2), as there.
import { Packer, view } from './bytes.ts';
import type { Ps5Debug } from './ps5debug.ts';
const CMD_PROC_SCAN_AOB_MULTI = 0xbdaa0502;
const CMD_PROC_SCAN_GET = 0xbdaacc03;
const SENTINEL = 0xffffffffffffffffn;
export interface AobQuery {
pattern: Uint8Array;
/** 1 where the byte must match, 0 for a wildcard. Same length as `pattern`. */
mask: Uint8Array;
/** Which match to report, counting from 1. At most 255. */
nth: number;
}
/**
* Searches one address range for several byte patterns in a single pass over
* the memory (section 2.2, `CMD_PROC_SCAN_AOB_MULTI`). Returns one address per
* query, in order: where that pattern's `nth` match starts, or 0n when the
* range holds fewer matches. A match never runs past the end of the range.
*
* The same pattern may be listed several times with nth = 1, 2, 3, ... to get
* its first few matches from one pass, which is how the text search avoids
* asking again for every hit.
*/
export function aobScanMulti(dbg: Ps5Debug, opts: { pid: number; address: bigint; length: number; queries: AobQuery[] }): Promise<bigint[]> {
const { queries } = opts;
if (queries.length === 0) return Promise.resolve([]);
// The console counts patterns in a u16 and trusts every length in the blob.
if (queries.length > 0xffff) throw new Error('Too many patterns to search for at once');
const blob = new Packer();
for (const q of queries) {
if (q.pattern.length === 0 || q.pattern.length !== q.mask.length) throw new Error('Malformed pattern');
if (!Number.isInteger(q.nth) || q.nth < 1 || q.nth > 255) throw new Error('A pattern search can report the 1st to the 255th match');
blob.u8(q.nth).u32(q.pattern.length).bytes(q.pattern).bytes(q.mask);
}
const body = blob.done();
return dbg.raw(async (io) => {
// stop_flag = 0: report the nth match even when there are more after it.
io.send(CMD_PROC_SCAN_AOB_MULTI, new Packer().u32(opts.pid).u64(opts.address).u32(opts.length).u8(0).u32(body.length).done());
await io.expectSuccess('Starting the pattern search');
io.write(body);
await io.expectSuccess('Searching for the patterns');
const raw = view(await io.read(queries.length * 8));
await io.expectSuccess('Searching for the patterns');
return queries.map((_, i) => raw.getBigUint64(i * 8, true));
}, null);
}
export interface ReadRange {
address: bigint;
length: number;
}
/** Ranges per exchange, and bytes per exchange, that keep one reply a comfortable size. */
const READ_MULTI_RANGES = 4096;
const READ_MULTI_BYTES = 4 * 1024 * 1024;
/**
* Reads many small pieces of memory in few exchanges (section 2.2,
* `CMD_PROC_SCAN_GET`). Needs `auth()` first, like the scan commands. As with a
* plain read, a piece that cannot be read comes back as zeros, and so does a
* piece that is only partly inside mapped memory.
*/
export async function readMulti(dbg: Ps5Debug, pid: number, ranges: ReadRange[]): Promise<Uint8Array[]> {
const out: Uint8Array[] = [];
for (let i = 0; i < ranges.length; ) {
let bytes = 0;
let j = i;
while (j < ranges.length && j - i < READ_MULTI_RANGES && (j === i || bytes + ranges[j].length <= READ_MULTI_BYTES)) bytes += ranges[j++].length;
const batch = ranges.slice(i, j);
out.push(...(await readBatch(dbg, pid, batch)));
i = j;
}
return out;
}
function readBatch(dbg: Ps5Debug, pid: number, batch: ReadRange[]): Promise<Uint8Array[]> {
return dbg.raw(async (io) => {
io.send(CMD_PROC_SCAN_GET, new Packer().u32(pid).u32(batch.length).done());
await io.expectSuccess('Reading memory');
const list = new Packer();
for (const r of batch) list.u64(r.address).u32(r.length);
io.write(list.done());
const out: Uint8Array[] = [];
for (const r of batch) out.push(r.length ? await io.read(r.length) : new Uint8Array(0));
// This command ends with the all-ones marker and no status word.
if ((await io.u64()) !== SENTINEL) throw new Error('Memory read is out of step');
return out;
}, 30000);
}
+464 -28
View File
@@ -41,6 +41,9 @@ const CMD = {
PROC_ALLOC: 0xbdaa000b,
PROC_FREE: 0xbdaa000c,
PROC_ALLOC_HINTED: 0xbdaa000e,
PROC_EXTRACT_CODE_XREFS: 0xbdaa0021,
PROC_FIND_XREFS_TO: 0xbdaa0022,
PROC_READ_STACK: 0xbdaa0023,
PROC_ASSEMBLE: 0xbdaa0024,
PROC_SCAN_AOB: 0xbdaa0501,
TURBO_CAPS: 0xbdaacc10,
@@ -52,7 +55,18 @@ const CMD = {
DEBUG_DETACH: 0xbdbb0002,
DEBUG_SET_BREAKPOINT: 0xbdbb0003,
DEBUG_SET_WATCHPOINT: 0xbdbb0004,
DEBUG_GET_THREAD_LIST: 0xbdbb0005,
DEBUG_SUSPEND_THREAD: 0xbdbb0006,
DEBUG_RESUME_THREAD: 0xbdbb0007,
DEBUG_GETREGS: 0xbdbb0008,
DEBUG_SETREGS: 0xbdbb0009,
DEBUG_GETFPREGS: 0xbdbb000a,
DEBUG_SETFPREGS: 0xbdbb000b,
DEBUG_GETFSGSBASE: 0xbdbb000e,
DEBUG_CONTINUE: 0xbdbb0010,
DEBUG_THREAD_INFO: 0xbdbb0011,
DEBUG_STEP: 0xbdbb0012,
DEBUG_STEP_THREAD: 0xbdbb0013,
PROC_SCAN_START: 0xbdaacc01,
PROC_SCAN_COUNT: 0xbdaacc02,
PROC_WRITE_MULTI: 0xbdaacc04,
@@ -177,31 +191,111 @@ export interface Registers {
rflags: bigint;
}
/** Size of a FreeBSD amd64 `struct reg`, the general register block the debugger reads and writes. */
export const REGS_SIZE = 176;
// Byte offsets of each register inside `struct reg`. The gaps hold trapno,
// err and the segment selectors, which are carried along untouched.
const REG_OFFSETS: Record<keyof Registers, number> = {
r15: 0,
r14: 8,
r13: 16,
r12: 24,
r11: 32,
r10: 40,
r9: 48,
r8: 56,
rdi: 64,
rsi: 72,
rbp: 80,
rbx: 88,
rdx: 96,
rcx: 104,
rax: 112,
rip: 136,
rflags: 152,
rsp: 160,
};
/** Names of the registers in a `Registers`, in the order people expect to read them. */
export const REGISTER_NAMES = [
'rax', 'rbx', 'rcx', 'rdx', 'rsi', 'rdi', 'rbp', 'rsp',
'r8', 'r9', 'r10', 'r11', 'r12', 'r13', 'r14', 'r15', 'rip', 'rflags',
] as const satisfies readonly (keyof Registers)[];
/** Parses a FreeBSD amd64 `struct reg` (176 bytes). */
export function parseRegisters(raw: Uint8Array): Registers {
const dv = view(raw);
const q = (off: number) => dv.getBigUint64(off, true);
return {
r15: q(0),
r14: q(8),
r13: q(16),
r12: q(24),
r11: q(32),
r10: q(40),
r9: q(48),
r8: q(56),
rdi: q(64),
rsi: q(72),
rbp: q(80),
rbx: q(88),
rdx: q(96),
rcx: q(104),
rax: q(112),
// 120: trapno, fs, gs, err, es, ds
rip: q(136),
rflags: q(152),
rsp: q(160),
};
const out = {} as Registers;
for (const name of REGISTER_NAMES) out[name] = dv.getBigUint64(REG_OFFSETS[name], true);
return out;
}
/**
* The inverse of `parseRegisters`: a 176-byte block ready for
* `setRegistersRaw`. Pass the block the registers were read from as `base` so
* the fields `Registers` does not carry (segment selectors, trap number) go
* back unchanged; writing zeros into those would crash the thread.
*/
export function encodeRegisters(regs: Partial<Registers>, base: Uint8Array): Uint8Array {
if (base.length !== REGS_SIZE) throw new Error('A register block is 176 bytes');
const out = base.slice();
const dv = view(out);
for (const name of REGISTER_NAMES) {
const v = regs[name];
if (v !== undefined) dv.setBigUint64(REG_OFFSETS[name], BigInt.asUintN(64, v), true);
}
return out;
}
/** Size of the floating point and vector register block (an XSAVE image: x87, XMM, then the upper halves of YMM). */
export const FPREGS_SIZE = 832;
export interface VectorRegister {
/** The low 128 bits (the XMM register), least significant byte first. */
bytes: Uint8Array;
/** The same 128 bits read as four floats, lowest first. Game code keeps a single float in floats[0]. */
floats: [number, number, number, number];
/** The same 128 bits read as two doubles, lowest first. */
doubles: [number, number];
/** Bits 128-255 (the upper half of the YMM register). All zero when the console could not read them. */
upper: Uint8Array;
}
export interface FpRegisters {
/** XMM0 to XMM15. */
xmm: VectorRegister[];
/** SSE control and status register. */
mxcsr: number;
}
const XMM_OFFSET = 160; // inside the legacy FXSAVE area
const YMM_HIGH_OFFSET = 576; // 512 legacy + 64 header
/** Parses the 832-byte block from `getFpRegistersRaw` (also embedded in every stop report at 0xE0). */
export function parseFpRegisters(raw: Uint8Array): FpRegisters {
if (raw.length < 512) throw new Error('A floating point register block is at least 512 bytes');
const dv = view(raw);
const xmm: VectorRegister[] = [];
for (let i = 0; i < 16; i++) {
const o = XMM_OFFSET + i * 16;
const hi = YMM_HIGH_OFFSET + i * 16;
xmm.push({
bytes: raw.slice(o, o + 16),
floats: [dv.getFloat32(o, true), dv.getFloat32(o + 4, true), dv.getFloat32(o + 8, true), dv.getFloat32(o + 12, true)],
doubles: [dv.getFloat64(o, true), dv.getFloat64(o + 8, true)],
upper: raw.length >= hi + 16 ? raw.slice(hi, hi + 16) : new Uint8Array(16),
});
}
return { xmm, mxcsr: dv.getUint32(24, true) };
}
/** Returns a copy of an 832-byte block with one XMM register's low 128 bits replaced. */
export function withXmm(raw: Uint8Array, index: number, bytes: Uint8Array): Uint8Array {
if (index < 0 || index > 15 || bytes.length !== 16) throw new Error('An XMM register is 16 bytes, numbered 0 to 15');
const out = raw.slice();
out.set(bytes, XMM_OFFSET + index * 16);
return out;
}
/** Size of the packet the console sends on the event channel for each debugger stop (section 1.9). */
@@ -217,21 +311,69 @@ export interface DebugEvent {
/** Debug status register: bits 0-3 say which watchpoint slot fired. */
dr6: bigint;
threadName: string;
/**
* General registers. For a software breakpoint these are from the moment
* the breakpoint was reached, with RIP on the breakpoint's instruction; by
* the time the report arrives the console has already run that instruction
* (see debugger.ts).
*/
regs: Registers;
/** The 176-byte block `regs` was parsed from, for `encodeRegisters`. */
rawRegs: Uint8Array;
/** The 832-byte floating point and vector block; parse with `parseFpRegisters`. */
rawFpRegs: Uint8Array;
/** DR0-DR3: the addresses the four hardware watchpoint slots hold in this thread. */
dr: [bigint, bigint, bigint, bigint];
/** Debug control register: which slots are enabled, and for what. */
dr7: bigint;
}
export function parseDebugEvent(raw: Uint8Array): DebugEvent {
const dv = view(raw);
const q = (i: number) => dv.getBigUint64(0x420 + i * 8, true);
const rawRegs = raw.slice(0x30, 0x30 + REGS_SIZE);
return {
lwpid: dv.getUint32(0, true),
status: dv.getUint32(4, true),
signal: (dv.getUint32(4, true) >>> 8) & 0xff,
dr6: dv.getBigUint64(0x420 + 6 * 8, true),
dr6: q(6),
threadName: cstr(raw.subarray(8, 48)),
regs: parseRegisters(raw.subarray(0x30, 0x30 + 176)),
regs: parseRegisters(rawRegs),
rawRegs,
rawFpRegs: raw.slice(0xe0, 0xe0 + FPREGS_SIZE),
dr: [q(0), q(1), q(2), q(3)],
dr7: q(7),
};
}
export interface ThreadInfo {
lwpid: number;
priority: number;
name: string;
}
/** One frame of a server-side stack walk (`readStack`). */
export interface StackFrame {
/** Frame pointer of this frame. */
rbp: bigint;
/** Lowest address of this frame. */
rsp: bigint;
/** The caller's frame pointer, stored at [rbp]. Zero ends the chain. */
savedRbp: bigint;
/** Where this frame returns to, stored at [rbp + 8]. */
returnAddress: bigint;
/** The frame's own bytes, from rsp up to and including the saved pair. Empty when the frame looked implausible. */
locals: Uint8Array;
/** Code around the return address: it starts `STACK_CODE_BEFORE` bytes before it. Empty when unreadable. */
code: Uint8Array;
}
/** How many bytes before each return address `StackFrame.code` starts. */
export const STACK_CODE_BEFORE = 10;
export const MAX_BREAKPOINTS = 30;
export const MAX_WATCHPOINTS = 4;
export type WatchKind = 'write' | 'readwrite' | 'execute';
export interface TurboSurvivor {
@@ -293,6 +435,19 @@ function compareVersions(a: string, b: string): number {
return 0;
}
/** What `Ps5Debug.raw()` hands to a command wrapper kept in another file. */
export interface RawExchange {
/** Sends the 12-byte header and the request body. */
send(cmd: number, body?: Uint8Array): void;
/** Sends trailing data (after the console's first status word). */
write(data: Uint8Array): void;
read(n: number): Promise<Uint8Array>;
u32(): Promise<number>;
u64(): Promise<bigint>;
/** Reads one status word and throws a DebuggerError unless it is success. */
expectSuccess(what: string): Promise<void>;
}
export class Ps5Debug {
/** Set after a successful `auth()`; the iterative scans need it. */
authed = false;
@@ -342,6 +497,24 @@ export class Ps5Debug {
return result;
}
/**
* Runs one exchange for a command this class has no method for, with the
* same locking and timeout as the built-in ones. It exists so wrappers for
* further commands can live in their own file (ps5debug-extra.ts) instead of
* growing this one. `timeoutMs` null means no timeout, for long scans.
*/
raw<T>(fn: (io: RawExchange) => Promise<T>, timeoutMs: number | null = this.timeoutMs): Promise<T> {
const io: RawExchange = {
send: (cmd, body) => this.send(cmd, body),
write: (data) => this.stream.write(data),
read: (n) => this.stream.read(n),
u32: () => this.u32(),
u64: () => this.u64(),
expectSuccess: (what) => this.expectSuccess(what),
};
return this.exclusive(() => fn(io), timeoutMs);
}
private send(cmd: number, body?: Uint8Array): void {
const header = new Packer()
.u32(PACKET_MAGIC)
@@ -956,25 +1129,288 @@ export class Ps5Debug {
}, 30000);
}
/** Software breakpoint. Thirty slots, index 0-29. */
/**
* Software breakpoint: the console swaps the byte at `address` for an INT3
* and remembers the original. Thirty slots, index 0-29.
*
* What the console does with it (debug.c, debug_set_breakpoint_handle):
* - Arming a slot that is already armed at the same address is a no-op;
* at a different address it is refused. Disarm first to move a slot.
* - Two slots cannot hold the same address.
* - Disarming puts the byte back and then **lets the game run** if it was
* stopped, without telling anyone. Follow it with `debugResume()` so the
* console's idea of the game's state matches (debugger.ts does).
*
* A refusal comes back as an error without closing the connection.
*/
setBreakpoint(index: number, enabled: boolean, address: bigint): Promise<void> {
return this.exclusive(async () => {
if (!this.attached) throw new Error('Attach the debugger first');
if (index < 0 || index >= MAX_BREAKPOINTS) throw new Error('Breakpoint slots are numbered 0 to 29');
this.send(CMD.DEBUG_SET_BREAKPOINT, new Packer().u32(index).u32(enabled ? 1 : 0).u64(address).done());
await this.expectSuccess('Setting the breakpoint');
}, 30000);
}
/** Lets the game run again after a stop was reported on the event channel. */
/**
* Lets the game run again after a stop. Harmless when it is already
* running; refused (an error, connection stays open) while the console is
* in the middle of a step.
*/
debugResume(): Promise<void> {
return this.stopGo(0, 'Resuming the game');
}
/**
* Freezes the whole game. Normally **no stop report follows**: the console
* stops the game and simply remembers that it is stopped. The exception is
* when a breakpoint or watchpoint fires at the same moment; then that stop
* is reported instead, and the freeze request stays queued inside the game
* (it takes effect, silently, soon after the next resume).
*/
debugStop(): Promise<void> {
return this.stopGo(1, 'Pausing the game');
}
private stopGo(action: number, what: string): Promise<void> {
return this.exclusive(async () => {
// With no session the console's handler reads through a null pointer, which takes the whole debugger down.
if (!this.attached) throw new Error('Attach the debugger first');
this.send(CMD.DEBUG_CONTINUE, new Packer().u32(0).done());
await this.expectSuccess('Resuming the game');
this.send(CMD.DEBUG_CONTINUE, new Packer().u32(action).done());
await this.expectSuccess(what);
}, 30000);
}
/**
* Runs one instruction of the thread that last reported a stop, then stops
* again and reports. Other threads run freely meanwhile, and a stop of
* theirs can arrive instead; `debugStepThread` is the stricter form.
*/
debugStep(): Promise<void> {
return this.exclusive(async () => {
if (!this.attached) throw new Error('Attach the debugger first');
this.send(CMD.DEBUG_STEP);
await this.expectSuccess('Stepping');
}, 30000);
}
/**
* Runs one instruction of one thread, then stops and reports. Until that
* report, stops caused by any other thread are swallowed by the console
* (those threads just continue). The game must be stopped.
*/
debugStepThread(lwpid: number): Promise<void> {
return this.exclusive(async () => {
if (!this.attached) throw new Error('Attach the debugger first');
this.send(CMD.DEBUG_STEP_THREAD, new Packer().u32(lwpid).done());
await this.expectSuccess('Stepping');
}, 30000);
}
/**
* Ids of the game's threads. If the game is running the console freezes it
* for a moment to count them, which **discards one pending stop**: with a
* breakpoint armed in running code that can crash the game, so only call
* this while the game is stopped (debugger.ts enforces that).
*/
threadList(): Promise<number[]> {
return this.exclusive(async () => {
if (!this.attached) throw new Error('Attach the debugger first');
this.send(CMD.DEBUG_GET_THREAD_LIST);
await this.expectSuccess('Listing threads');
const count = await this.u32();
if (count > 4096) throw new Error('Thread list is out of step');
const raw = view(await this.stream.read(count * 4));
const out: number[] = [];
for (let i = 0; i < count; i++) out.push(raw.getUint32(i * 4, true));
return out;
}, 30000);
}
/** A thread's name and priority. The console closes the connection if the thread no longer exists. */
threadInfo(lwpid: number): Promise<ThreadInfo> {
return this.exclusive(async () => {
if (!this.attached) throw new Error('Attach the debugger first');
this.send(CMD.DEBUG_THREAD_INFO, new Packer().u32(lwpid).done());
await this.expectSuccess('Reading the thread');
const raw = await this.stream.read(40);
return { lwpid: view(raw).getUint32(0, true), priority: view(raw).getUint32(4, true), name: cstr(raw.subarray(8, 40)) };
});
}
/** PT_SUSPEND: keeps one thread from running when the game is resumed. */
suspendThread(lwpid: number): Promise<void> {
return this.exclusive(async () => {
if (!this.attached) throw new Error('Attach the debugger first');
this.send(CMD.DEBUG_SUSPEND_THREAD, new Packer().u32(lwpid).done());
await this.expectSuccess('Suspending the thread');
});
}
/** Undoes `suspendThread`. */
resumeThread(lwpid: number): Promise<void> {
return this.exclusive(async () => {
if (!this.attached) throw new Error('Attach the debugger first');
this.send(CMD.DEBUG_RESUME_THREAD, new Packer().u32(lwpid).done());
await this.expectSuccess('Resuming the thread');
});
}
/** A thread's general registers as the raw 176-byte block. The game must be stopped. */
getRegistersRaw(lwpid: number): Promise<Uint8Array> {
return this.exclusive(async () => {
if (!this.attached) throw new Error('Attach the debugger first');
this.send(CMD.DEBUG_GETREGS, new Packer().u32(lwpid).done());
await this.expectSuccess('Reading registers');
return this.stream.read(REGS_SIZE);
});
}
async getRegisters(lwpid: number): Promise<Registers> {
return parseRegisters(await this.getRegistersRaw(lwpid));
}
/**
* Writes a full 176-byte register block (build it with `encodeRegisters`
* from one that was read).
*
* The console ignores `lwpid` here (debug.c, debug_setregs_handle passes
* the process id to PT_SETREGS): the block always goes to the thread whose
* stop was reported last. Writing another thread's registers is therefore
* not possible, and trying would overwrite the stopped thread's instead.
*/
setRegistersRaw(lwpid: number, raw: Uint8Array): Promise<void> {
return this.exclusive(async () => {
if (!this.attached) throw new Error('Attach the debugger first');
if (raw.length !== REGS_SIZE) throw new Error('A register block is 176 bytes');
this.send(CMD.DEBUG_SETREGS, new Packer().u32(lwpid).u32(raw.length).done());
await this.expectSuccess('Writing registers');
this.stream.write(raw);
await this.expectSuccess('Writing registers');
});
}
/** A thread's floating point and vector registers as the raw 832-byte block (see `parseFpRegisters`). */
getFpRegistersRaw(lwpid: number): Promise<Uint8Array> {
return this.exclusive(async () => {
if (!this.attached) throw new Error('Attach the debugger first');
this.send(CMD.DEBUG_GETFPREGS, new Packer().u32(lwpid).done());
await this.expectSuccess('Reading vector registers');
return this.stream.read(FPREGS_SIZE);
});
}
async getFpRegisters(lwpid: number): Promise<FpRegisters> {
return parseFpRegisters(await this.getFpRegistersRaw(lwpid));
}
/** Writes back a full 832-byte block, normally one from `getFpRegistersRaw` changed with `withXmm`. */
setFpRegistersRaw(lwpid: number, raw: Uint8Array): Promise<void> {
return this.exclusive(async () => {
if (!this.attached) throw new Error('Attach the debugger first');
// A shorter block takes a different path on the console that ignores the thread id.
if (raw.length !== FPREGS_SIZE) throw new Error('A vector register block is 832 bytes');
this.send(CMD.DEBUG_SETFPREGS, new Packer().u32(lwpid).u32(raw.length).done());
await this.expectSuccess('Writing vector registers');
this.stream.write(raw);
await this.expectSuccess('Writing vector registers');
});
}
/** The FS and GS base addresses of a thread (FS is its thread-local storage). */
getFsGsBase(lwpid: number): Promise<{ fs: bigint; gs: bigint }> {
return this.exclusive(async () => {
if (!this.attached) throw new Error('Attach the debugger first');
this.send(CMD.DEBUG_GETFSGSBASE, new Packer().u32(lwpid).done());
await this.expectSuccess('Reading the thread base');
const raw = view(await this.stream.read(16));
return { fs: raw.getBigUint64(0, true), gs: raw.getBigUint64(8, true) };
});
}
// ---- code analysis done on the console -------------------------------------------
/**
* Walks a thread's stack on the console by following saved frame pointers
* from `rbp`. Works without a debug session, but the registers have to
* come from somewhere, and it only makes sense while the thread is stopped.
*
* It trusts the frame-pointer chain: code built without frame pointers
* (much optimized game code) gives a short or wrong chain. Up to 64 frames.
*/
readStack(pid: number, rbp: bigint, rsp: bigint, depth: number): Promise<StackFrame[]> {
return this.exclusive(async () => {
this.send(CMD.PROC_READ_STACK, new Packer().u32(pid).u64(rbp).u64(rsp).u32(depth).done());
await this.expectSuccess('Reading the stack');
const len = await this.u32();
if (len < 4 || len > 0x100000) throw new Error('Stack reply is out of step');
const raw = await this.stream.read(len);
const dv = view(raw);
const frames: StackFrame[] = [];
let o = 4;
for (let i = 0, n = dv.getUint32(0, true); i < n && o + 44 <= raw.length; i++) {
const localsLen = dv.getUint32(o + 36, true);
const codeLen = dv.getUint32(o + 40, true);
if (o + 44 + localsLen + codeLen > raw.length) break;
frames.push({
rbp: dv.getBigUint64(o, true),
rsp: dv.getBigUint64(o + 8, true),
savedRbp: dv.getBigUint64(o + 16, true),
returnAddress: dv.getBigUint64(o + 24, true),
locals: raw.slice(o + 44, o + 44 + localsLen),
code: raw.slice(o + 44 + localsLen, o + 44 + localsLen + codeLen),
});
o += 44 + localsLen + codeLen;
}
return frames;
}, 30000);
}
/**
* "What refers to this address?" Disassembles `length` bytes of code on the
* console and returns the address of every instruction with a RIP-relative
* memory operand that resolves to `target` (loads, stores and LEAs of a
* global). Relative calls and jumps are **not** found: they are not memory
* operands.
*
* The console decodes linearly from `address`, so start on an instruction
* boundary (the start of a code region is one). A whole game executable
* takes several seconds.
*/
findXrefsTo(opts: { pid: number; address: bigint; length: number; target: bigint }): Promise<bigint[]> {
return this.exclusive(async () => {
this.send(CMD.PROC_FIND_XREFS_TO, new Packer().u32(opts.pid).u64(opts.address).u32(opts.length).u64(opts.target).done());
await this.expectSuccess('Searching for references');
return this.readU64Stream(1_000_000);
}, null);
}
/**
* Every address that code in the range refers to RIP-relatively, in code
* order, duplicates included. `maxInstructions` 0 means the console's
* default (ten million). At most `limit` results are kept.
*/
extractCodeXrefs(opts: { pid: number; address: bigint; length: number; maxInstructions?: number; limit?: number }): Promise<bigint[]> {
return this.exclusive(async () => {
this.send(
CMD.PROC_EXTRACT_CODE_XREFS,
new Packer().u32(opts.pid).u64(opts.address).u32(opts.length).u32(opts.maxInstructions ?? 0).done(),
);
await this.expectSuccess('Reading references');
return this.readU64Stream(opts.limit ?? 1_000_000);
}, null);
}
/** Reads u64 values up to the all-ones sentinel, keeping at most `limit` of them. */
private async readU64Stream(limit: number): Promise<bigint[]> {
const out: bigint[] = [];
for (;;) {
const v = await this.u64();
if (v === SENTINEL) return out;
if (out.length < limit) out.push(v);
}
}
/** Reads u64-length-prefixed result blocks up to the all-ones sentinel. */
private async readScanBlocks(valueSize: number, onBlock: (block: ScanBlock) => void): Promise<void> {
const recordSize = 4 + valueSize;
+263
View File
@@ -0,0 +1,263 @@
// "Where to search": the game's memory map turned into a short list a person
// can check and uncheck, and that choice turned back into address ranges.
//
// A real game has thousands of mapped blocks and gigabytes of writable memory,
// most of it graphics and audio buffers. Searching only the parts that can
// hold game values is the biggest speed-up there is.
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. */
export interface MemRange {
start: bigint;
end: bigint;
}
export type RegionGroup = 'game' | 'heap' | 'modules' | 'system';
export const REGION_GROUP_LABELS: Record<RegionGroup, string> = {
game: 'The game itself',
heap: 'Memory the game asked for while running',
modules: 'Libraries the game loaded',
system: 'Stacks and system buffers',
};
/** One line of the checklist. It can stand for a single block or for many small ones lumped together. */
export interface RegionItem {
/**
* Stays the same from one launch of the game to the next where that is
* possible (names and sizes, never addresses), so a remembered choice still
* applies after a restart.
*/
id: string;
group: RegionGroup;
label: string;
bytes: number;
/** Disjoint, in address order. */
ranges: MemRange[];
/** How many separate blocks `ranges` holds. */
blocks: number;
/** True for a line that stands for "all the small ones" rather than one named thing. */
lump: boolean;
/** False for code and read-only data, which only a text search looks at. */
writable: boolean;
}
/** What the user chose. Kept per game. */
export interface ScopeChoice {
mode: 'all' | 'pick' | 'range';
/** Ids of the checklist lines that are unchecked ("pick"). */
off: string[];
/** Custom range as typed, hex ("range"). */
from: string;
to: string;
}
export const DEFAULT_SCOPE: ScopeChoice = { mode: 'all', off: [], from: '', to: '' };
/**
* Heap blocks at least this big get a line of their own; they are the ones
* worth unchecking. A running game has thousands of smaller blocks, which are
* lumped by size so the list stays short enough to read.
*/
const OWN_LINE_BYTES = 64 * 1024 * 1024;
const LARGE_BYTES = 16 * 1024 * 1024;
const MID_BYTES = 1024 * 1024;
const byStart = (a: MemRange, b: MemRange) => (a.start < b.start ? -1 : a.start > b.start ? 1 : 0);
/** Sorts ranges and joins the ones that touch or overlap. */
export function mergeRanges(ranges: MemRange[]): MemRange[] {
const out: MemRange[] = [];
for (const r of [...ranges].sort(byStart)) {
if (r.end <= r.start) continue;
const last = out[out.length - 1];
if (last && r.start <= last.end) {
if (r.end > last.end) last.end = r.end;
} else {
out.push({ start: r.start, end: r.end });
}
}
return out;
}
export function rangesBytes(ranges: MemRange[]): number {
let n = 0n;
for (const r of ranges) n += r.end - r.start;
return Number(n);
}
/** The parts of `ranges` that fall inside [start, end). */
export function clipRanges(ranges: MemRange[], start: bigint, end: bigint): MemRange[] {
const out: MemRange[] = [];
for (const r of ranges) {
const s = r.start > start ? r.start : start;
const e = r.end < end ? r.end : end;
if (s < e) out.push({ start: s, end: e });
}
return out;
}
const isLibrary = (name: string) => /\.(s?prx|self|elf|bin)$/i.test(name);
// "(aux)" is what ps5debug-NG calls memory it found by walking the page tables; it has no name of its own.
const isAnonymous = (name: string) => name === '' || name === '(aux)' || /^anon:/i.test(name);
/**
* Builds the checklist from the memory map. `image` is the address range of
* the game's own executable (see `imageRange`), or null when it is not known.
* Unreadable reservations are left out; they hold nothing.
*/
export function listRegions(maps: MapEntry[], image: MemRange | null): RegionItem[] {
interface Bucket {
id: string;
group: RegionGroup;
label: string;
writable: boolean;
ranges: MemRange[];
lump?: boolean;
}
const buckets = new Map<string, Bucket>();
const add = (id: string, group: RegionGroup, label: string, writable: boolean, m: MemRange) => {
let b = buckets.get(id);
if (!b) buckets.set(id, (b = { id, group, label, writable, ranges: [] }));
b.ranges.push({ start: m.start, end: m.end });
};
const heapWritable: MemRange[] = [];
for (const m of maps) {
if (!(m.prot & PROT.READ) || m.end <= m.start) continue;
// Same rule as the number search always used: values live in memory that can be written and is not code.
const writable = (m.prot & PROT.WRITE) !== 0 && !(m.prot & PROT.EXEC);
if (image && m.start >= image.start && m.start < image.end) {
if (writable) add('game:data', 'game', "The game's own data", true, m);
else add('game:fixed', 'game', "The game's code and fixed data", false, m);
} else if (isAnonymous(m.name)) {
if (writable) heapWritable.push(m);
else add('heap:fixed', 'heap', 'Read-only blocks', false, m);
} else if (isLibrary(m.name)) {
if (writable) add(`mod:${m.name}`, 'modules', m.name, true, m);
else add('mod:fixed', 'modules', "The libraries' code and fixed data", false, m);
} else if (writable) {
add(`sys:${m.name}`, 'system', m.name, true, m);
} else {
add('sys:fixed', 'system', 'Read-only system areas', false, m);
}
}
const items: RegionItem[] = [];
const push = (b: Bucket) => {
const ranges = mergeRanges(b.ranges);
items.push({ id: b.id, group: b.group, label: b.label, writable: b.writable, ranges, blocks: ranges.length, bytes: rangesBytes(ranges), lump: b.lump === true });
};
// Named areas: the big ones keep a line each; the dozens of small libraries and
// the hundreds of thread stacks share one line per group.
const smallSystem: Bucket = { lump: true, id: 'sys:small', group: 'system', label: 'Everything smaller (thread stacks and the like)', writable: true, ranges: [] };
const smallModules: Bucket = { lump: true, id: 'mod:small', group: 'modules', label: 'Data of the smaller libraries', writable: true, ranges: [] };
for (const b of buckets.values()) {
const small = b.writable && rangesBytes(mergeRanges(b.ranges)) < MID_BYTES;
if (small && b.group === 'system') smallSystem.ranges.push(...b.ranges);
else if (small && b.group === 'modules') smallModules.ranges.push(...b.ranges);
else push(b);
}
if (smallSystem.ranges.length) push(smallSystem);
if (smallModules.ranges.length) push(smallModules);
// The heap: neighboring blocks are one allocation as far as anyone searching is concerned.
const large: Bucket = { lump: true, id: 'heap:large', group: 'heap', label: 'Blocks of 16 to 64 MB', writable: true, ranges: [] };
const mid: Bucket = { lump: true, id: 'heap:mid', group: 'heap', label: 'Blocks of 1 to 16 MB', writable: true, ranges: [] };
const small: Bucket = { lump: true, id: 'heap:small', group: 'heap', label: 'Blocks under 1 MB', writable: true, ranges: [] };
const sameSize = new Map<number, number>();
for (const block of mergeRanges(heapWritable)) {
const bytes = Number(block.end - block.start);
if (bytes >= OWN_LINE_BYTES) {
// Addresses change between launches, sizes mostly do not: the id is the size, plus a counter for twins.
const n = sameSize.get(bytes) ?? 0;
sameSize.set(bytes, n + 1);
push({ id: `heap:${bytes}:${n}`, group: 'heap', label: `Block at 0x${block.start.toString(16).toUpperCase()}`, writable: true, ranges: [block] });
} else if (bytes >= LARGE_BYTES) large.ranges.push(block);
else if (bytes >= MID_BYTES) mid.ranges.push(block);
else small.ranges.push(block);
}
if (large.ranges.length) push(large);
if (mid.ranges.length) push(mid);
if (small.ranges.length) push(small);
const order: RegionGroup[] = ['game', 'heap', 'modules', 'system'];
return items.sort((a, b) => order.indexOf(a.group) - order.indexOf(b.group) || b.bytes - a.bytes || (a.id < b.id ? -1 : 1));
}
/**
* Ids of the lines that are one big thing of at least `minBytes`: the "uncheck
* the huge ones" button. The game's own data is never among them, and neither
* are the lumped lines, which are big only because they hold many small blocks.
*/
export function hugeRegionIds(items: RegionItem[], minBytes: number): string[] {
return items.filter((i) => i.writable && !i.lump && i.bytes >= minBytes && i.group !== 'game').map((i) => i.id);
}
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.trim()}" is not a hex address. An address looks like 0x880000000.`);
return BigInt('0x' + t);
}
/**
* The address ranges a choice stands for, merged and in address order.
* `fixed` adds code and read-only data (text search only). A custom range is
* cut down to the memory that actually exists inside it.
*/
export function scopeRanges(items: RegionItem[], choice: ScopeChoice, opts: { fixed?: boolean } = {}): MemRange[] {
const usable = items.filter((i) => i.writable || opts.fixed);
if (choice.mode === 'pick') {
const off = new Set(choice.off);
return mergeRanges(usable.filter((i) => !off.has(i.id)).flatMap((i) => i.ranges));
}
const all = mergeRanges(usable.flatMap((i) => i.ranges));
if (choice.mode === 'all') return all;
const from = parseHex(choice.from, 'start');
const to = parseHex(choice.to, 'end');
if (to <= from) throw new Error('The end address has to be after the start address');
return clipRanges(all, from, to);
}
/** Everything a search of this kind could cover, for "Searching 1.2 GB of 9.6 GB". */
export function totalBytes(items: RegionItem[], opts: { fixed?: boolean } = {}): number {
return rangesBytes(mergeRanges(items.filter((i) => i.writable || opts.fixed).flatMap((i) => i.ranges)));
}
/**
* 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 = SCAN_SLICE_BYTES): ScanRegion[] {
const out: ScanRegion[] = [];
for (const r of mergeRanges(ranges)) {
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 });
start += BigInt(take);
}
}
return out;
}
export function loadScope(titleId: string): ScopeChoice {
const saved = loadJson<Partial<ScopeChoice> | null>(`scope.${titleId}`, null);
if (!saved || (saved.mode !== 'all' && saved.mode !== 'pick' && saved.mode !== 'range')) return { ...DEFAULT_SCOPE };
return {
mode: saved.mode,
off: Array.isArray(saved.off) ? saved.off.filter((x) => typeof x === 'string') : [],
from: typeof saved.from === 'string' ? saved.from : '',
to: typeof saved.to === 'string' ? saved.to : '',
};
}
export function saveScope(titleId: string, choice: ScopeChoice): void {
saveJson(`scope.${titleId}`, choice);
}
Loaded 100 of 202 files, more files were not shown because too many files have changed in this diff. Show more