mirror of
https://github.com/TiagoVal97/cheatspecter.git
synced 2026-10-18 07:15:16 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
08c9abb573 |
No files matched your search
@@ -1,12 +1,25 @@
|
||||
## What this changes
|
||||
## Summary
|
||||
|
||||
<!-- One or two sentences. Link the issue if there is one. -->
|
||||
<!-- What this changes and why, in a sentence or two. Link the issue: "Closes #123". -->
|
||||
|
||||
## Type of change
|
||||
|
||||
- [ ] Bug fix
|
||||
- [ ] New feature
|
||||
- [ ] Lesson or documentation
|
||||
- [ ] Refactor or tests only
|
||||
|
||||
## How it was tested
|
||||
|
||||
- [ ] `npm run typecheck` and `npm test` pass
|
||||
- [ ] New behavior has a test
|
||||
- [ ] Checked in a browser at phone and desktop widths (for interface changes)
|
||||
- [ ] Tested on a real PS5 (say which firmware and game), or: not tested on hardware
|
||||
- [ ] Interface changes checked in a browser at phone and desktop widths
|
||||
- [ ] Tried on a real PS5 (firmware and game: ...), or: not tried on hardware
|
||||
|
||||
## Anything reviewers should know
|
||||
## Screenshots
|
||||
|
||||
<!-- For interface changes: before and after. Delete this section otherwise. -->
|
||||
|
||||
## Notes for the reviewer
|
||||
|
||||
<!-- Anything risky, unfinished or worth a closer look. -->
|
||||
@@ -1,5 +1,43 @@
|
||||
# Changelog
|
||||
|
||||
## 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
|
||||
|
||||
+29
-3
@@ -114,9 +114,10 @@ capitals, spacing and a final period are ignored), `explain`, optionally `text`.
|
||||
}
|
||||
```
|
||||
|
||||
- `game`: which of the lesson's games. Steps that follow each other in the same game carry on
|
||||
in it (the learner's search and cheats are still there), unless a step has `"fresh": true`,
|
||||
which starts a new game with no cheats.
|
||||
- `game`: which of the lesson's games. Steps in the same game carry on in it (the learner's
|
||||
search and cheats are still there), also when a reading or a question sits between them,
|
||||
unless a step has `"fresh": true`, which starts a new game with no cheats. Mark a step
|
||||
`fresh` when it does not use anything from the steps before it.
|
||||
- `tool`: the tab shown when the step opens: `find`, `memory`, `code` or `cheats`.
|
||||
- `hints`: one to six, revealed one at a time, weakest first.
|
||||
- `"challenge": true` marks a step the learner does alone. Give the goal and no steps. Every
|
||||
@@ -125,6 +126,31 @@ capitals, spacing and a final period are ignored), `explain`, optionally `text`.
|
||||
The learner also gets a **Show me** button. It reaches the goal with the same tools and writes
|
||||
down what it did; you do not script it.
|
||||
|
||||
### Writing practice steps that hold up
|
||||
|
||||
A learner does not always arrive at a step the way you wrote it: they skip, come back the next
|
||||
day (the game starts over), restart the game when not asked, or type the wrong number. The app
|
||||
helps: when a step builds on earlier ones that were not done in the running game, it says so
|
||||
and offers the way back; and when a search has dropped the value a goal is about, it says that
|
||||
too. The text has to do its part:
|
||||
|
||||
- **Call things what the app calls them.** Bold in a practice step is for what the learner
|
||||
presses or looks at, and the checker refuses a bold name that is neither in the app nor in
|
||||
the game. (Examples to type, such as a cheat's name, go in `EXAMPLES` in the checker.)
|
||||
- **Do not say where on the screen something is.** The practice game is at the bottom of a
|
||||
phone and beside the lesson in a wide window; a table has column headings on one and not on
|
||||
the other. Say "the practice game's panel", "the box on the row".
|
||||
- **Do not assume a button that only exists in one state.** "Press New search" is wrong when
|
||||
no search is going; write "if the Find tab shows a search, press New search".
|
||||
- **Follow your own hints in the app**, literally, in the game as it starts. A game is laid out
|
||||
the same way for every learner, so what you see is what they get: if the pointer search
|
||||
offers two paths, the hint has to say which one.
|
||||
- **Order a goal's parts so that only the real thing passes.** "Make a cheat that survives the
|
||||
update" is `off`, then `updated`, then `off` again; with `updated` first, a cheat made after
|
||||
the update would pass.
|
||||
- An older CheatSpecter reads these same files. Stay within the format, and keep text true for
|
||||
it as well: in 0.4.0 the game started over after every reading or question.
|
||||
|
||||
**`soon`**: `text` explaining practice that the app cannot offer yet.
|
||||
|
||||
## Goals
|
||||
|
||||
@@ -162,7 +162,7 @@
|
||||
{
|
||||
"kind": "practice",
|
||||
"title": "A number that is not where you look",
|
||||
"text": "The rally car shows **Fuel** with a decimal point, and **Use boost** burns some.\n\nTry it the old way first if you like: search for the fuel as **4 bytes**, boost, and narrow down. The list runs dry, because the fuel was never there.\n\nThen do it properly:\n\n1. Press **New search**.\n2. Set **Type** to **Auto**.\n3. Search for the fuel number, boost, type the new number, narrow down.\n\nEach match says which type it is. Set the fuel to **500**.",
|
||||
"text": "The rally car shows **Fuel** with a decimal point, and **Use boost** burns some.\n\nTry it the old way first if you like: search for 50 as **4 bytes**, then boost. The new number, 42.5, is not even accepted as a whole number, and whatever you narrow down with instead, the list runs dry. The fuel was never in it.\n\nThen do it properly:\n\n1. Press **New search**, if you tried the old way.\n2. Set **Type** to **Auto**.\n3. Search for the fuel number, boost, type the new number, narrow down.\n\nEach match says which type it is. Set the fuel to **500**.",
|
||||
"game": "race",
|
||||
"tool": "find",
|
||||
"goal": {
|
||||
@@ -173,7 +173,7 @@
|
||||
"value": 500
|
||||
},
|
||||
"hints": [
|
||||
"Type is the first dropdown in the Find tab. It can only be changed before a search starts, so press New search first.",
|
||||
"Type is the first dropdown in the Find tab. It can only be changed before a search starts, so if one is going, press New search first.",
|
||||
"With Auto, type the fuel exactly as shown, for example 50 or 42.5.",
|
||||
"One boost burns 7.5. After one boost from 50, narrow down with 42.5.",
|
||||
"The match that says \"Float\" is the fuel. Type 500 in its box and press Set."
|
||||
@@ -201,7 +201,7 @@
|
||||
{
|
||||
"kind": "practice",
|
||||
"title": "Stop the clock",
|
||||
"text": "Find the **Time left** clock and freeze it.\n\n1. Press **New search**. Set **Type** to **Float** and **Value is** to **Between**.\n2. Type **0** in the first box and **100** in the second, and press **Search**.\n3. Set **Value now is** to **Went down** and press **Narrow down**. Wait a second or two and press it again. Repeat until a few are left.\n4. Freeze the one that is counting down, with its **padlock**.",
|
||||
"text": "Find the **Time left** clock and freeze it.\n\n1. If the Find tab shows a search, press **New search**. Then set **Type** to **Float** and **Value is** to **Between**.\n2. Type **0** in the first box and **100** in the second, and press **Search**.\n3. Set **Value now is** to **Went down** and press **Narrow down**. Wait a second or two and press it again. Repeat until a few are left.\n4. Freeze the one that is counting down, with its **padlock**.",
|
||||
"game": "race",
|
||||
"tool": "find",
|
||||
"goal": {
|
||||
@@ -215,7 +215,8 @@
|
||||
"After the Between search, do not type anything more. Just choose \"Went down\" and press Narrow down every couple of seconds.",
|
||||
"In the list, the right row is the one whose Value keeps dropping by one each second. Press its padlock."
|
||||
],
|
||||
"done": "Time stands still. You searched without typing the value once, and froze a number the game was changing on its own."
|
||||
"done": "Time stands still. You searched without typing the value once, and froze a number the game was changing on its own.",
|
||||
"fresh": true
|
||||
},
|
||||
{
|
||||
"kind": "practice",
|
||||
|
||||
@@ -54,7 +54,8 @@
|
||||
"text": "",
|
||||
"question": "A line reads \"jne 0x401200\". You want it to jump always, whatever was compared. What should its first word be?",
|
||||
"accept": [
|
||||
"jmp"
|
||||
"jmp",
|
||||
"jmp 0x401200"
|
||||
],
|
||||
"explain": "`jmp` jumps without asking. Swapping a conditional jump for `jmp`, or for no-ops, are the two smallest edits that flip a decision."
|
||||
},
|
||||
@@ -74,7 +75,7 @@
|
||||
{
|
||||
"kind": "soon",
|
||||
"title": "Practice",
|
||||
"text": "An editor for writing detours like the one described here is being built into CheatSpecter. When it is ready, this lesson will get a practice game where you add the \"only for the player\" check yourself.\n\nUntil then, lesson 10's practice game is a good place to read real instructions: use **Find what writes to this** on your health and press **Show** to see the code around it."
|
||||
"text": "The practice games in these lessons cannot run code that you add yet, so this lesson has no practice step of its own. When they can, you will add the \"only for the player\" check here yourself.\n\nUntil then, lesson 10's practice game is a good place to read real instructions: use **Find what writes to this** on your health and press **Show** to see the code around it."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -157,7 +157,7 @@
|
||||
{
|
||||
"kind": "practice",
|
||||
"title": "The first search",
|
||||
"text": "The practice game at the bottom of the window shows your **Ammo**, and **Fire** is you pulling the trigger.\n\nIn the **Find** tab:\n\n1. Leave **Type** on 4 bytes and **Value is** on Exactly. That is how most whole numbers in games are kept.\n2. Type the ammo number you see into **Value**.\n3. Press **Search**.\n\nYou get a list of every place that holds that number right now. Do not fire yet.",
|
||||
"text": "The practice game shows your **Ammo**, and **Fire** is you pulling the trigger.\n\nIn the **Find** tab:\n\n1. Leave **Type** on 4 bytes and **Value is** on Exactly. That is how most whole numbers in games are kept.\n2. Type the ammo number you see into **Value**.\n3. Press **Search**.\n\nYou get a list of every place that holds that number right now. Do not fire yet.",
|
||||
"game": "range",
|
||||
"tool": "find",
|
||||
"goal": {
|
||||
@@ -167,7 +167,7 @@
|
||||
"atMost": 2000
|
||||
},
|
||||
"hints": [
|
||||
"The number to search for is the one under \"Ammo\" at the bottom of the window. It starts at 30.",
|
||||
"The number to search for is the one under \"Ammo\" in the practice game. It starts at 30.",
|
||||
"In the Find tab, type 30 into the Value box and press Search. If you already fired, type the number shown now instead."
|
||||
],
|
||||
"done": "That list is every place holding the number, your real ammo among them. Next, you thin it out."
|
||||
@@ -194,7 +194,7 @@
|
||||
{
|
||||
"kind": "practice",
|
||||
"title": "Change it",
|
||||
"text": "The match has a box next to it under **Change to**. Whatever you put there is written straight into the game's memory.\n\n1. Type **999** into the box on that row.\n2. Press **Set**.\n\nThen look at the game at the bottom of the window.",
|
||||
"text": "The match has an empty box on its row, followed by a **Set** button. Whatever you put there is written straight into the game's memory.\n\n1. Type **999** into the box on that row.\n2. Press **Set**.\n\nThen look at the Ammo number in the practice game.",
|
||||
"game": "range",
|
||||
"tool": "find",
|
||||
"goal": {
|
||||
|
||||
@@ -175,7 +175,7 @@
|
||||
{
|
||||
"kind": "practice",
|
||||
"title": "Switch it off",
|
||||
"text": "Next to the instruction is a button: **Disable it**. Press it.\n\nThe tool stops watching, overwrites the instruction with no-ops, and saves the whole thing as a cheat with an on/off switch under **Your cheats**.",
|
||||
"text": "Next to the instruction is a button: **Disable it**. Press it.\n\nThe tool stops watching, overwrites the instruction with no-ops, saves the whole thing as a cheat with an on/off switch under **Your cheats**, and takes you to that tab.",
|
||||
"game": "range",
|
||||
"tool": "code",
|
||||
"goal": {
|
||||
|
||||
@@ -221,12 +221,17 @@
|
||||
{
|
||||
"kind": "practice",
|
||||
"title": "On your own: free potions, any version",
|
||||
"text": "Make potions free, install the shop's update, and have potions still be free afterward.",
|
||||
"text": "Make potions free. Then install the shop's update, and have potions still be free afterward.\n\nThe order matters: the cheat has to be one you made before the update.",
|
||||
"game": "shop",
|
||||
"tool": "find",
|
||||
"goal": {
|
||||
"check": "all",
|
||||
"of": [
|
||||
{
|
||||
"check": "off",
|
||||
"label": "Switch off the code that takes your coins",
|
||||
"action": "buy"
|
||||
},
|
||||
{
|
||||
"check": "updated",
|
||||
"label": "Install the game update"
|
||||
@@ -247,8 +252,9 @@
|
||||
},
|
||||
"hints": [
|
||||
"Make the cheat first, the lesson 6 way: find the coins, find what writes to them, disable it.",
|
||||
"Then press \"Install game update\" and switch your cheat back on in Your cheats.",
|
||||
"Buy three potions to prove it."
|
||||
"Then press \"Install game update\". The update switches the cheat off, so switch it back on in Your cheats.",
|
||||
"Buy three potions to prove it.",
|
||||
"If you installed the update before making the cheat, press \"Start this step again\" to get the first version of the game back."
|
||||
],
|
||||
"done": "A cheat that outlives a restart and an update. Most cheats people share are exactly this: a named switch, a pattern, and a few no-ops.",
|
||||
"fresh": true,
|
||||
|
||||
@@ -83,7 +83,7 @@
|
||||
{
|
||||
"kind": "read",
|
||||
"title": "At your own risk",
|
||||
"text": "Changing a running game's memory is never completely safe, and what you do with these tools is your choice and **your responsibility**. Nobody can promise that a change is harmless.\n\n- **Crashes.** A wrong value or a wrong patch can stop the game on the spot. Anything not saved is lost.\n- **Saves.** A game can write a changed value into your save file, and it stays there after the cheat is off.\n- **Accounts.** Games that talk to online services can notice changed values, and the penalty can be a ban, sometimes for the console and not only the game.\n\nCheatSpecter has only been tested on one jailbroken PS5, on firmware 13.60. The rest of this lesson is about keeping the risk small: back up, change one thing at a time, and know how to put everything back."
|
||||
"text": "Changing a running game's memory is never completely safe, and what you do with these tools is your choice and **your responsibility**. Nobody can promise that a change is harmless.\n\n- **Crashes.** A wrong value or a wrong patch can stop the game on the spot. Anything not saved is lost.\n- **Saves.** A game can write a changed value into your save file, and it stays there after the cheat is off.\n\nCheatSpecter has only been tested on one jailbroken PS5, on firmware 13.60. The rest of this lesson is about keeping the risk small: back up, change one thing at a time, and know how to put everything back."
|
||||
},
|
||||
{
|
||||
"kind": "read",
|
||||
|
||||
@@ -152,7 +152,7 @@
|
||||
{
|
||||
"kind": "practice",
|
||||
"title": "Freeze your ammo",
|
||||
"text": "First find your ammo, the way you did in lesson 2: search for the number, fire, narrow down, until one match is left.\n\nThen press the **padlock** at the end of that row. It turns yellow: the value is frozen at what it is now. The row moves to the top of the list and stays there.\n\nFire three times, slowly, and watch the Ammo number.",
|
||||
"text": "First find your ammo, the way you did in lesson 2: search for the number, fire, narrow down, until one match is left.\n\nThen press the **padlock** at the end of that row. It turns yellow: the value is frozen at what it is now. The row also stays in the list from now on, even if a later search would drop it.\n\nFire three times, slowly, and watch the Ammo number.",
|
||||
"game": "range",
|
||||
"tool": "find",
|
||||
"goal": {
|
||||
@@ -191,7 +191,7 @@
|
||||
]
|
||||
},
|
||||
"hints": [
|
||||
"\"Restart game\" is on the practice game's bar at the bottom of the window, top right."
|
||||
"\"Restart game\" is on the practice game's own panel, next to its name."
|
||||
],
|
||||
"done": "The ammo went down, and your frozen row is gone. The freeze belonged to an address in the game that closed. In the new one, the ammo lives somewhere else, and the tool will not keep writing to an address that may now hold something unrelated."
|
||||
},
|
||||
|
||||
@@ -86,7 +86,7 @@
|
||||
{
|
||||
"kind": "practice",
|
||||
"title": "See how crowded memory is",
|
||||
"text": "Time to look inside a game. A small practice game is running at the bottom of the window. It shows **Lives** and **Steps**. The tools above it are the real ones, pointed at that game.\n\nIn the **Find** tab, type the number of lives you see into **Value** and press **Search**.\n\nLook at how many matches come back. Every one of them is a place in this tiny game's memory that holds the same number as your lives. A real game has thousands of times more memory than this.",
|
||||
"text": "Time to look inside a game. A small practice game is running in its own panel on this page, marked **Practice game**. It shows **Lives** and **Steps**. The tools on this page are the real ones, pointed at that game.\n\nIn the **Find** tab, type the number of lives you see into **Value** and press **Search**.\n\nLook at how many matches come back. Every one of them is a place in this tiny game's memory that holds the same number as your lives. A real game has thousands of times more memory than this.",
|
||||
"game": "lives",
|
||||
"tool": "find",
|
||||
"goal": {
|
||||
@@ -104,7 +104,7 @@
|
||||
{
|
||||
"kind": "practice",
|
||||
"title": "Restart, and watch it move",
|
||||
"text": "Look at the first few addresses in your list and remember roughly what they look like.\n\nNow press **Restart game** at the top right of the practice game, as if you had closed the game and started it again. Then search for the lives once more.\n\nCompare the addresses with the ones from before.",
|
||||
"text": "Look at the first few addresses in your list and remember roughly what they look like.\n\nNow press **Restart game** on the practice game's panel, as if you had closed the game and started it again. Then search for the lives once more.\n\nCompare the addresses with the ones from before.",
|
||||
"game": "lives",
|
||||
"tool": "find",
|
||||
"goal": {
|
||||
@@ -123,7 +123,7 @@
|
||||
]
|
||||
},
|
||||
"hints": [
|
||||
"\"Restart game\" is the small button on the practice game's own bar at the bottom of the window.",
|
||||
"\"Restart game\" is the small button on the practice game's own panel, next to its name.",
|
||||
"After the restart the Find tab is empty again, because everything it knew was about the game that just closed. Type 3 and press Search."
|
||||
],
|
||||
"done": "Different addresses, every one of them. Nothing you find by searching outlives the game it was found in. Keep that in mind; lessons 6 and 9 are about getting around it."
|
||||
|
||||
@@ -180,7 +180,7 @@
|
||||
{
|
||||
"kind": "practice",
|
||||
"title": "Fill it and freeze it",
|
||||
"text": "Look at the **Value** column of what is left. One of them is your health, and you can now see what \"full\" is in this game.\n\nPress the **padlock** on its row to freeze it, then take three hits.\n\n> tip: If more than one row is left, take a hit and see which value drops by the same amount as the bar. That one is your health.",
|
||||
"text": "Look at the value on each row that is left. One of them is your health, and you can now see what \"full\" is in this game.\n\nPress the **padlock** on its row to freeze it, then take three hits.\n\n> tip: If more than one row is left, take a hit and see which value drops by the same amount as the bar. That one is your health.",
|
||||
"game": "arena",
|
||||
"tool": "find",
|
||||
"goal": {
|
||||
|
||||
@@ -258,7 +258,7 @@
|
||||
{
|
||||
"kind": "practice",
|
||||
"title": "On your own: a longer way round",
|
||||
"text": "In this game the lives are two notes away from the fixed spot. Make a cheat that holds **99 lives**, then restart the game and show that it still works.",
|
||||
"text": "In this game the lives are two notes away from the fixed spot, and the search may offer you more than one path. Remember the last step: not every path is real. Make a cheat that holds **99 lives**, then restart the game and show that it still works.",
|
||||
"game": "tower",
|
||||
"tool": "find",
|
||||
"goal": {
|
||||
@@ -286,8 +286,9 @@
|
||||
},
|
||||
"hints": [
|
||||
"Find the Lives number. Falling off changes it. There are many small numbers in memory, so it may take a few rounds.",
|
||||
"… then \"Make it survive restarts…\", Search, and Use on the first path. Set the value to 99 in the form and save.",
|
||||
"Restart the game, then switch the cheat on in Your cheats."
|
||||
"… then \"Make it survive restarts…\" and Search. If more than one path comes back, do not take the shortest on trust: a path with 1 pointer here is a coincidence, something that happens to sit near the lives in this run. Press Use on the one with 2 pointers, set the value to 99 in the form and save.",
|
||||
"Restart the game, then switch the cheat on in Your cheats.",
|
||||
"If the lives do not change after the restart, the path you used was the coincidence. Find the lives again, make a new cheat from the other path, and switch that one on."
|
||||
],
|
||||
"done": "Two pointers deep, and it held. Between switching off instructions and following pointers, you can now make a cheat for almost anything that survives a restart.",
|
||||
"fresh": true,
|
||||
|
||||
@@ -122,7 +122,7 @@
|
||||
{
|
||||
"kind": "practice",
|
||||
"title": "Put it in a group",
|
||||
"text": "Open the **…** menu on the cheat again and choose **Put in a group…**. Type a name for a new group, such as **Weapons**.",
|
||||
"text": "Open the **…** menu on the cheat again and choose **Put in a group…**. Type a name for a new group, such as **Weapons**, and press **Create**.",
|
||||
"game": "range",
|
||||
"tool": "cheats",
|
||||
"goal": {
|
||||
@@ -135,7 +135,7 @@
|
||||
},
|
||||
"hints": [
|
||||
"Your cheats tab, … on the cheat, \"Put in a group…\".",
|
||||
"Type a group name in the box and confirm."
|
||||
"Type a group name in the box and press Create."
|
||||
],
|
||||
"done": "Groups get their own heading and one switch for everything inside. With thirty cheats for one game, you will be glad of them."
|
||||
},
|
||||
|
||||
@@ -210,7 +210,7 @@
|
||||
]
|
||||
},
|
||||
"hints": [
|
||||
"First switch your \"take damage\" cheat off in Your cheats, so the guard can be hurt again.",
|
||||
"If your \"take damage\" cheat from before is still on in Your cheats, switch it off first, so the guard can be hurt again.",
|
||||
"Freezing works on one address: yours. Find your health and press its padlock.",
|
||||
"With your health frozen, let the guard hit you three times, then hit him until his health reaches 0."
|
||||
],
|
||||
|
||||
+51
-19
@@ -115,6 +115,15 @@ function createThread(info, regs) {
|
||||
return t;
|
||||
}
|
||||
|
||||
/**
|
||||
* A region of any size that takes no memory here: it reads as zeros and
|
||||
* forgets writes. Push some onto `mock.game.regions` to give the game a
|
||||
* real-sized memory map (gigabytes, thousands of blocks).
|
||||
*/
|
||||
export function blankRegion(name, start, length, prot = 3) {
|
||||
return { name, start, prot, blank: true, buf: { length, copy: () => 0 } };
|
||||
}
|
||||
|
||||
function createGame() {
|
||||
// int3 padding with real instructions at the interesting offsets, each
|
||||
// followed by a ret, so it disassembles cleanly.
|
||||
@@ -222,6 +231,7 @@ function createGame() {
|
||||
function write(addr, data) {
|
||||
const hit = locate(addr, data.length);
|
||||
if (!hit) return false;
|
||||
if (hit.r.blank) return true;
|
||||
data.copy(hit.r.buf, hit.off);
|
||||
return true;
|
||||
}
|
||||
@@ -561,6 +571,14 @@ export function startMock(opts = {}) {
|
||||
const game = createGame();
|
||||
if (opts.rtti) addTypeInfo(game);
|
||||
const state = { authed: false, notifications: [], foreground: true };
|
||||
// Tests change these while the mock runs: a pause per scanned range, and a console that stops answering mid-scan.
|
||||
state.scanDelayMs = opts.scanDelayMs ?? 0;
|
||||
state.scanHang = false;
|
||||
state.countDelayMs = 0; // the same pause for each range of a "narrow down"
|
||||
const scanPause = async () => {
|
||||
if (state.scanHang) await new Promise(() => {});
|
||||
if (state.scanDelayMs) await new Promise((r) => setTimeout(r, state.scanDelayMs));
|
||||
};
|
||||
const authLfsr = makeLfsr(0x12345678, 0x9abcdef0, 0x0f1e2d3c, 0x4b5a6978);
|
||||
|
||||
state.hazards = [];
|
||||
@@ -986,7 +1004,7 @@ export function startMock(opts = {}) {
|
||||
status(ST.SUCCESS);
|
||||
const pattern = needsValue ? await rd.read(lenData) : null;
|
||||
status(ST.SUCCESS);
|
||||
if (opts.scanDelayMs) await new Promise((r) => setTimeout(r, opts.scanDelayMs));
|
||||
await scanPause();
|
||||
const step = alignment || size;
|
||||
const mem = game.read(address, length);
|
||||
const upper = cmp === 4 ? pattern : null;
|
||||
@@ -1027,6 +1045,8 @@ export function startMock(opts = {}) {
|
||||
status(ST.SUCCESS);
|
||||
const pattern = needsValue && lenData ? await rd.read(lenData) : null;
|
||||
const entrySize = withPrev ? 4 + size : 4;
|
||||
if (state.scanHang) await new Promise(() => {});
|
||||
if (state.countDelayMs) await new Promise((r) => setTimeout(r, state.countDelayMs));
|
||||
for (;;) {
|
||||
const chunkLen = (await rd.read(4)).readUInt32LE(0);
|
||||
if (chunkLen === 0xffffffff) break;
|
||||
@@ -1185,7 +1205,7 @@ export function startMock(opts = {}) {
|
||||
status(ST.SUCCESS);
|
||||
const blob = await rd.read(d.readUInt32LE(17));
|
||||
status(ST.SUCCESS);
|
||||
if (opts.scanDelayMs) await new Promise((r) => setTimeout(r, opts.scanDelayMs));
|
||||
await scanPause();
|
||||
const mem = game.read(address, length);
|
||||
const results = [];
|
||||
for (let cursor = 0; cursor + 5 <= blob.length; ) {
|
||||
@@ -1252,32 +1272,41 @@ export function startMock(opts = {}) {
|
||||
status(ST.SUCCESS);
|
||||
const nseg = (await rd.read(4)).readUInt32LE(0);
|
||||
const segs = await rd.read(nseg * 12);
|
||||
if (opts.scanDelayMs) await new Promise((r) => setTimeout(r, opts.scanDelayMs));
|
||||
const records = [];
|
||||
let total = 0;
|
||||
for (let i = 0; i < nseg; i++) {
|
||||
const address = segs.readBigUInt64LE(i * 12);
|
||||
const length = segs.readUInt32LE(i * 12 + 8);
|
||||
total += length;
|
||||
const mem = game.read(address, length);
|
||||
for (let j = 0; j + size <= length; j += size) {
|
||||
const v = mem.subarray(j, j + size);
|
||||
if (!(flags & 8) && v.every((b) => b === 0)) continue; // zero slots are dropped unless asked for
|
||||
records.push({ address: address + BigInt(j), value: Buffer.from(v) });
|
||||
}
|
||||
}
|
||||
turbo = { valType, size, records };
|
||||
for (let i = 0; i < nseg; i++) total += segs.readUInt32LE(i * 12 + 8);
|
||||
// The plan first, then progress as each segment is done, like the console.
|
||||
const plan = Buffer.alloc(16);
|
||||
plan.writeBigUInt64LE(BigInt(Math.floor(total / size)), 0);
|
||||
plan.writeBigUInt64LE(BigInt(total), 8);
|
||||
sock.write(plan);
|
||||
const prog = Buffer.alloc(8);
|
||||
prog.writeBigUInt64LE(BigInt(total));
|
||||
sock.write(prog);
|
||||
let blankSlots = 0;
|
||||
let doneBytes = 0;
|
||||
for (let i = 0; i < nseg; i++) {
|
||||
const address = segs.readBigUInt64LE(i * 12);
|
||||
const length = segs.readUInt32LE(i * 12 + 8);
|
||||
await scanPause();
|
||||
if (game.regions.some((r) => r.blank && address >= r.start && address < r.start + BigInt(r.buf.length))) {
|
||||
// Blank regions are counted but not kept one by one: there can be gigabytes of them.
|
||||
blankSlots += Math.floor(length / size);
|
||||
} else {
|
||||
const mem = game.read(address, length);
|
||||
for (let j = 0; j + size <= length; j += size) {
|
||||
const v = mem.subarray(j, j + size);
|
||||
if (!(flags & 8) && v.every((b) => b === 0)) continue; // zero slots are dropped unless asked for
|
||||
records.push({ address: address + BigInt(j), value: Buffer.from(v) });
|
||||
}
|
||||
}
|
||||
doneBytes += length;
|
||||
const prog = Buffer.alloc(8);
|
||||
prog.writeBigUInt64LE(BigInt(doneBytes));
|
||||
sock.write(prog);
|
||||
}
|
||||
turbo = { valType, size, records, blankSlots: flags & 8 ? blankSlots : 0 };
|
||||
sentinel();
|
||||
const sum = Buffer.alloc(12);
|
||||
sum.writeUInt32LE(1, 0);
|
||||
sum.writeBigUInt64LE(BigInt(records.length), 4);
|
||||
sum.writeBigUInt64LE(BigInt(records.length + turbo.blankSlots), 4);
|
||||
sock.write(sum);
|
||||
status(ST.SUCCESS);
|
||||
break;
|
||||
@@ -1301,7 +1330,10 @@ export function startMock(opts = {}) {
|
||||
}
|
||||
}
|
||||
turbo.records = kept;
|
||||
turbo.blankSlots = 0; // blank memory never changes; the mock simply stops counting it
|
||||
}
|
||||
if (state.scanHang) await new Promise(() => {});
|
||||
if (state.countDelayMs) await new Promise((r) => setTimeout(r, state.countDelayMs));
|
||||
sentinel();
|
||||
const n = Buffer.alloc(8);
|
||||
n.writeBigUInt64LE(BigInt(kept.length));
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "cheatspecter",
|
||||
"version": "0.4.0",
|
||||
"version": "0.4.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "cheatspecter",
|
||||
"version": "0.4.0",
|
||||
"version": "0.4.1",
|
||||
"license": "GPL-3.0-only",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-use-controllable-state": "^1.2.6",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "cheatspecter",
|
||||
"version": "0.4.0",
|
||||
"version": "0.4.1",
|
||||
"private": true,
|
||||
"description": "Browser-based real-time modding tool for jailbroken PS5 consoles running a ps5debug payload.",
|
||||
"license": "GPL-3.0-only",
|
||||
|
||||
@@ -307,6 +307,7 @@ struct relay {
|
||||
int ws;
|
||||
int tcp;
|
||||
pthread_mutex_t wlock;
|
||||
int closed; /* a close frame has gone out; nothing may follow it. Guarded by wlock. */
|
||||
/* For browser_busy(); guarded by g_relays_lock. */
|
||||
int awaiting; /* the browser has sent something the debugger has not answered yet */
|
||||
int slow; /* the answer now arriving took long to start: a search streaming its results */
|
||||
@@ -397,6 +398,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);
|
||||
|
||||
@@ -12,7 +12,79 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import { checkIndex, checkLesson } from '../src/lib/learn.ts';
|
||||
import { PracticeGame } from '../src/lib/practice/game.ts';
|
||||
|
||||
const DEFAULT_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'learn');
|
||||
const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const DEFAULT_DIR = path.join(ROOT, 'learn');
|
||||
|
||||
// ---- what the words say ------------------------------------------------------------
|
||||
//
|
||||
// The format check cannot tell whether a lesson is true. These catch the ways
|
||||
// lesson text has gone wrong before: naming a button that the app calls
|
||||
// something else, saying where on the screen the practice game is (it is at the
|
||||
// bottom of a phone and beside the lesson on a wide screen), and wording the
|
||||
// project does not use.
|
||||
|
||||
/** Spellings and subjects the lessons stay away from, each with what to write instead. */
|
||||
const WORDING = [
|
||||
[/\b(colour|behaviour|favour|cancelled|cancelling|grey|centre|labelled|untick|learnt|whilst|afterwards|towards)/i, 'use US spelling'],
|
||||
[/\b(online|multiplayer|multi-player|single-player|singleplayer)\b/i, 'the lessons do not go into online or single-player play; the standing note is "use at your own risk"'],
|
||||
[/\b(at|on) the (bottom|top|left|right|side) of the (window|screen|page)\b/i, 'do not say where on the screen something is; the layout differs between a phone and a wide window. Name the thing instead ("the practice game\'s panel")'],
|
||||
];
|
||||
|
||||
/** Bold words in practice steps that are examples to type, not names of things on screen. */
|
||||
const EXAMPLES = ['Lots of gold', 'padlock'];
|
||||
|
||||
/** Everything the app can put on screen, as one long string: its source. */
|
||||
function appText() {
|
||||
let all = '';
|
||||
for (const dir of ['src/ui', 'src/lib']) {
|
||||
const full = path.join(ROOT, dir);
|
||||
for (const file of fs.readdirSync(full)) if (/\.tsx?$/.test(file)) all += fs.readFileSync(path.join(full, file), 'utf8') + '\n';
|
||||
}
|
||||
return all.replace(/\s+/g, ' ');
|
||||
}
|
||||
|
||||
/** Every piece of text a lesson shows, with where it is. */
|
||||
function textsOf(lesson) {
|
||||
const out = [
|
||||
['title', lesson.title],
|
||||
['summary', lesson.summary],
|
||||
];
|
||||
lesson.steps.forEach((s, i) => {
|
||||
const at = `steps[${i}]`;
|
||||
for (const key of ['title', 'text', 'question', 'explain', 'done']) if (typeof s[key] === 'string') out.push([`${at}.${key}`, s[key]]);
|
||||
for (const key of ['options', 'hints']) (s[key] ?? []).forEach((t, j) => out.push([`${at}.${key}[${j}]`, t]));
|
||||
if (s.goal) for (const g of s.goal.check === 'all' ? s.goal.of : [s.goal]) out.push([`${at}.goal.label`, g.label]);
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('../src/lib/learn.ts').Lesson} lesson
|
||||
* @param {string} app
|
||||
* @returns {string[]}
|
||||
*/
|
||||
function wordProblems(lesson, app) {
|
||||
const problems = [];
|
||||
for (const [where, text] of textsOf(lesson)) {
|
||||
for (const [pattern, instead] of WORDING) {
|
||||
const found = pattern.exec(text);
|
||||
if (found) problems.push(`${where}: "${found[0]}": ${instead}`);
|
||||
}
|
||||
}
|
||||
// In a practice step, bold is for what the learner presses or looks at. It has to be called that in the app or in the game.
|
||||
const games = JSON.stringify(lesson.games);
|
||||
lesson.steps.forEach((s, i) => {
|
||||
if (s.kind !== 'practice') return;
|
||||
for (const text of [s.text, s.done, ...s.hints]) {
|
||||
for (const [, name] of text.matchAll(/\*\*([^*]+)\*\*/g)) {
|
||||
if (/^\d/.test(name) || EXAMPLES.includes(name)) continue;
|
||||
if (app.includes(name) || games.includes(JSON.stringify(name).slice(0, -1))) continue;
|
||||
problems.push(`steps[${i}]: "${name}" is in bold like a button or a label, but nothing in the app or in the game is called that. Use the app's own words, or add it to EXAMPLES in scripts/check-lessons.mjs if it is something to type`);
|
||||
}
|
||||
}
|
||||
});
|
||||
return problems;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} [dir] the learn/ folder
|
||||
@@ -30,6 +102,7 @@ export function checkLessons(dir = DEFAULT_DIR) {
|
||||
return { problems, lessons, index };
|
||||
}
|
||||
const listed = new Set();
|
||||
const app = appText();
|
||||
for (const course of index.courses) {
|
||||
for (const ref of course.lessons) {
|
||||
const file = `lessons/${ref.id}.json`;
|
||||
@@ -42,6 +115,7 @@ export function checkLessons(dir = DEFAULT_DIR) {
|
||||
continue;
|
||||
}
|
||||
lessons.push(lesson);
|
||||
for (const p of wordProblems(lesson, app)) problems.push(`${file}: ${p}`);
|
||||
if (lesson.id !== ref.id) problems.push(`${file}: its id is "${lesson.id}", which should match its file name`);
|
||||
// The list of lessons is drawn from the index alone, so it has to say the same as the lesson.
|
||||
for (const key of ['title', 'summary', 'minutes']) {
|
||||
|
||||
@@ -5,6 +5,22 @@ 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 last
|
||||
// lasting control that had focus (menu items go away with their menu, so they
|
||||
// do not count), and each dialog returns focus to the one it was opened from.
|
||||
let lastFocused: HTMLElement | null = null
|
||||
if (typeof document !== "undefined") {
|
||||
const note = (e: Event) => {
|
||||
const el = e.target instanceof Element ? e.target.closest<HTMLElement>("button,a[href],input,select,textarea,[tabindex]") : null
|
||||
if (el && !el.closest('[role="menu"],[role="listbox"]')) lastFocused = el
|
||||
}
|
||||
document.addEventListener("focusin", note, true)
|
||||
// A menu's button opens on press without taking focus, so presses count too.
|
||||
document.addEventListener("pointerdown", note, true)
|
||||
}
|
||||
|
||||
function Dialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||
@@ -53,16 +69,26 @@ function DialogContent({
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
// Taken on the first render, before the dialog moves focus into itself.
|
||||
const opener = React.useRef(lastFocused)
|
||||
const { onCloseAutoFocus, ...rest } = props
|
||||
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",
|
||||
"fixed top-[50%] left-[50%] z-50 grid max-h-[calc(100dvh-2rem)] scroll-pb-32 w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] grid-cols-[minmax(0,1fr)] gap-4 overflow-y-auto overscroll-contain rounded-lg border bg-background p-6 shadow-lg duration-200 outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 sm:max-w-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
{...rest}
|
||||
onCloseAutoFocus={(e) => {
|
||||
onCloseAutoFocus?.(e)
|
||||
const el = opener.current
|
||||
if (e.defaultPrevented || !el || !el.isConnected) return
|
||||
e.preventDefault()
|
||||
el.focus()
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
@@ -101,7 +127,10 @@ function DialogFooter({
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn(
|
||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||
// Stays at the bottom edge of a dialog that scrolls, so Save and Cancel are
|
||||
// always in reach however long the form is. The margins take it out to the
|
||||
// dialog's own edges, over the padding, so nothing shows through beside it.
|
||||
"sticky -bottom-6 z-10 -mx-6 -mb-6 flex flex-col-reverse gap-2 bg-background px-6 pt-3 pb-6 before:pointer-events-none before:absolute before:inset-x-0 before:-top-4 before:h-4 before:bg-linear-to-t before:from-background before:to-transparent sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -34,7 +34,7 @@ function SelectTrigger({
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"flex w-fit items-center justify-between gap-2 rounded-md border border-input bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[placeholder]:text-muted-foreground data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
|
||||
"flex w-fit items-center justify-between gap-2 rounded-md border border-input bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[placeholder]:text-muted-foreground data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:block *:data-[slot=select-value]:min-w-0 *:data-[slot=select-value]:truncate dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -14,7 +14,8 @@ function Switch({
|
||||
data-slot="switch"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"peer group/switch inline-flex shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-[1.15rem] data-[size=default]:w-8 data-[size=sm]:h-3.5 data-[size=sm]:w-6 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input dark:data-[state=unchecked]:bg-input/80",
|
||||
// The switch is drawn small; the invisible ::after makes the area that takes a tap finger-sized.
|
||||
"peer group/switch relative inline-flex shrink-0 after:absolute after:-inset-x-1.5 after:-inset-y-3 after:content-[''] items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-[1.15rem] data-[size=default]:w-8 data-[size=sm]:h-3.5 data-[size=sm]:w-6 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input dark:data-[state=unchecked]:bg-input/80",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -9,6 +9,27 @@ export function hexToBytes(hex: string): Uint8Array {
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bytes as people type them: "90 90", "90,90", "0x90 0x90", "9090". Says
|
||||
* what is wrong in plain words; the shared hexToBytes drops anything it does
|
||||
* not know, which turns a typo into different bytes.
|
||||
*/
|
||||
export function parseTypedBytes(text: string): Uint8Array {
|
||||
const parts = text
|
||||
.trim()
|
||||
.split(/[\s,]+/)
|
||||
.filter((p) => p !== '')
|
||||
.map((p) => p.replace(/^0x/i, ''));
|
||||
if (parts.length === 0) throw new Error('Enter some bytes, for example: 90 90 90');
|
||||
const out: number[] = [];
|
||||
for (const part of parts) {
|
||||
if (!/^[0-9a-fA-F]+$/.test(part)) throw new Error(`"${part}" is not a byte. Bytes are written as two hex digits each (0 to 9 and A to F), for example: 90 90 90`);
|
||||
if (part.length % 2 !== 0) throw new Error(`"${part}" is half a byte. Each byte is two hex digits; write a single digit with a zero in front, like 0${part.slice(-1)}.`);
|
||||
for (let i = 0; i < part.length; i += 2) out.push(parseInt(part.slice(i, i + 2), 16));
|
||||
}
|
||||
return Uint8Array.from(out);
|
||||
}
|
||||
|
||||
export function bytesToHex(bytes: Uint8Array, sep = ''): string {
|
||||
let s = '';
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
|
||||
+16
-3
@@ -90,6 +90,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 +113,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 }> {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
+11
-2
@@ -35,6 +35,12 @@ export interface EngineReport {
|
||||
/** What was tried and how it went, one line each. */
|
||||
steps: string[];
|
||||
ms: number;
|
||||
/**
|
||||
* True when nothing was found and at least one of the looks stopped with an
|
||||
* error (the connection dropped, a read failed), so "not found" is not a
|
||||
* verdict on the game and looking again may well give another answer.
|
||||
*/
|
||||
failed?: boolean;
|
||||
}
|
||||
|
||||
export interface DetectOptions {
|
||||
@@ -95,12 +101,14 @@ async function run(ctx: GameContext, opts: DetectOptions): Promise<EngineReport>
|
||||
say('Looking at what the game has loaded');
|
||||
let guess: EngineGuess;
|
||||
let unfinished = false;
|
||||
let errors = 0;
|
||||
try {
|
||||
guess = await (opts.identify ?? ((c) => identifyEngine(c, { budgetMs: 8000 })))(ctx);
|
||||
unfinished = guess.engine === 'unknown' && guess.evidence.some((line) => line.startsWith('Stopped looking'));
|
||||
} catch (e) {
|
||||
guess = { engine: 'unknown', evidence: [`The first look at the game did not finish: ${reason(e)}`] };
|
||||
unfinished = true;
|
||||
errors++;
|
||||
}
|
||||
|
||||
const attempt = async (id: EngineId): Promise<EngineHelper | null> => {
|
||||
@@ -114,6 +122,7 @@ async function run(ctx: GameContext, opts: DetectOptions): Promise<EngineReport>
|
||||
return helper;
|
||||
} catch (e) {
|
||||
steps.push(`${title}: stopped with an error (${reason(e)}).`);
|
||||
errors++;
|
||||
return null;
|
||||
}
|
||||
};
|
||||
@@ -132,7 +141,7 @@ async function run(ctx: GameContext, opts: DetectOptions): Promise<EngineReport>
|
||||
}
|
||||
if (!helper) helper = await attempt('cpp');
|
||||
if (helper && guess.engine === 'unknown' && helper.engine !== 'cpp') guess = { engine: helper.engine, evidence: guess.evidence };
|
||||
return { helper, guess, steps, ms: Date.now() - started };
|
||||
return helper || errors === 0 ? { helper, guess, steps, ms: Date.now() - started } : { helper, guess, steps, ms: Date.now() - started, failed: true };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -144,7 +153,7 @@ export function detectEngine(ctx: GameContext, opts: DetectOptions = {}): Promis
|
||||
let job = opts.refresh ? undefined : pending.get(ctx);
|
||||
if (!job) {
|
||||
job = run(ctx, opts)
|
||||
.catch((e): EngineReport => ({ helper: null, guess: { engine: 'unknown', evidence: [] }, steps: [`Stopped with an error (${reason(e)}).`], ms: 0 }))
|
||||
.catch((e): EngineReport => ({ helper: null, guess: { engine: 'unknown', evidence: [] }, steps: [`Stopped with an error (${reason(e)}).`], ms: 0, failed: true }))
|
||||
.then((report) => {
|
||||
settled.set(ctx, report);
|
||||
return report;
|
||||
|
||||
@@ -0,0 +1,408 @@
|
||||
// The words the Find tab uses to say how a search ended: what was looked for,
|
||||
// in how much memory, what came back and what to try next. Kept apart from the
|
||||
// screens so every outcome can be checked by a test.
|
||||
|
||||
import { fmtBytes } from './bytes.ts';
|
||||
import { AUTO_KIND, roundedRange } from './scanner.ts';
|
||||
import type { CompareKind, ScanProgress, ValueKind } from './scanner.ts';
|
||||
|
||||
export interface Feedback {
|
||||
tone: 'info' | 'warn' | 'error';
|
||||
title: string;
|
||||
/** What to try next, most useful first. */
|
||||
tips: string[];
|
||||
}
|
||||
|
||||
/** What was typed into the number search. */
|
||||
export interface NumberQuery {
|
||||
kind: ValueKind;
|
||||
compare: CompareKind;
|
||||
value: string;
|
||||
value2: string;
|
||||
}
|
||||
|
||||
/** How much memory a search covered, and how much there was. */
|
||||
export interface Coverage {
|
||||
bytes: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
const n = (x: number) => x.toLocaleString('en-US');
|
||||
const plural = (count: number, one: string, many: string) => `${n(count)} ${count === 1 ? one : many}`;
|
||||
|
||||
/** "3.1 of 9.6 GB": both numbers in the unit of the bigger one. */
|
||||
export function ofBytes(done: number, total: number): string {
|
||||
const units: [number, string][] = [
|
||||
[1024 ** 3, 'GB'],
|
||||
[1024 ** 2, 'MB'],
|
||||
[1024, 'KB'],
|
||||
];
|
||||
for (const [size, name] of units) {
|
||||
if (total >= size) {
|
||||
// Enough digits that the number visibly moves: 0.31 of 1.04 GB, 3.1 of 19.6 GB, 120 of 480 MB.
|
||||
const digits = name !== 'GB' ? (total < 10 * size ? 1 : 0) : total < 10 * size ? 2 : 1;
|
||||
const whole = (total / size).toFixed(digits);
|
||||
// Never shows "9.6 of 9.6" before the end: the part done is rounded down.
|
||||
const part = Math.min(Number(whole), Math.floor((done / size) * 10 ** digits) / 10 ** digits).toFixed(digits);
|
||||
return `${done >= total ? whole : part} of ${whole} ${name}`;
|
||||
}
|
||||
}
|
||||
return `${done} of ${total} bytes`;
|
||||
}
|
||||
|
||||
/** "9.6 GB of memory (everything)" or "1.2 GB of the game's 9.6 GB". */
|
||||
export function coverageWords(c: Coverage): string {
|
||||
return c.bytes >= c.total ? `all ${fmtBytes(c.total)} of the game's memory` : `${fmtBytes(c.bytes)} of the game's ${fmtBytes(c.total)}`;
|
||||
}
|
||||
|
||||
const typeWords = (kind: ValueKind) => (kind.id === AUTO_KIND.id ? '4 bytes, Float or Double' : kind.label);
|
||||
|
||||
/** "90 and 200" whichever way round the two were typed. */
|
||||
function rangeWords(q: NumberQuery): string {
|
||||
const a = q.value.trim();
|
||||
const b = q.value2.trim();
|
||||
return Number(a) <= Number(b) ? `${a} and ${b}` : `${b} and ${a}`;
|
||||
}
|
||||
|
||||
/** Completes "places that hold ...". */
|
||||
export function holdsWords(q: NumberQuery): string {
|
||||
const type = typeWords(q.kind);
|
||||
const v = q.value.trim();
|
||||
switch (q.compare.id) {
|
||||
case 'exact':
|
||||
return `${v} as ${type}`;
|
||||
case 'rounded': {
|
||||
let range = '';
|
||||
try {
|
||||
const [lo, hi] = roundedRange(v);
|
||||
range = ` (${Number(lo.toPrecision(12))} to ${Number(hi.toPrecision(12))})`;
|
||||
} catch {
|
||||
// The text was checked before the search ran; without a range the sentence still reads.
|
||||
}
|
||||
return `about ${v}${range} as ${type}`;
|
||||
}
|
||||
case 'bigger':
|
||||
return `a value bigger than ${v} as ${type}`;
|
||||
case 'smaller':
|
||||
return `a value smaller than ${v} as ${type}`;
|
||||
case 'between':
|
||||
return `a value between ${rangeWords(q)} as ${type}`;
|
||||
default:
|
||||
return `any value as ${type}`;
|
||||
}
|
||||
}
|
||||
|
||||
/** The check a "narrow down" applied, as in: kept the ones whose "value went up". */
|
||||
export function checkWords(q: NumberQuery): string {
|
||||
const v = q.value.trim();
|
||||
switch (q.compare.id) {
|
||||
case 'exact':
|
||||
return `value is now ${v}`;
|
||||
case 'rounded':
|
||||
return `value is now about ${v}`;
|
||||
case 'bigger':
|
||||
return `value is now bigger than ${v}`;
|
||||
case 'smaller':
|
||||
return `value is now smaller than ${v}`;
|
||||
case 'between':
|
||||
return `value is now between ${rangeWords(q)}`;
|
||||
case 'increased':
|
||||
return 'value went up';
|
||||
case 'decreased':
|
||||
return 'value went down';
|
||||
case 'increasedBy':
|
||||
return `value went up by exactly ${v}`;
|
||||
case 'decreasedBy':
|
||||
return `value went down by exactly ${v}`;
|
||||
case 'changed':
|
||||
return 'value changed';
|
||||
case 'unchanged':
|
||||
return 'value stayed the same';
|
||||
default:
|
||||
return q.compare.label.toLowerCase();
|
||||
}
|
||||
}
|
||||
|
||||
const seconds = (ms: number) => (ms < 950 ? 'under a second' : ms < 90_000 ? `${Math.round(ms / 1000)} s` : `${Math.round(ms / 60_000)} min`);
|
||||
|
||||
const NARROW_HOW = 'Change the value in the game (take damage, spend money), type the new number above and press "Narrow down". Repeat until only a few are left.';
|
||||
|
||||
/** A first search that finished. */
|
||||
export function firstFeedback(q: NumberQuery, where: Coverage, matches: number, tookMs: number, dropped: string[] = []): Feedback {
|
||||
const mem = coverageWords(where);
|
||||
if (q.compare.id === 'unknown') {
|
||||
return {
|
||||
tone: 'info',
|
||||
title: `Took note of every value in ${mem}, read as ${typeWords(q.kind)}: ${plural(matches, 'match', 'matches')}. Took ${seconds(tookMs)}.`,
|
||||
tips: [
|
||||
'Now make the value change in the game. Then choose "Went up", "Went down" or "Changed" above and press "Narrow down".',
|
||||
'If it has not changed, choose "Stayed the same" to rule out everything that did.',
|
||||
],
|
||||
};
|
||||
}
|
||||
const what = holdsWords(q);
|
||||
if (matches === 0) {
|
||||
const tips = ['Check that the number is exactly what the game shows right now. If it changed while the search ran, search for the new number.'];
|
||||
if (dropped.length) tips.push(`Not counted here: ${dropped.join(', ')}. To search one of those, pick that type.`);
|
||||
if (q.kind.id !== AUTO_KIND.id) {
|
||||
tips.push(
|
||||
q.kind.float
|
||||
? q.compare.id === 'exact'
|
||||
? 'A decimal value is rarely exactly what the screen shows. Set "Value is" to "About (shown rounded)", or set Type to Auto.'
|
||||
: 'The game may keep it as a whole number. Set Type to Auto to try 4 bytes, Float and Double in one go.'
|
||||
: 'The game may store it as another type. Set Type to Auto to try 4 bytes, Float and Double in one go.',
|
||||
);
|
||||
} else if (q.compare.id === 'exact') {
|
||||
tips.push('If the game rounds what it shows, set "Value is" to "About (shown rounded)".');
|
||||
}
|
||||
if (where.bytes < where.total) tips.push(`Only ${fmtBytes(where.bytes)} of ${fmtBytes(where.total)} was searched. Open "Where to search" and choose "Everything".`);
|
||||
tips.push(
|
||||
'Some games store a different number than they show (100 health kept as 1.0, or as 1000). Set "Value is" to "Unknown" and narrow down with "Went up" and "Went down" instead.',
|
||||
);
|
||||
return { tone: 'warn', title: `Nothing found. No place in ${mem} holds ${what}. Took ${seconds(tookMs)}.`, tips };
|
||||
}
|
||||
const tips: string[] = [];
|
||||
if (matches === 1) tips.push('Only one place, so this is very likely it. Type a new value next to it, press "Set" and check the game.');
|
||||
else if (matches <= 10) tips.push('Few enough to try: change them one at a time and watch the game. To be sure first, change the value in the game and narrow down.');
|
||||
else tips.push(`Too many to try one by one. ${NARROW_HOW}`);
|
||||
if (dropped.length) tips.push(`Left out of this search: ${dropped.join(', ')}. To search one of those, start again and pick that type.`);
|
||||
return { tone: 'info', title: `Found ${plural(matches, 'place', 'places')} holding ${what}, in ${mem}. Took ${seconds(tookMs)}.`, tips };
|
||||
}
|
||||
|
||||
/** A "narrow down" that finished. */
|
||||
export function narrowFeedback(q: NumberQuery, before: number, matches: number, canUndo: boolean): Feedback {
|
||||
const check = checkWords(q);
|
||||
if (matches === 0) {
|
||||
const tips: string[] = [];
|
||||
if (canUndo) tips.push(`Press "Undo last step" to get the ${plural(before, 'match', 'matches')} back and try a different check.`);
|
||||
if (q.compare.inputs > 0) {
|
||||
tips.push('Check that the number is what the game shows right now.');
|
||||
tips.push('If the game shows the value differently than it stores it, use "Went up", "Went down" or "Changed" instead of a number.');
|
||||
} else {
|
||||
tips.push('Make sure the value really changed that way in the game since the last step. Some games only update it when a menu closes.');
|
||||
}
|
||||
tips.push(canUndo ? 'Or press "New search" and try another type, such as Auto.' : 'Press "New search" to start again; try another type, such as Auto.');
|
||||
return { tone: 'warn', title: `Nothing left. None of the ${plural(before, 'match', 'matches')} passed the check "${check}".`, tips };
|
||||
}
|
||||
const tips: string[] = [];
|
||||
if (matches === before) tips.push('Nothing was ruled out. Make the value change in the game first, then narrow down with the new number.');
|
||||
if (matches === 1) tips.push('One place left, so this is very likely it. Type a new value next to it, press "Set" and check the game.');
|
||||
else if (matches <= 10) tips.push('Few enough to try: change them one at a time and watch the game.');
|
||||
else tips.push(`Still too many to try one by one. ${NARROW_HOW}`);
|
||||
return { tone: 'info', title: `${n(matches)} of ${plural(before, 'match', 'matches')} left: the ones whose ${check}.`, tips };
|
||||
}
|
||||
|
||||
/** A search the person canceled. `kept` is the candidate list that is still there, if any. */
|
||||
export function canceledFeedback(first: boolean, progress: ScanProgress | null, where: Coverage | null, kept: number | null): Feedback {
|
||||
if (!first) {
|
||||
return kept === null
|
||||
? {
|
||||
tone: 'warn',
|
||||
title: 'Canceled. This search is gone.',
|
||||
tips: ['A search that started from "Unknown" keeps its list on the console, and stopping it halfway drops that list. Press "Search" to start again.'],
|
||||
}
|
||||
: { tone: 'warn', title: `Canceled. Nothing changed: your list still has ${plural(kept, 'match', 'matches')}.`, tips: [] };
|
||||
}
|
||||
const total = progress?.total ?? where?.bytes ?? 0;
|
||||
const done = progress?.done ?? 0;
|
||||
const pass = progress?.part && progress.part.count > 1 ? ` on round ${progress.part.index} of ${progress.part.count}` : '';
|
||||
const far = total > 0 ? ` after searching ${ofBytes(done, total)}${pass}` : '';
|
||||
const tips = ['Nothing was kept, because a list from part of the memory would miss the value if it is in the rest.'];
|
||||
if (progress && progress.matches > 0) tips.push(`${plural(progress.matches, 'match', 'matches')} had been found up to then.`);
|
||||
tips.push('To make a search quicker, open "Where to search" and uncheck the biggest blocks.');
|
||||
return { tone: 'warn', title: `Canceled${far}.`, tips };
|
||||
}
|
||||
|
||||
/** A search that stopped on an error. `gameGone` when the game is no longer the one that was searched. */
|
||||
export function failedFeedback(first: boolean, message: string, kept: number | null, gameGone: boolean): Feedback {
|
||||
const text = /[.!?]$/.test(message) ? message : `${message}.`;
|
||||
const tips: string[] = [];
|
||||
if (first || kept === null) tips.push('Nothing was kept.');
|
||||
else tips.push(`Your list is unchanged: it still has ${plural(kept, 'match', 'matches')}.`);
|
||||
if (gameGone) {
|
||||
tips.push(
|
||||
'The game is not running any more (or was restarted). A game puts its values in new places each time it starts, so start a new search once it is running again.',
|
||||
);
|
||||
} else {
|
||||
tips.push('Check that the game is still running and the console is on, then try again.');
|
||||
}
|
||||
return { tone: 'error', title: `${first ? 'The search' : 'Narrowing down'} stopped before it finished. ${text}`, tips };
|
||||
}
|
||||
|
||||
/** More matches than can be kept. */
|
||||
export function tooManyFeedback(q: NumberQuery, limit: number): Feedback {
|
||||
return {
|
||||
tone: 'warn',
|
||||
title: `More than ${n(limit)} places hold ${holdsWords(q)}. That is too many to keep, so nothing was kept.`,
|
||||
tips: [
|
||||
'Search for a less common number: 0 and 1 are everywhere.',
|
||||
'Or change the value in the game to something unusual and search for that.',
|
||||
'Or open "Where to search" and search less memory.',
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// ---- time ------------------------------------------------------------------------
|
||||
|
||||
export interface ProgressSample {
|
||||
/** Milliseconds, any clock. */
|
||||
at: number;
|
||||
/** 0..1 */
|
||||
fraction: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Milliseconds a search still needs, from how fast it has really been going,
|
||||
* or null while that is not known well enough to say. Speed is taken over the
|
||||
* last 20 seconds so it follows a search that slows down or speeds up.
|
||||
*/
|
||||
export function timeLeft(samples: ProgressSample[], now: number): number | null {
|
||||
if (samples.length < 4) return null;
|
||||
const first = samples[0];
|
||||
const last = samples[samples.length - 1];
|
||||
if (last.at - first.at < 3000 || last.fraction >= 1) return null;
|
||||
let from = first;
|
||||
for (const s of samples) {
|
||||
if (last.at - s.at <= 20_000) {
|
||||
// A window shorter than two seconds is too jumpy; fall back to the whole run.
|
||||
if (last.at - s.at >= 2000) from = s;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const gained = last.fraction - from.fraction;
|
||||
if (gained <= 0) return null;
|
||||
const left = ((1 - last.fraction) * (last.at - from.at)) / gained - (now - last.at);
|
||||
return left > 0 ? left : 0;
|
||||
}
|
||||
|
||||
/** "about 40 s left", rounded so it does not flicker. */
|
||||
export function leftWords(ms: number): string {
|
||||
if (ms < 5000) return 'a few seconds left';
|
||||
if (ms < 60_000) return `about ${Math.round(ms / 5000) * 5} s left`;
|
||||
if (ms < 10 * 60_000) return `about ${Math.round(ms / 30_000) / 2} min left`;
|
||||
return `about ${Math.round(ms / 60_000)} min left`;
|
||||
}
|
||||
|
||||
/** Elapsed time as 0:36 or 12:05. */
|
||||
export function clock(ms: number): string {
|
||||
const s = Math.max(0, Math.floor(ms / 1000));
|
||||
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
// ---- text and group searches -------------------------------------------------------
|
||||
|
||||
export interface TextQuery {
|
||||
text: string;
|
||||
plain: boolean;
|
||||
wide: boolean;
|
||||
ignoreCase: boolean;
|
||||
/** Code and fixed data were searched too. */
|
||||
fixed: boolean;
|
||||
}
|
||||
|
||||
/** What a text search can report back; a subset of TextSearchResult. */
|
||||
export interface TextOutcome {
|
||||
hits: number;
|
||||
capped: boolean;
|
||||
canceled: boolean;
|
||||
bytesSearched: number;
|
||||
}
|
||||
|
||||
export function textFeedback(q: TextQuery, res: TextOutcome, where: Coverage, tookMs: number): Feedback {
|
||||
const as = q.plain && q.wide ? 'plain or wide text' : q.plain ? 'plain text' : 'wide text';
|
||||
const caps = q.ignoreCase ? ', in any mix of capitals' : ', with exactly those capitals';
|
||||
const what = `"${q.text}" as ${as}${caps}`;
|
||||
const tips: string[] = [];
|
||||
|
||||
if (res.canceled) {
|
||||
const far = `Canceled after searching ${ofBytes(res.bytesSearched, where.bytes)}.`;
|
||||
return {
|
||||
tone: 'warn',
|
||||
title: res.hits
|
||||
? `${far} ${plural(res.hits, 'place', 'places')} found up to then ${res.hits === 1 ? 'is' : 'are'} listed below.`
|
||||
: `${far} Nothing had been found up to then.`,
|
||||
tips: ['The rest of the memory was not searched, so there may be more. Search again to cover all of it.'],
|
||||
};
|
||||
}
|
||||
if (res.hits === 0) {
|
||||
tips.push('Check the spelling, and try one distinctive word instead of a whole sentence: games often build a line from pieces.');
|
||||
if (!q.ignoreCase) tips.push('Check "Ignore capitals" to also find it written with other capitals.');
|
||||
if (!(q.plain && q.wide)) tips.push(`Only ${as} was searched. Check both "Plain text" and "Wide text".`);
|
||||
if (!q.fixed)
|
||||
tips.push(
|
||||
'Text that never changes (menu labels, built-in names) is usually in the game\'s fixed data. Check "Also look in the game\'s code and fixed data" and search again.',
|
||||
);
|
||||
if (where.bytes < where.total) tips.push(`Only ${fmtBytes(where.bytes)} of ${fmtBytes(where.total)} was searched. Open "Where to search" and choose "Everything".`);
|
||||
tips.push('Text drawn as a picture, or in a language-specific encoding, cannot be found this way.');
|
||||
return { tone: 'warn', title: `Nothing found. No place in ${coverageWords(where)} holds ${what}. Took ${seconds(tookMs)}.`, tips };
|
||||
}
|
||||
if (res.capped) {
|
||||
tips.push(
|
||||
`The search stopped at the first ${n(res.hits)}, after ${ofBytes(res.bytesSearched, where.bytes)}, so there are probably more. Type a longer piece of the text, or search less memory, to get a shorter list.`,
|
||||
);
|
||||
}
|
||||
tips.push("Open a result's menu (the three dots) to see it in Memory or to find what points to it.");
|
||||
return {
|
||||
tone: res.capped ? 'warn' : 'info',
|
||||
title: res.capped
|
||||
? `Found at least ${plural(res.hits, 'place', 'places')} holding ${what}.`
|
||||
: `Found ${plural(res.hits, 'place', 'places')} holding ${what}, in ${coverageWords(where)}. Took ${seconds(tookMs)}.`,
|
||||
tips,
|
||||
};
|
||||
}
|
||||
|
||||
/** What a group search can report back; a subset of GroupResult, with the values as typed. */
|
||||
export interface GroupOutcome {
|
||||
groups: number;
|
||||
/** The value that was searched for first, as typed, and in how many places it was found. */
|
||||
anchorText: string;
|
||||
anchorMatches: number;
|
||||
capped: boolean;
|
||||
canceled: boolean;
|
||||
bytesSearched: number;
|
||||
}
|
||||
|
||||
export function groupFeedback(values: string[], distance: number, res: GroupOutcome, where: Coverage, tookMs: number): Feedback {
|
||||
const list = values.map((v) => v.trim()).join(', ');
|
||||
if (res.canceled) {
|
||||
const during = res.bytesSearched < where.bytes ? `after searching ${ofBytes(res.bytesSearched, where.bytes)}` : 'while checking what is next to each place';
|
||||
return {
|
||||
tone: 'warn',
|
||||
title: `Canceled ${during}. ${res.groups ? `${plural(res.groups, 'group', 'groups')} found up to then ${res.groups === 1 ? 'is' : 'are'} listed below.` : 'No group had been found up to then.'}`,
|
||||
tips: ['The list is incomplete. Search again to cover everything.'],
|
||||
};
|
||||
}
|
||||
if (res.groups === 0) {
|
||||
if (res.anchorMatches === 0) {
|
||||
return {
|
||||
tone: 'warn',
|
||||
title: `Nothing found. ${res.anchorText} is not anywhere in ${coverageWords(where)} as that type, so no group can hold it. Took ${seconds(tookMs)}.`,
|
||||
tips: [
|
||||
`Check that ${res.anchorText} is what the game shows right now, and that its type is right: a number with a bar or decimals is often a Float.`,
|
||||
'If you are not sure of one of the values, remove its line and search with the others.',
|
||||
...(where.bytes < where.total
|
||||
? [`Only ${fmtBytes(where.bytes)} of ${fmtBytes(where.total)} was searched. Open "Where to search" and choose "Everything".`]
|
||||
: []),
|
||||
],
|
||||
};
|
||||
}
|
||||
return {
|
||||
tone: 'warn',
|
||||
title: `No group found. ${res.anchorText} is in ${plural(res.anchorMatches, 'place', 'places')}, but none of them has the other values (${list}) within ${n(distance)} bytes. Took ${seconds(tookMs)}.`,
|
||||
tips: [
|
||||
'They may be further apart: try a bigger distance, such as 256 or 1024.',
|
||||
'One of them may be stored as a different type (try Float), or may not be next to the others at all: remove one line and search again.',
|
||||
'Check that every number is what the game shows right now.',
|
||||
],
|
||||
};
|
||||
}
|
||||
const tips: string[] = [];
|
||||
if (res.capped) tips.push(`Only the first ${n(res.groups)} groups were kept. Add another value or make the distance smaller to cut the list down.`);
|
||||
if (res.groups === 1) tips.push('One place, so this is very likely it. Choose a value under "Keep narrowing with" and continue in Number search to change it.');
|
||||
else tips.push('To find the right one, choose a value under "Keep narrowing with", continue in Number search, change that value in the game and narrow down.');
|
||||
return {
|
||||
tone: res.capped ? 'warn' : 'info',
|
||||
title: `Found ${plural(res.groups, 'group', 'groups')} holding ${list} within ${n(distance)} bytes, in ${coverageWords(where)}. Took ${seconds(tookMs)}.`,
|
||||
tips,
|
||||
};
|
||||
}
|
||||
+99
-28
@@ -19,7 +19,7 @@ import { readMulti } from './ps5debug-extra.ts';
|
||||
import { view } from './bytes.ts';
|
||||
import { mergeRanges } from './regions.ts';
|
||||
import type { MemRange } from './regions.ts';
|
||||
import { encodeValue } from './scanner.ts';
|
||||
import { ScanCanceledError, cancelable, encodeValue, liveDbg } from './scanner.ts';
|
||||
import type { ScanRegion, ValueKind } from './scanner.ts';
|
||||
|
||||
export interface GroupMember {
|
||||
@@ -46,13 +46,23 @@ export interface GroupResult {
|
||||
/** True when the list was cut at `maxGroups`. */
|
||||
capped: boolean;
|
||||
canceled: boolean;
|
||||
/** Bytes of memory searched for the first value, and how many there were to search. */
|
||||
bytesSearched: number;
|
||||
bytesTotal: number;
|
||||
}
|
||||
|
||||
export interface GroupProgress {
|
||||
/** 0..1 */
|
||||
/** 0..1 within the current step. */
|
||||
fraction: number;
|
||||
/** 1: searching memory for the rarest value. 2: checking what is next to each place found. */
|
||||
step: 1 | 2;
|
||||
detail: string;
|
||||
groups: number;
|
||||
/** What `fraction` is made of: bytes of memory in step 1, places to check in step 2. */
|
||||
done: number;
|
||||
total: number;
|
||||
/** Places holding the first value so far (step 1) or in all (step 2). */
|
||||
anchors: number;
|
||||
}
|
||||
|
||||
export interface GroupSearchOptions {
|
||||
@@ -134,16 +144,31 @@ const RUN_BYTES = 256 * 1024;
|
||||
/** Hits checked per batch of reads. */
|
||||
const BATCH = 2000;
|
||||
|
||||
export async function groupSearch(opts: GroupSearchOptions): Promise<GroupResult> {
|
||||
const { members } = opts;
|
||||
/**
|
||||
* Checks what was typed before anything is sent, and returns each value as
|
||||
* bytes. Throws a plain message naming the line that is wrong.
|
||||
*/
|
||||
export function checkGroup(members: GroupMember[], distance: number): Uint8Array[] {
|
||||
if (members.length < 2) throw new Error('A group needs at least two values');
|
||||
if (members.length > GROUP_MAX_MEMBERS) throw new Error(`A group can have at most ${GROUP_MAX_MEMBERS} values`);
|
||||
const want = members.map((m) => encodeValue(m.kind, m.text));
|
||||
const want = members.map((m, i) => {
|
||||
if (m.text.trim() === '') throw new Error(`Value ${i + 1} is empty. Type a number there, or remove that line.`);
|
||||
try {
|
||||
return encodeValue(m.kind, m.text);
|
||||
} catch (e) {
|
||||
throw new Error(`Value ${i + 1}: ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
});
|
||||
const needed = members.reduce((n, m) => n + m.kind.size, 0);
|
||||
if (!Number.isInteger(opts.distance) || opts.distance < needed) {
|
||||
throw new Error(`These values take up ${needed} bytes side by side, so the distance has to be at least ${needed}`);
|
||||
}
|
||||
if (opts.distance > GROUP_MAX_DISTANCE) throw new Error(`The distance can be at most ${GROUP_MAX_DISTANCE} bytes`);
|
||||
if (!Number.isInteger(distance) || distance <= 0) throw new Error('The distance is a whole number of bytes, such as 64');
|
||||
if (distance < needed) throw new Error(`These values take up ${needed} bytes side by side, so the distance has to be at least ${needed}`);
|
||||
if (distance > GROUP_MAX_DISTANCE) throw new Error(`The distance can be at most ${GROUP_MAX_DISTANCE.toLocaleString('en-US')} bytes`);
|
||||
return want;
|
||||
}
|
||||
|
||||
export async function groupSearch(opts: GroupSearchOptions): Promise<GroupResult> {
|
||||
const { members } = opts;
|
||||
const want = checkGroup(members, opts.distance);
|
||||
const maxAnchors = opts.maxAnchors ?? 100_000;
|
||||
const maxGroups = opts.maxGroups ?? 2000;
|
||||
const distance = BigInt(opts.distance);
|
||||
@@ -157,30 +182,64 @@ export async function groupSearch(opts: GroupSearchOptions): Promise<GroupResult
|
||||
let scanned = 0;
|
||||
const anchors: bigint[] = [];
|
||||
let canceled = false;
|
||||
const step1 = () =>
|
||||
opts.onProgress?.({
|
||||
step: 1,
|
||||
fraction: total ? scanned / total : 1,
|
||||
detail: `Looking for ${members[anchor].text}`,
|
||||
groups: 0,
|
||||
done: scanned,
|
||||
total,
|
||||
anchors: anchors.length,
|
||||
});
|
||||
step1();
|
||||
for (const region of opts.regions) {
|
||||
if (opts.signal?.aborted) {
|
||||
canceled = true;
|
||||
break;
|
||||
}
|
||||
const dbg = opts.dbg();
|
||||
const dbg = await liveDbg(opts.dbg);
|
||||
if (!dbg.authed) await dbg.auth();
|
||||
let tooMany = false;
|
||||
await dbg.scanStart(
|
||||
{ pid: opts.pid, address: region.start, length: region.length, valueType: anchorKind.wire, compareType: COMPARE.EXACT, alignment: anchorKind.size, value: want[anchor] },
|
||||
(block) => {
|
||||
// Past the limit the rest is still read off the wire, so the stream stays in step.
|
||||
if (tooMany || anchors.length + block.count > maxAnchors) {
|
||||
tooMany = true;
|
||||
return;
|
||||
}
|
||||
const dv = view(block.records);
|
||||
const record = 4 + anchorKind.size;
|
||||
for (let i = 0; i < block.count; i++) anchors.push(region.start + BigInt(dv.getUint32(i * record, true)));
|
||||
},
|
||||
);
|
||||
const got: bigint[] = [];
|
||||
try {
|
||||
// A cancel gets through even when the console is slow to answer.
|
||||
await cancelable(
|
||||
dbg,
|
||||
opts.signal,
|
||||
() => null,
|
||||
() =>
|
||||
dbg.scanStart(
|
||||
{
|
||||
pid: opts.pid,
|
||||
address: region.start,
|
||||
length: region.length,
|
||||
valueType: anchorKind.wire,
|
||||
compareType: COMPARE.EXACT,
|
||||
alignment: anchorKind.size,
|
||||
value: want[anchor],
|
||||
},
|
||||
(block) => {
|
||||
// Past the limit the rest is still read off the wire, so the stream stays in step.
|
||||
if (tooMany || anchors.length + got.length + block.count > maxAnchors) {
|
||||
tooMany = true;
|
||||
return;
|
||||
}
|
||||
const dv = view(block.records);
|
||||
const record = 4 + anchorKind.size;
|
||||
for (let i = 0; i < block.count; i++) got.push(region.start + BigInt(dv.getUint32(i * record, true)));
|
||||
},
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
if (!(e instanceof ScanCanceledError)) throw e;
|
||||
canceled = true;
|
||||
break;
|
||||
}
|
||||
for (const a of got) anchors.push(a);
|
||||
if (tooMany) throw new GroupTooBroadError(members[anchor], maxAnchors);
|
||||
scanned += region.length;
|
||||
opts.onProgress?.({ fraction: total ? (scanned / total) * 0.8 : 0.8, detail: `Looking for ${members[anchor].text}`, groups: 0 });
|
||||
step1();
|
||||
}
|
||||
|
||||
// Step 2: read around each hit and look for the rest.
|
||||
@@ -222,7 +281,7 @@ export async function groupSearch(opts: GroupSearchOptions): Promise<GroupResult
|
||||
}
|
||||
}
|
||||
const data = await readMulti(
|
||||
opts.dbg(),
|
||||
await liveDbg(opts.dbg),
|
||||
opts.pid,
|
||||
runs.map((r) => ({ address: r.start, length: Number(r.end - r.start) })),
|
||||
);
|
||||
@@ -230,18 +289,30 @@ export async function groupSearch(opts: GroupSearchOptions): Promise<GroupResult
|
||||
for (let i = run.first; i < run.first + run.count && !capped; i++) {
|
||||
const group = bestGroup(members, want, anchor, batch[i], windows[i], data[r], run.start, opts.distance);
|
||||
if (!group) continue;
|
||||
const key = group.members.map((m) => m.address.toString(16)).sort().join(',');
|
||||
const key = group.members
|
||||
.map((m) => m.address.toString(16))
|
||||
.sort()
|
||||
.join(',');
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
if (hits.length >= maxGroups) capped = true;
|
||||
else hits.push(group);
|
||||
}
|
||||
});
|
||||
opts.onProgress?.({ fraction: 0.8 + 0.2 * Math.min(1, (done + batch.length) / anchors.length), detail: 'Checking what is next to each one', groups: hits.length });
|
||||
const checked = Math.min(anchors.length, done + batch.length);
|
||||
opts.onProgress?.({
|
||||
step: 2,
|
||||
fraction: checked / anchors.length,
|
||||
detail: 'Checking what is next to each one',
|
||||
groups: hits.length,
|
||||
done: checked,
|
||||
total: anchors.length,
|
||||
anchors: anchors.length,
|
||||
});
|
||||
}
|
||||
|
||||
hits.sort((a, b) => (a.base < b.base ? -1 : a.base > b.base ? 1 : 0));
|
||||
return { hits, anchor, anchorMatches: anchors.length, capped, canceled };
|
||||
return { hits, anchor, anchorMatches: anchors.length, capped, canceled, bytesSearched: scanned, bytesTotal: total };
|
||||
}
|
||||
|
||||
/** How many arrangements are tried around one hit before settling for the best so far. */
|
||||
|
||||
+71
-15
@@ -447,9 +447,11 @@ function checkGoal(doc: unknown, where: string, game: GameDef, nested = false):
|
||||
}
|
||||
}
|
||||
|
||||
const NEWER = 'This was written for a newer version of CheatSpecter than the one you have. Update CheatSpecter to read it.';
|
||||
|
||||
function checkVersion(v: unknown, where: string): number {
|
||||
const version = num(v, `${where}.version`, 1, 1000, true);
|
||||
if (version > FORMAT_VERSION) throw new LearnError('newer', 'These lessons were written for a newer version of CheatSpecter. Update CheatSpecter to read them.');
|
||||
if (version > FORMAT_VERSION) throw new LearnError('newer', NEWER);
|
||||
return version;
|
||||
}
|
||||
|
||||
@@ -631,22 +633,57 @@ const MAX_FILE_BYTES = 256 * 1024;
|
||||
|
||||
type Fetch = (url: string, init?: RequestInit) => Promise<Response>;
|
||||
|
||||
async function download(fetcher: Fetch, url: string): Promise<unknown> {
|
||||
/** How long a download may take before it is given up on, so "Downloading…" always ends. */
|
||||
const DOWNLOAD_PATIENCE_MS = 20000;
|
||||
|
||||
/**
|
||||
* Downloads one file and returns what it holds. `one`: a single lesson is
|
||||
* being fetched, after the list of lessons arrived fine.
|
||||
*/
|
||||
async function download(fetcher: Fetch, url: string, one = false, patienceMs = DOWNLOAD_PATIENCE_MS): Promise<unknown> {
|
||||
// A connection that stalls without failing would otherwise leave the page waiting for good.
|
||||
const stop = typeof AbortController === 'function' ? new AbortController() : null;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const late = new Promise<never>((_, reject) => {
|
||||
timer = setTimeout(() => {
|
||||
reject(new LearnError('offline', 'GitHub is taking too long to answer. Check the connection, then try again.'));
|
||||
stop?.abort();
|
||||
}, patienceMs);
|
||||
});
|
||||
try {
|
||||
return await Promise.race([fetchLesson(fetcher, url, one, stop ? stop.signal : undefined), late]);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchLesson(fetcher: Fetch, url: string, one: boolean, signal: AbortSignal | undefined): Promise<unknown> {
|
||||
let res: Response;
|
||||
try {
|
||||
// No cookies or referrer go to GitHub, and nothing of the answer is run.
|
||||
res = await fetcher(url, { cache: 'no-cache', credentials: 'omit', referrerPolicy: 'no-referrer' });
|
||||
res = await fetcher(url, { cache: 'no-cache', credentials: 'omit', referrerPolicy: 'no-referrer', ...(signal ? { signal } : {}) });
|
||||
} catch {
|
||||
throw new LearnError('offline', 'The lessons could not be downloaded. Check that this device is connected to the internet, then try again.');
|
||||
throw new LearnError('offline', `${one ? 'This lesson did not download.' : 'Nothing came back from GitHub.'} Check that this device is connected to the internet, then try again.`);
|
||||
}
|
||||
if (res.status === 404) {
|
||||
throw new LearnError(
|
||||
'unpublished',
|
||||
one ? 'This lesson is in the list, but its file is not on GitHub yet. Try again later; the other lessons are not affected.' : 'The lessons are not on CheatSpecter’s GitHub page yet. They will appear here as soon as they are.',
|
||||
);
|
||||
}
|
||||
if (!res.ok) throw new LearnError('offline', `GitHub answered with an error (${res.status}) instead of the ${one ? 'lesson' : 'lessons'}. Try again in a little while.`);
|
||||
let text: string;
|
||||
try {
|
||||
text = await res.text();
|
||||
} catch {
|
||||
// The connection dropped partway through.
|
||||
throw new LearnError('offline', 'The download stopped partway. Check the connection, then try again.');
|
||||
}
|
||||
if (res.status === 404) throw new LearnError('unpublished', 'The lessons are not published yet. They will appear here once they are on CheatSpecter’s GitHub page.');
|
||||
if (!res.ok) throw new LearnError('offline', `The lessons could not be downloaded (GitHub answered ${res.status}). Try again in a little while.`);
|
||||
const text = await res.text();
|
||||
if (text.length > MAX_FILE_BYTES) throw new LearnError('invalid', 'The download is too large to be a lesson, so it was not opened.');
|
||||
try {
|
||||
return JSON.parse(text) as unknown;
|
||||
} catch {
|
||||
throw new LearnError('invalid', 'The download is not a lesson file, so it was not opened.');
|
||||
throw new LearnError('invalid', 'What came back is not a lesson file, so it was not opened. A network that asks you to sign in first (a hotel or guest Wi-Fi) can cause this.');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -672,14 +709,16 @@ export interface LoadedIndex {
|
||||
export class LessonLibrary {
|
||||
private fetcher: Fetch;
|
||||
private preferLocal: boolean;
|
||||
private patienceMs: number;
|
||||
private base: string | null = null;
|
||||
private index: Promise<LoadedIndex> | null = null;
|
||||
private lessons = new Map<string, Promise<Lesson>>();
|
||||
|
||||
/** `preferLocal`: look for a learn/ folder served next to the app before going to GitHub. */
|
||||
constructor(opts: { preferLocal?: boolean; fetch?: Fetch } = {}) {
|
||||
constructor(opts: { preferLocal?: boolean; fetch?: Fetch; patienceMs?: number } = {}) {
|
||||
this.fetcher = opts.fetch ?? ((url, init) => fetch(url, init));
|
||||
this.preferLocal = opts.preferLocal ?? false;
|
||||
this.patienceMs = opts.patienceMs ?? DOWNLOAD_PATIENCE_MS;
|
||||
}
|
||||
|
||||
loadIndex(): Promise<LoadedIndex> {
|
||||
@@ -687,14 +726,14 @@ export class LessonLibrary {
|
||||
this.index = (async (): Promise<LoadedIndex> => {
|
||||
if (this.preferLocal) {
|
||||
try {
|
||||
const index = checkIndex(await download(this.fetcher, LOCAL_URL + 'index.json'));
|
||||
const index = checkIndex(await download(this.fetcher, LOCAL_URL + 'index.json', false, this.patienceMs));
|
||||
this.base = LOCAL_URL;
|
||||
return { index, from: 'local' };
|
||||
} catch {
|
||||
// No local copy (or a broken one): the published lessons it is.
|
||||
}
|
||||
}
|
||||
const index = checked(checkIndex, await download(this.fetcher, LEARN_URL + 'index.json'));
|
||||
const index = checked(checkIndex, await download(this.fetcher, LEARN_URL + 'index.json', false, this.patienceMs));
|
||||
this.base = LEARN_URL;
|
||||
return { index, from: 'github' };
|
||||
})();
|
||||
@@ -710,7 +749,7 @@ export class LessonLibrary {
|
||||
lesson = (async () => {
|
||||
if (!ID.test(id)) throw new LearnError('invalid', 'That is not a lesson.');
|
||||
await this.loadIndex();
|
||||
const doc = checked(checkLesson, await download(this.fetcher, `${this.base}lessons/${id}.json`));
|
||||
const doc = checked(checkLesson, await download(this.fetcher, `${this.base}lessons/${id}.json`, true, this.patienceMs));
|
||||
if (doc.id !== id) throw new LearnError('invalid', 'The download is a different lesson from the one asked for, so it was not opened.');
|
||||
return doc;
|
||||
})();
|
||||
@@ -786,16 +825,33 @@ export class ProgressStore {
|
||||
/** null: not tried yet. False: the server has no such thing, so it is not asked again. */
|
||||
private remote: boolean | null = null;
|
||||
private timer: ReturnType<typeof setTimeout> | undefined;
|
||||
private patienceMs: number;
|
||||
|
||||
constructor(opts: { fetch?: Fetch } = {}) {
|
||||
constructor(opts: { fetch?: Fetch; patienceMs?: number } = {}) {
|
||||
this.fetcher = opts.fetch ?? ((url, init) => fetch(url, init));
|
||||
this.patienceMs = opts.patienceMs ?? 4000;
|
||||
}
|
||||
|
||||
async load(): Promise<Progress> {
|
||||
/** `server` false: whatever serves the page is known not to keep settings (the bridge on a computer), so it is not asked. */
|
||||
async load(server = true): Promise<Progress> {
|
||||
const local = readProgress(loadJson<unknown>(STORAGE_KEY, null));
|
||||
if (!server) this.remote = false;
|
||||
if (this.remote === false) return local;
|
||||
try {
|
||||
const res = await this.fetcher(PREFS_URL, { cache: 'no-store' });
|
||||
// Whatever serves the page gets a few seconds. If it does not answer, the Learn section opens on the
|
||||
// browser's copy, and leaves the server's alone for this visit so that nothing there is overwritten unseen.
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const res = await Promise.race([
|
||||
this.fetcher(PREFS_URL, { cache: 'no-store' }),
|
||||
new Promise<null>((resolve) => {
|
||||
timer = setTimeout(() => resolve(null), this.patienceMs);
|
||||
}),
|
||||
]);
|
||||
clearTimeout(timer);
|
||||
if (!res) {
|
||||
this.remote = false;
|
||||
return local;
|
||||
}
|
||||
if (res.ok) {
|
||||
this.remote = true;
|
||||
return mergeProgress(local, readProgress(await res.json()));
|
||||
|
||||
+39
-4
@@ -357,17 +357,52 @@ export async function parseAddress(ctx: GameContext, text: string): Promise<bigi
|
||||
const t = text.trim().replace(/\s+/g, '');
|
||||
if (!t) throw new Error('Enter an address');
|
||||
const plus = t.lastIndexOf('+');
|
||||
if (plus <= 0) return hex(t);
|
||||
if (plus <= 0) return inRange(explained(t));
|
||||
const name = t.slice(0, plus);
|
||||
const offset = hex(t.slice(plus + 1));
|
||||
if (isMainName(ctx, name)) return (await imageBase(ctx)) + offset;
|
||||
if (plus === t.length - 1) throw new Error(`Type the offset after "${name}+", for example ${name}+1A2B3C.`);
|
||||
const offset = explained(t.slice(plus + 1));
|
||||
if (isMainName(ctx, name)) return inRange((await imageBase(ctx)) + offset);
|
||||
let range = await moduleRange(ctx, name);
|
||||
if (!range) {
|
||||
await ctx.maps(true);
|
||||
range = await moduleRange(ctx, name);
|
||||
}
|
||||
if (!range) throw new Error(`No module called "${name}" is loaded. Use "game+..." for the game itself, or a plain hex address.`);
|
||||
return range.start + offset;
|
||||
return inRange(range.start + offset);
|
||||
}
|
||||
|
||||
/** `hex`, with a hint of what an address looks like for someone who typed something else. */
|
||||
function explained(text: string): bigint {
|
||||
try {
|
||||
return hex(text);
|
||||
} catch (e) {
|
||||
throw new Error(`${e instanceof Error ? e.message : String(e)}. Addresses look like 880012340, or game+1A2B3C.`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Addresses are 64 bits; anything longer was a slip of the keyboard, and would wrap around on the way to the console. */
|
||||
function inRange(address: bigint): bigint {
|
||||
if (address > 0xffff_ffff_ffff_ffffn) throw new Error('That address is too large. Addresses have at most 16 hex digits.');
|
||||
return address;
|
||||
}
|
||||
|
||||
/**
|
||||
* How much of `length` bytes at `address` the game has mapped as readable.
|
||||
* The console answers a read of unmapped memory with zeros instead of an
|
||||
* error, so screens that show raw memory ask this to tell real zeros from
|
||||
* "nothing is here".
|
||||
*/
|
||||
export async function readableAt(ctx: GameContext, address: bigint, length: number, refresh = false): Promise<'all' | 'part' | 'none'> {
|
||||
const maps = await ctx.maps(refresh);
|
||||
const end = address + BigInt(length);
|
||||
let covered = 0n;
|
||||
for (const m of maps) {
|
||||
if (!(m.prot & PROT.READ)) continue;
|
||||
const lo = m.start > address ? m.start : address;
|
||||
const hi = m.end < end ? m.end : end;
|
||||
if (hi > lo) covered += hi - lo;
|
||||
}
|
||||
return covered >= BigInt(length) ? 'all' : covered > 0n ? 'part' : 'none';
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+10
-1
@@ -19,6 +19,14 @@ export interface MemSnapshot {
|
||||
takenAt: number;
|
||||
}
|
||||
|
||||
/** Thrown when a snapshot is canceled partway. Nothing of it is kept. */
|
||||
export class SnapshotCanceledError extends Error {
|
||||
constructor() {
|
||||
super('Canceled');
|
||||
this.name = 'SnapshotCanceledError';
|
||||
}
|
||||
}
|
||||
|
||||
export async function takeSnapshot(
|
||||
dbg: () => Ps5Debug,
|
||||
pid: number,
|
||||
@@ -30,8 +38,9 @@ export async function takeSnapshot(
|
||||
if (!Number.isInteger(length) || length <= 0) throw new Error('Choose how much memory to compare');
|
||||
if (length > SNAPSHOT_MAX_BYTES) throw new Error(`A snapshot can cover at most ${SNAPSHOT_MAX_BYTES / (1024 * 1024)} MB. Choose a smaller area.`);
|
||||
const bytes = new Uint8Array(length);
|
||||
onProgress?.(0);
|
||||
for (let off = 0; off < length; off += READ_BYTES) {
|
||||
if (signal?.aborted) throw new Error('Canceled');
|
||||
if (signal?.aborted) throw new SnapshotCanceledError();
|
||||
const take = Math.min(READ_BYTES, length - off);
|
||||
bytes.set(await dbg().read(pid, start + BigInt(off), take), off);
|
||||
onProgress?.((off + take) / length);
|
||||
|
||||
@@ -40,6 +40,12 @@ export interface PointerScanOptions {
|
||||
* minute on a big game, so this is where the time goes.
|
||||
*/
|
||||
clusterSpan?: number;
|
||||
/**
|
||||
* Lets the search be given up. A pass over one stretch of memory cannot be
|
||||
* interrupted on the console, so it ends after the stretch in hand, and
|
||||
* the paths found up to then are returned.
|
||||
*/
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
interface Candidate {
|
||||
@@ -91,6 +97,7 @@ export async function findPointerPaths(
|
||||
const found = new Map<Node, Candidate[]>(nodes.map((n) => [n, []]));
|
||||
let pass = 0;
|
||||
for (const cluster of clusters) {
|
||||
if (opts.signal?.aborted) break;
|
||||
onProgress(describe(++pass, clusters.length));
|
||||
const hi = cluster[cluster.length - 1].addr;
|
||||
const lo = cluster[0].addr > maxOffset ? cluster[0].addr - maxOffset : 0n;
|
||||
@@ -99,6 +106,7 @@ export async function findPointerPaths(
|
||||
view(value).setBigUint64(8, hi, true);
|
||||
let kept = 0;
|
||||
for (const region of regions) {
|
||||
if (opts.signal?.aborted) break;
|
||||
await dbg.scanStart(
|
||||
{ pid: ctx.pid, address: region.start, length: region.length, valueType: VALUE_TYPE.U64, compareType: COMPARE.BETWEEN, alignment: 8, value },
|
||||
(block) => {
|
||||
@@ -126,10 +134,12 @@ export async function findPointerPaths(
|
||||
// Breadth first, so the shortest paths come out first.
|
||||
let frontier: Node[] = [{ addr: target, tail: [] }];
|
||||
for (let depth = 1; depth <= maxDepth && frontier.length && results.length < maxResults; depth++) {
|
||||
if (opts.signal?.aborted) break;
|
||||
const next: Node[] = [];
|
||||
const found = await pointersTo(frontier, (pass, passes) =>
|
||||
passes === 1 ? `Level ${depth} of ${maxDepth}: searching memory` : `Level ${depth} of ${maxDepth}: search ${pass} of ${passes}`,
|
||||
);
|
||||
// A pass that was given up saw only part of memory; what it did find is still true, but nothing deeper is worth starting.
|
||||
for (const node of frontier) {
|
||||
let followed = 0;
|
||||
for (const c of found.get(node) ?? []) {
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
// lesson, which is what proves each goal can be reached.
|
||||
|
||||
import { fieldSize, goalParts } from '../learn.ts';
|
||||
import type { FieldType, Goal, LeafGoal } from '../learn.ts';
|
||||
import type { FieldType, GameDef, Goal, LeafGoal } from '../learn.ts';
|
||||
import type { GameContext, Location } from '../locations.ts';
|
||||
import { resolveLocation } from '../locations.ts';
|
||||
import { nopEntry, valueEntry } from '../make.ts';
|
||||
@@ -153,6 +153,41 @@ export class GoalTracker {
|
||||
return this.index >= this.items.length;
|
||||
}
|
||||
|
||||
/** How far along the current part is, when it is one that counts something: "2 of 3". */
|
||||
get progress(): string | null {
|
||||
const g = this.current?.goal;
|
||||
if (!g) return null;
|
||||
if (g.check === 'held') return this.held.count > 0 ? `${Math.min(this.held.count, g.times)} of ${g.times}` : null;
|
||||
if (g.check === 'pressed') {
|
||||
const have = (this.bench.con.presses.get(g.action) ?? 0) - this.since.presses;
|
||||
return g.times > 1 && have > 0 ? `${Math.min(have, g.times)} of ${g.times}` : null;
|
||||
}
|
||||
if (g.check === 'steady') {
|
||||
const every = this.bench.con.game.def.actions.find((a) => a.id === this.heldAction(g))?.every ?? 1000;
|
||||
const seconds = Math.floor((this.held.count * every) / 1000);
|
||||
return seconds > 0 ? `${Math.min(seconds, g.seconds)} of ${g.seconds} seconds` : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Something the learner cannot see for themselves and needs to know to get
|
||||
* on: their search has dropped the very value the goal is about. Null when
|
||||
* there is nothing of the kind to say.
|
||||
*/
|
||||
advice(): string | null {
|
||||
const g = this.current?.goal;
|
||||
const { con } = this.bench;
|
||||
if (!g || con.game.crashed || this.bench.ctx.pid !== con.game.pid) return null;
|
||||
// Only for goals that are reached through the learner's own list. Where a
|
||||
// lesson has them search for one value to get at another, the list is not expected to hold it.
|
||||
if (g.check !== 'narrowed' && g.check !== 'held' && g.check !== 'steady' && g.check !== 'caught') return null;
|
||||
const left = con.lastScan.size;
|
||||
if (left === 0 || con.lastScan.has(con.game.address(g.field))) return null;
|
||||
const what = labelOf(this.bench, g.field);
|
||||
return `Your last search left ${left.toLocaleString('en-US')} ${left === 1 ? 'match' : 'matches'}, and the ${what} is not among them. Something in that search did not fit what the game was showing: the number, the type, or the answer about how it changed. Press New search and start over.`;
|
||||
}
|
||||
|
||||
/** Stops watching the game. */
|
||||
stop(): void {
|
||||
this.unsubscribe();
|
||||
@@ -255,6 +290,25 @@ async function waitFor(cond: () => boolean, ms: number): Promise<boolean> {
|
||||
return cond();
|
||||
}
|
||||
|
||||
/** How many pointers the game itself follows from its fixed data to reach an object, or 0 when it has no such chain. */
|
||||
function pointerHops(def: GameDef, object: string): number {
|
||||
let at = def.objects.filter((o) => o.static).map((o) => o.id);
|
||||
const seen = new Set(at);
|
||||
for (let hops = 1; at.length && hops <= 8; hops++) {
|
||||
const next: string[] = [];
|
||||
for (const id of at) {
|
||||
for (const f of def.objects.find((o) => o.id === id)?.fields ?? []) {
|
||||
if (f.type !== 'pointer' || !f.to || seen.has(f.to)) continue;
|
||||
if (f.to === object) return hops;
|
||||
seen.add(f.to);
|
||||
next.push(f.to);
|
||||
}
|
||||
}
|
||||
at = next;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/** Asks the debugger which instruction writes a value, by watching it while the game changes it. */
|
||||
async function findWriter(bench: CoachBench, field: string): Promise<Insn> {
|
||||
const { con, ctx } = bench;
|
||||
@@ -389,6 +443,23 @@ export async function showMe(goal: LeafGoal, bench: CoachBench, opts: { settleMs
|
||||
}
|
||||
}
|
||||
if (reached()) return;
|
||||
// A cheat already made for this value: use that, as the learner would, changing the number it holds if it has to.
|
||||
const fits = (held: number) => (goal.is === 'atLeast' ? held >= goal.value : goal.is === 'atMost' ? held <= goal.value : held === goal.value);
|
||||
for (const c of bench.cheats()) {
|
||||
if (c.entry.type !== 'value' || !c.entry.freeze) continue;
|
||||
if ((await resolveLocation(bench.ctx, c.entry.at).catch(() => null)) !== game.address(goal.field)) continue;
|
||||
let entry: ValueEntry = c.entry;
|
||||
const changed = !fits(Number(entry.value));
|
||||
if (changed) {
|
||||
entry = { ...entry, value: String(goal.value) };
|
||||
await bench.replace(entry);
|
||||
}
|
||||
if (changed || !c.on) await bench.toggle(entry, true);
|
||||
await waitFor(reached, 1500);
|
||||
if (!reached()) break;
|
||||
const how = entry.at.kind === 'pointer' ? 'It followed its pointer path to' : 'It writes to';
|
||||
return bench.say(`${changed ? `Set "${entry.name}" in Your cheats to hold ${entry.value}, and switched it on` : `Switched "${entry.name}" on in Your cheats`}. ${how} the ${labelOf(bench, goal.field)}, wherever it is in this run of the game.`);
|
||||
}
|
||||
await bench.write(game.address(goal.field), encodeValue(valueKind(KIND[game.type(goal.field)]), String(goal.value)));
|
||||
return bench.say(`Found the ${labelOf(bench, goal.field)} and wrote ${goal.value} over it.`);
|
||||
}
|
||||
@@ -432,8 +503,20 @@ export async function showMe(goal: LeafGoal, bench: CoachBench, opts: { settleMs
|
||||
if (goal.location === 'pointer') {
|
||||
const paths = await findPointerPaths(bench.ctx, address, { maxDepth: 3 }, () => undefined);
|
||||
if (paths.length === 0) throw new Error('No pointer path leads to the value.');
|
||||
at = paths[0].location;
|
||||
bench.say(`Searched for a pointer path to the ${labelOf(bench, field)} and took the shortest of ${paths.length}.`);
|
||||
// The shortest is not always real: two things that happen to sit near each other today
|
||||
// give a path that breaks on the next start. The game's own chain has one pointer per
|
||||
// object on the way and ends with the value's place inside its object.
|
||||
const hops = pointerHops(game.def, field.split('.')[0]);
|
||||
const last = '0x' + game.fieldOffset(field).toString(16).toUpperCase();
|
||||
const real = paths.find((p) => p.depth === hops && p.location.offsets[p.location.offsets.length - 1]?.toUpperCase() === last.toUpperCase());
|
||||
at = (real ?? paths[0]).location;
|
||||
bench.say(
|
||||
paths.length === 1
|
||||
? `Searched for a pointer path to the ${labelOf(bench, field)} and found one.`
|
||||
: real && real !== paths[0]
|
||||
? `Searched for a pointer path to the ${labelOf(bench, field)}: ${paths.length} candidates. Took the one with ${hops} pointers, because the shorter one only works by coincidence and would break on a restart.`
|
||||
: `Searched for a pointer path to the ${labelOf(bench, field)} and took the shortest of ${paths.length}.`,
|
||||
);
|
||||
}
|
||||
const have = await cheatFor(bench, goal, false);
|
||||
const base: ValueEntry = have ? (have.entry as ValueEntry) : await valueEntry(bench.ctx, address, valueKind(KIND[game.type(field)]), shown(game.get(field)), at);
|
||||
|
||||
@@ -84,6 +84,18 @@ function hash(text: string): number {
|
||||
return h >>> 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* The title ID a lesson's practice game runs under: the same for a lesson and
|
||||
* game every time, so "Your cheats" for it has somewhere to live while the
|
||||
* lesson is open, and so the game is laid out the same way for every learner
|
||||
* (the layout is drawn from the title; lesson text can rely on it).
|
||||
*/
|
||||
export function practiceTitle(lesson: string, game: string): string {
|
||||
let h = 0;
|
||||
for (const ch of `${lesson}/${game}`) h = (Math.imul(h, 31) + ch.charCodeAt(0)) >>> 0;
|
||||
return 'CSPR' + String(h % 100000).padStart(5, '0');
|
||||
}
|
||||
|
||||
function readNumber(dv: DataView, at: number, type: FieldType): number {
|
||||
switch (type) {
|
||||
case 'u8':
|
||||
@@ -334,6 +346,11 @@ export class PracticeGame {
|
||||
return this.placed(field).def.type;
|
||||
}
|
||||
|
||||
/** How far into its object a value sits, in bytes. */
|
||||
fieldOffset(field: string): number {
|
||||
return this.placed(field).def.offset!;
|
||||
}
|
||||
|
||||
get(field: string): number {
|
||||
const p = this.placed(field);
|
||||
return readNumber(new DataView(p.region.bytes.buffer), p.at, p.def.type);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// The practice engine in one piece, so the lesson screen can load it (and only
|
||||
// it) the first time a practice step comes up.
|
||||
|
||||
export { PracticeGame } from './game.ts';
|
||||
export { PracticeGame, practiceTitle } from './game.ts';
|
||||
export { PracticeConsole, connectPractice } from './console.ts';
|
||||
export type { PracticeEvent } from './console.ts';
|
||||
export { GoalTracker, patience, showMe } from './coach.ts';
|
||||
|
||||
+4
-3
@@ -8,6 +8,7 @@
|
||||
import { PROT } from './ps5debug.ts';
|
||||
import type { MapEntry } from './ps5debug.ts';
|
||||
import { loadJson, saveJson } from './storage.ts';
|
||||
import { SCAN_SLICE_BYTES } from './scanner.ts';
|
||||
import type { ScanRegion } from './scanner.ts';
|
||||
|
||||
/** `end` is exclusive. */
|
||||
@@ -201,7 +202,7 @@ export function hugeRegionIds(items: RegionItem[], minBytes: number): string[] {
|
||||
|
||||
function parseHex(text: string, what: string): bigint {
|
||||
const t = text.trim().replace(/^0x/i, '');
|
||||
if (!/^[0-9a-fA-F]+$/.test(t)) throw new Error(t === '' ? `Enter the ${what} address` : `"${text}" is not a hex address`);
|
||||
if (!/^[0-9a-fA-F]+$/.test(t)) throw new Error(t === '' ? `Enter the ${what} address` : `"${text.trim()}" is not a hex address. An address looks like 0x880000000.`);
|
||||
return BigInt('0x' + t);
|
||||
}
|
||||
|
||||
@@ -233,10 +234,10 @@ export function totalBytes(items: RegionItem[], opts: { fixed?: boolean } = {}):
|
||||
* Cuts ranges into the slices a number scan is sent in: small enough that
|
||||
* offsets fit in a u32 and progress moves steadily.
|
||||
*/
|
||||
export function sliceRanges(ranges: MemRange[], sliceBytes = 64 * 1024 * 1024): ScanRegion[] {
|
||||
export function sliceRanges(ranges: MemRange[], sliceBytes = SCAN_SLICE_BYTES): ScanRegion[] {
|
||||
const out: ScanRegion[] = [];
|
||||
for (const r of mergeRanges(ranges)) {
|
||||
for (let start = r.start; start < r.end; ) {
|
||||
for (let start = r.start; start < r.end;) {
|
||||
const left = r.end - start;
|
||||
const take = left > BigInt(sliceBytes) ? sliceBytes : Number(left);
|
||||
out.push({ start, length: take });
|
||||
|
||||
+275
-73
@@ -51,8 +51,12 @@ export interface Scan {
|
||||
readonly kind: ValueKind;
|
||||
readonly passes: number;
|
||||
readonly matches: number;
|
||||
first(regions: ScanRegion[], compare: CompareKind, value: Uint8Array, onProgress: (p: ScanProgress) => void): Promise<void>;
|
||||
next(compare: CompareKind, value: Uint8Array, onProgress: (p: ScanProgress) => void): Promise<void>;
|
||||
/**
|
||||
* Both passes can be canceled through `signal`. They then throw a
|
||||
* ScanCanceledError and leave the scan exactly as it was before the call.
|
||||
*/
|
||||
first(regions: ScanRegion[], compare: CompareKind, value: Uint8Array, onProgress: (p: ScanProgress) => void, signal?: AbortSignal): Promise<void>;
|
||||
next(compare: CompareKind, value: Uint8Array, onProgress: (p: ScanProgress) => void, signal?: AbortSignal): Promise<void>;
|
||||
hits(limit: number): Promise<ScanHit[]>;
|
||||
/** True when the last "narrow down" can be taken back. Only one step is kept. */
|
||||
readonly canUndo: boolean;
|
||||
@@ -92,15 +96,31 @@ export function compareKind(id: string): CompareKind {
|
||||
return k;
|
||||
}
|
||||
|
||||
/** The smallest and largest whole number a type can hold. */
|
||||
export function kindRange(kind: ValueKind): [bigint, bigint] {
|
||||
const bits = kind.size * 8;
|
||||
return kind.signed ? [-(1n << BigInt(bits - 1)), (1n << BigInt(bits - 1)) - 1n] : [0n, (1n << BigInt(bits)) - 1n];
|
||||
}
|
||||
|
||||
/** Why typed text is not a number, said in a way that tells the person what to change. */
|
||||
function notANumber(text: string, whole = false): string {
|
||||
const t = text.trim();
|
||||
if (/^[-+]?\d{1,3}([, ]\d{3})+(\.\d+)?$/.test(t)) return `Type "${t}" without commas or spaces: ${t.replace(/[, ]/g, '')}`;
|
||||
if (/^[-+]?\d+,\d+$/.test(t)) return `Use a point for decimals: ${t.replace(',', '.')}`;
|
||||
return `"${t}" is not a ${whole ? 'whole number' : 'number'}. Type digits only, such as 100.`;
|
||||
}
|
||||
|
||||
/** Parses what the user typed. Integers accept decimal or 0x hex. */
|
||||
export function encodeValue(kind: ValueKind, text: string): Uint8Array {
|
||||
const t = text.trim();
|
||||
if (t === '') throw new Error('Enter a value');
|
||||
if (t === '') throw new Error('Enter a value first');
|
||||
const out = new Uint8Array(kind.size);
|
||||
const dv = view(out);
|
||||
if (kind.float) {
|
||||
const n = Number(t);
|
||||
if (!Number.isFinite(n)) throw new Error(`"${text}" is not a number`);
|
||||
if (!Number.isFinite(n)) throw new Error(notANumber(text));
|
||||
// The console's own cheat engine reads numbers the same way (payload/src/engine.c), so what is
|
||||
// accepted here has to stay in step with it: a number too big for a float becomes infinity in both.
|
||||
if (kind.size === 4) dv.setFloat32(0, n, true);
|
||||
else dv.setFloat64(0, n, true);
|
||||
return out;
|
||||
@@ -109,12 +129,21 @@ export function encodeValue(kind: ValueKind, text: string): Uint8Array {
|
||||
try {
|
||||
big = BigInt(t);
|
||||
} catch {
|
||||
throw new Error(`"${text}" is not a whole number`);
|
||||
const n = Number(t);
|
||||
if (Number.isFinite(n) && !Number.isInteger(n)) {
|
||||
throw new Error(`${t} is not a whole number, and the type "${kind.label}" only holds whole numbers. Choose the type Float (or Auto) for a number with decimals.`);
|
||||
}
|
||||
throw new Error(notANumber(text, true));
|
||||
}
|
||||
const [min, max] = kindRange(kind);
|
||||
if (big < 0n && !kind.signed) {
|
||||
throw new Error(`The type "${kind.label}" cannot hold a negative number. Choose "${kind.label} (signed)" for ${t}.`);
|
||||
}
|
||||
if (big < min || big > max) {
|
||||
const bigger = kind.size < 8 ? ` Choose a bigger type for ${t}.` : '';
|
||||
throw new Error(`${t} does not fit in the type "${kind.label}", which holds ${min.toLocaleString('en-US')} to ${max.toLocaleString('en-US')}.${bigger}`);
|
||||
}
|
||||
const bits = kind.size * 8;
|
||||
const min = kind.signed ? -(1n << BigInt(bits - 1)) : 0n;
|
||||
const max = kind.signed ? (1n << BigInt(bits - 1)) - 1n : (1n << BigInt(bits)) - 1n;
|
||||
if (big < min || big > max) throw new Error(`${text} does not fit in ${kind.label}`);
|
||||
if (kind.size === 8) dv.setBigUint64(0, BigInt.asUintN(64, big), true);
|
||||
else writeSmallInt(dv, kind.size, Number(BigInt.asUintN(bits, big)));
|
||||
return out;
|
||||
@@ -128,7 +157,7 @@ export function encodeValue(kind: ValueKind, text: string): Uint8Array {
|
||||
export function roundedRange(text: string): [number, number] {
|
||||
const t = text.trim();
|
||||
const n = Number(t);
|
||||
if (t === '' || !Number.isFinite(n)) throw new Error(t === '' ? 'Enter a value' : `"${text}" is not a number`);
|
||||
if (t === '' || !Number.isFinite(n)) throw new Error(t === '' ? 'Enter a value first' : notANumber(text));
|
||||
const decimals = /^[-+]?\d*\.(\d+)$/.exec(t)?.[1].length ?? 0;
|
||||
const half = 0.5 / 10 ** decimals;
|
||||
return [n - half, n + half];
|
||||
@@ -143,14 +172,19 @@ export function encodeCompare(kind: ValueKind, compare: CompareKind, text: strin
|
||||
if (compare.id === 'rounded') {
|
||||
if (!kind.float) {
|
||||
// A whole-number type has nothing to round, so it is an exact match written as a range.
|
||||
const whole = encodeValue(kind, String(Math.round(Number(text.trim()))));
|
||||
const [lo] = roundedRange(text);
|
||||
const whole = encodeValue(kind, String(Math.round(lo + 0.5)));
|
||||
return concat(whole, whole);
|
||||
}
|
||||
const [lo, hi] = roundedRange(text);
|
||||
return concat(encodeValue(kind, String(lo)), encodeValue(kind, String(hi)));
|
||||
}
|
||||
const a = encodeValue(kind, text);
|
||||
return compare.inputs === 1 ? a : concat(a, encodeValue(kind, text2));
|
||||
if (compare.inputs === 1) return a;
|
||||
if (text2.trim() === '') throw new Error('Type both numbers: the smallest and the biggest the value can be');
|
||||
const b = encodeValue(kind, text2);
|
||||
// Either order is accepted; the console wants the smaller one first.
|
||||
return Number(decodeValue(kind, a)) <= Number(decodeValue(kind, b)) ? concat(a, b) : concat(b, a);
|
||||
}
|
||||
|
||||
function writeSmallInt(dv: DataView, size: number, v: number): void {
|
||||
@@ -194,10 +228,88 @@ export interface ScanRegion {
|
||||
}
|
||||
|
||||
export interface ScanProgress {
|
||||
/** 0..1 */
|
||||
/** 0..1 over the whole operation. Real work done over work to do, never an estimate. */
|
||||
fraction: number;
|
||||
matches: number;
|
||||
detail: string;
|
||||
/**
|
||||
* What `fraction` is made of, for "3.1 of 9.6 GB": bytes of memory on a
|
||||
* first pass, candidates when narrowing down. With `part`, they count
|
||||
* within that part.
|
||||
*/
|
||||
done?: number;
|
||||
total?: number;
|
||||
unit?: 'bytes' | 'candidates';
|
||||
/** An Auto search goes over the same memory once per type: which round this is. */
|
||||
part?: { index: number; count: number };
|
||||
}
|
||||
|
||||
/** Thrown by a pass that was canceled. Says how far it got; nothing of it is kept. */
|
||||
export class ScanCanceledError extends Error {
|
||||
progress: ScanProgress | null;
|
||||
constructor(progress: ScanProgress | null) {
|
||||
super('Canceled');
|
||||
this.name = 'ScanCanceledError';
|
||||
this.progress = progress;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The scan connection, once it is usable. The console closes a connection
|
||||
* after most errors and after a cancel, and the session needs a moment to
|
||||
* open the next one; a search started in that moment waits here instead of
|
||||
* failing on the dead one.
|
||||
*/
|
||||
export async function liveDbg(getDbg: () => Ps5Debug, waitMs = 6000): Promise<Ps5Debug> {
|
||||
const until = Date.now() + waitMs;
|
||||
for (;;) {
|
||||
const dbg = getDbg();
|
||||
if (!dbg.closed) return dbg;
|
||||
if (Date.now() >= until) throw new Error('The connection to the console was lost and has not come back. Check that the console is on, then try again.');
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* How long a canceled pass waits for the request in flight to finish on its
|
||||
* own. After that the connection is closed, which is the only way to stop a
|
||||
* request the console is still working on (the session opens a new one).
|
||||
*/
|
||||
const CANCEL_GRACE_MS = 2500;
|
||||
|
||||
/**
|
||||
* Runs one request so that a cancel always gets through: at once when the
|
||||
* request finishes in time, otherwise by closing the connection.
|
||||
*/
|
||||
export async function cancelable<T>(
|
||||
dbg: Ps5Debug,
|
||||
signal: AbortSignal | undefined,
|
||||
last: () => ScanProgress | null,
|
||||
run: () => Promise<T>,
|
||||
/** Return the result of a request that finished before the cancel could stop it, for work that cannot be thrown away. */
|
||||
keepFinished = false,
|
||||
): Promise<T> {
|
||||
if (!signal) return run();
|
||||
if (signal.aborted) throw new ScanCanceledError(last());
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const onAbort = () => {
|
||||
timer = setTimeout(() => {
|
||||
if (!dbg.closed) dbg.close('Canceled');
|
||||
}, CANCEL_GRACE_MS);
|
||||
};
|
||||
signal.addEventListener('abort', onAbort);
|
||||
let result: T;
|
||||
try {
|
||||
result = await run();
|
||||
} catch (e) {
|
||||
if (signal.aborted) throw new ScanCanceledError(last());
|
||||
throw e;
|
||||
} finally {
|
||||
signal.removeEventListener('abort', onAbort);
|
||||
clearTimeout(timer);
|
||||
}
|
||||
if (signal.aborted && !keepFinished) throw new ScanCanceledError(last());
|
||||
return result;
|
||||
}
|
||||
|
||||
export interface ScanHit {
|
||||
@@ -207,8 +319,15 @@ export interface ScanHit {
|
||||
kind?: ValueKind;
|
||||
}
|
||||
|
||||
/** Slices are capped so each one's offsets fit in a u32 and progress moves steadily. */
|
||||
const SLICE_BYTES = 64 * 1024 * 1024;
|
||||
/**
|
||||
* Slices are capped so each one's offsets fit in a u32 and progress moves
|
||||
* steadily. The console answers a slice only when it has finished it, and a
|
||||
* real game is searched at roughly 100 MB a second, so 16 MB reports several
|
||||
* times a second and a cancel never waits long. A request costs the console
|
||||
* next to nothing (it reads 1 MB at a time whatever the slice size).
|
||||
*/
|
||||
export const SCAN_SLICE_BYTES = 16 * 1024 * 1024;
|
||||
const SLICE_BYTES = SCAN_SLICE_BYTES;
|
||||
|
||||
/**
|
||||
* Candidate limit. Past this the result set is not worth keeping in a browser
|
||||
@@ -363,46 +482,46 @@ export class ScanSession implements Scan {
|
||||
}
|
||||
|
||||
/** First pass over every scannable region. */
|
||||
async first(
|
||||
regions: ScanRegion[],
|
||||
compare: CompareKind,
|
||||
value: Uint8Array,
|
||||
onProgress: (p: ScanProgress) => void,
|
||||
): Promise<void> {
|
||||
async first(regions: ScanRegion[], compare: CompareKind, value: Uint8Array, onProgress: (p: ScanProgress) => void, signal?: AbortSignal): Promise<void> {
|
||||
if (!compare.first) throw new Error(`"${compare.label}" needs an earlier scan to compare against`);
|
||||
const dbg = this.getDbg();
|
||||
if (!dbg.authed) await dbg.auth();
|
||||
|
||||
const total = regions.reduce((n, r) => n + r.length, 0);
|
||||
let done = 0;
|
||||
let matches = 0;
|
||||
const slices: Slice[] = [];
|
||||
const at = (): ScanProgress => ({ fraction: total ? done / total : 1, matches, detail: '', done, total, unit: 'bytes' });
|
||||
// Before the first slice comes back, so the screen can say how much there is to do.
|
||||
onProgress(at());
|
||||
|
||||
for (const region of regions) {
|
||||
// Asked for again each slice: the connection is replaced when the console drops it.
|
||||
const dbg = await liveDbg(this.getDbg);
|
||||
const collector = new BlockCollector();
|
||||
await dbg.scanStart(
|
||||
{
|
||||
pid: this.pid,
|
||||
address: region.start,
|
||||
length: region.length,
|
||||
valueType: this.kind.wire,
|
||||
compareType: compare.wire,
|
||||
alignment: this.kind.size,
|
||||
value,
|
||||
},
|
||||
(block) => {
|
||||
// Keep draining after the limit so the stream stays in step.
|
||||
if (matches + collector.count <= MAX_MATCHES) collector.add(block);
|
||||
else collector.count += block.count;
|
||||
},
|
||||
);
|
||||
await cancelable(dbg, signal, at, async () => {
|
||||
if (!dbg.authed) await dbg.auth();
|
||||
await dbg.scanStart(
|
||||
{
|
||||
pid: this.pid,
|
||||
address: region.start,
|
||||
length: region.length,
|
||||
valueType: this.kind.wire,
|
||||
compareType: compare.wire,
|
||||
alignment: this.kind.size,
|
||||
value,
|
||||
},
|
||||
(block) => {
|
||||
// Keep draining after the limit so the stream stays in step.
|
||||
if (matches + collector.count <= MAX_MATCHES) collector.add(block);
|
||||
else collector.count += block.count;
|
||||
},
|
||||
);
|
||||
});
|
||||
matches += collector.count;
|
||||
if (matches > MAX_MATCHES) throw new TooManyMatchesError();
|
||||
if (collector.count > 0) {
|
||||
slices.push({ base: region.start, length: region.length, records: collector.take(), count: collector.count });
|
||||
}
|
||||
done += region.length;
|
||||
onProgress({ fraction: total ? done / total : 1, matches, detail: '' });
|
||||
onProgress(at());
|
||||
}
|
||||
|
||||
this.slices = slices;
|
||||
@@ -415,37 +534,41 @@ export class ScanSession implements Scan {
|
||||
* to be in ascending address order with a common alignment; that holds
|
||||
* because first() scans aligned and results are never reordered.
|
||||
*/
|
||||
async next(compare: CompareKind, value: Uint8Array, onProgress: (p: ScanProgress) => void): Promise<void> {
|
||||
async next(compare: CompareKind, value: Uint8Array, onProgress: (p: ScanProgress) => void, signal?: AbortSignal): Promise<void> {
|
||||
if (this.passes === 0) throw new Error('Run a first scan before narrowing down');
|
||||
const dbg = this.getDbg();
|
||||
if (!dbg.authed) await dbg.auth();
|
||||
const recordSize = 4 + this.kind.size;
|
||||
const withPrevious = compareNeedsPrevious(compare.wire);
|
||||
const before = this.matches;
|
||||
let processed = 0;
|
||||
let matches = 0;
|
||||
const kept: Slice[] = [];
|
||||
const at = (): ScanProgress => ({ fraction: before ? processed / before : 1, matches, detail: '', done: processed, total: before, unit: 'candidates' });
|
||||
onProgress(at());
|
||||
|
||||
for (const slice of this.slices) {
|
||||
const dbg = await liveDbg(this.getDbg);
|
||||
const candidates = withPrevious ? slice.records : stripValues(slice.records, recordSize);
|
||||
const collector = new BlockCollector();
|
||||
await dbg.scanCount(
|
||||
{
|
||||
pid: this.pid,
|
||||
base: slice.base,
|
||||
valueType: this.kind.wire,
|
||||
compareType: compare.wire,
|
||||
value,
|
||||
candidates,
|
||||
},
|
||||
(block) => collector.add(block),
|
||||
);
|
||||
await cancelable(dbg, signal, at, async () => {
|
||||
if (!dbg.authed) await dbg.auth();
|
||||
await dbg.scanCount(
|
||||
{
|
||||
pid: this.pid,
|
||||
base: slice.base,
|
||||
valueType: this.kind.wire,
|
||||
compareType: compare.wire,
|
||||
value,
|
||||
candidates,
|
||||
},
|
||||
(block) => collector.add(block),
|
||||
);
|
||||
});
|
||||
if (collector.count > 0) {
|
||||
kept.push({ base: slice.base, length: slice.length, records: collector.take(), count: collector.count });
|
||||
matches += collector.count;
|
||||
}
|
||||
processed += slice.count;
|
||||
onProgress({ fraction: before ? processed / before : 1, matches, detail: '' });
|
||||
onProgress(at());
|
||||
}
|
||||
|
||||
// Records are never changed in place, so keeping the old list costs no copying.
|
||||
@@ -510,28 +633,76 @@ export class SnapshotScan implements Scan {
|
||||
this.kind = kind;
|
||||
}
|
||||
|
||||
async first(regions: ScanRegion[], _compare: CompareKind, _value: Uint8Array, onProgress: (p: ScanProgress) => void): Promise<void> {
|
||||
const dbg = this.getDbg();
|
||||
if (!dbg.authed) await dbg.auth();
|
||||
const count = await dbg.turboSnapshot(
|
||||
{ pid: this.pid, valueType: this.kind.wire, segments: regions.map((r) => ({ address: r.start, length: r.length })) },
|
||||
(done, total) => onProgress({ fraction: total ? done / total : 0, matches: 0, detail: '' }),
|
||||
);
|
||||
if (count === null) throw new Error('The console could not hold a snapshot of this much memory. Try a search for a known value instead.');
|
||||
async first(regions: ScanRegion[], _compare: CompareKind, _value: Uint8Array, onProgress: (p: ScanProgress) => void, signal?: AbortSignal): Promise<void> {
|
||||
const dbg = await liveDbg(this.getDbg);
|
||||
const bytes = regions.reduce((n, r) => n + r.length, 0);
|
||||
let last: ScanProgress = { fraction: 0, matches: 0, detail: '', done: 0, total: bytes, unit: 'bytes' };
|
||||
onProgress(last);
|
||||
// One long request; the console says how far it is as it goes.
|
||||
let count: number | null;
|
||||
try {
|
||||
count = await cancelable(
|
||||
dbg,
|
||||
signal,
|
||||
() => last,
|
||||
async () => {
|
||||
if (!dbg.authed) await dbg.auth();
|
||||
return dbg.turboSnapshot({ pid: this.pid, valueType: this.kind.wire, segments: regions.map((r) => ({ address: r.start, length: r.length })) }, (done, total) =>
|
||||
onProgress((last = { fraction: total ? Math.min(1, done / total) : 0, matches: 0, detail: '', done, total, unit: 'bytes' })),
|
||||
);
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
// Canceled just as the snapshot finished: it exists on the console but nobody will use it, so free it.
|
||||
if (e instanceof ScanCanceledError && !dbg.closed) await dbg.turboEnd().catch(() => undefined);
|
||||
throw e;
|
||||
}
|
||||
if (count === null) {
|
||||
throw new Error(
|
||||
'The console could not hold a snapshot of this much memory. Open "Where to search" and uncheck the biggest blocks, or search for a number you can see instead.',
|
||||
);
|
||||
}
|
||||
this.owner = dbg;
|
||||
this.slots = regions.reduce((n, r) => n + Math.floor(r.length / this.kind.size), 0);
|
||||
this.matches = count;
|
||||
this.passes = 1;
|
||||
}
|
||||
|
||||
async next(compare: CompareKind, value: Uint8Array, onProgress: (p: ScanProgress) => void): Promise<void> {
|
||||
/**
|
||||
* The console narrows its list in place, so a pass that is canceled (which
|
||||
* means closing the connection) or fails loses the whole search.
|
||||
*/
|
||||
async next(compare: CompareKind, value: Uint8Array, onProgress: (p: ScanProgress) => void, signal?: AbortSignal): Promise<void> {
|
||||
const dbg = this.checkOwner();
|
||||
this.matches = await dbg.turboNarrow({ pid: this.pid, valueType: this.kind.wire, compareType: compare.wire, value }, (done) =>
|
||||
onProgress({ fraction: this.slots ? Math.min(1, done / this.slots) : 0, matches: 0, detail: '' }),
|
||||
);
|
||||
// The console counts every value it started with, not the ones still in the list.
|
||||
let last: ScanProgress = { fraction: 0, matches: 0, detail: '' };
|
||||
onProgress(last);
|
||||
try {
|
||||
// If the console finishes before a cancel can stop it, its list is narrowed whether we like it or not: take the result.
|
||||
this.matches = await cancelable(
|
||||
dbg,
|
||||
signal,
|
||||
() => last,
|
||||
() =>
|
||||
dbg.turboNarrow({ pid: this.pid, valueType: this.kind.wire, compareType: compare.wire, value }, (done) =>
|
||||
onProgress((last = { fraction: this.slots ? Math.min(1, done / this.slots) : 0, matches: 0, detail: '' })),
|
||||
),
|
||||
true,
|
||||
);
|
||||
} catch (e) {
|
||||
if (dbg.closed) {
|
||||
this.owner = null;
|
||||
this.matches = 0;
|
||||
this.lost = true;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
this.passes++;
|
||||
}
|
||||
|
||||
/** True once the console has dropped the list (the connection it lived on closed). */
|
||||
lost = false;
|
||||
|
||||
async hits(limit: number): Promise<ScanHit[]> {
|
||||
if (this.matches === 0) return [];
|
||||
const rows = await this.checkOwner().turboGet(0, limit, this.kind.size);
|
||||
@@ -620,11 +791,13 @@ export class AutoScan implements Scan {
|
||||
}
|
||||
}
|
||||
|
||||
async first(regions: ScanRegion[], compare: CompareKind, _value: Uint8Array, onProgress: (p: ScanProgress) => void): Promise<void> {
|
||||
async first(regions: ScanRegion[], compare: CompareKind, _value: Uint8Array, onProgress: (p: ScanProgress) => void, signal?: AbortSignal): Promise<void> {
|
||||
if (compare.inputs === 0) throw new Error('Auto needs a number to look for. Pick a type to search for an unknown value.');
|
||||
const plan = AUTO_TRY.map((id) => valueKind(id))
|
||||
.map((kind) => ({ kind, value: this.encode(kind, compare) }))
|
||||
.filter((p): p is { kind: ValueKind; value: Uint8Array } => p.value !== null);
|
||||
// No type can hold it: say why, in the words the widest type would use.
|
||||
if (plan.length === 0) encodeCompare(valueKind('double'), compare, this.texts[0], this.texts[1]);
|
||||
if (plan.length === 0) throw new Error(`"${this.texts[0]}" is not a number`);
|
||||
|
||||
const parts: ScanSession[] = [];
|
||||
@@ -634,13 +807,27 @@ export class AutoScan implements Scan {
|
||||
const { kind, value } = plan[i];
|
||||
const part = new ScanSession(this.getDbg, this.pid, kind);
|
||||
const report = (p: ScanProgress) =>
|
||||
onProgress({ fraction: (i + p.fraction) / plan.length, matches: found + p.matches, detail: `as ${kind.label.toLowerCase()}` });
|
||||
onProgress({
|
||||
...p,
|
||||
fraction: (i + p.fraction) / plan.length,
|
||||
matches: found + p.matches,
|
||||
detail: `as ${kind.label.toLowerCase()}`,
|
||||
part: { index: i + 1, count: plan.length },
|
||||
});
|
||||
try {
|
||||
await part.first(regions, compare, value, report);
|
||||
await part.first(regions, compare, value, report, signal);
|
||||
parts.push(part);
|
||||
found += part.matches;
|
||||
} catch (e) {
|
||||
// One type drowning in matches should not sink the others.
|
||||
if (e instanceof ScanCanceledError && e.progress) {
|
||||
e.progress = {
|
||||
...e.progress,
|
||||
fraction: (i + e.progress.fraction) / plan.length,
|
||||
matches: found + e.progress.matches,
|
||||
part: { index: i + 1, count: plan.length },
|
||||
};
|
||||
}
|
||||
if (!(e instanceof TooManyMatchesError)) throw e;
|
||||
dropped.push({ kind, reason: 'too many matches' });
|
||||
}
|
||||
@@ -652,9 +839,13 @@ export class AutoScan implements Scan {
|
||||
this.passes = 1;
|
||||
}
|
||||
|
||||
async next(compare: CompareKind, _value: Uint8Array, onProgress: (p: ScanProgress) => void): Promise<void> {
|
||||
async next(compare: CompareKind, _value: Uint8Array, onProgress: (p: ScanProgress) => void, signal?: AbortSignal): Promise<void> {
|
||||
if (this.passes === 0) throw new Error('Run a first scan before narrowing down');
|
||||
const before = this.matches;
|
||||
// Every type that is left must be able to hold what was typed, or nothing could match: say so instead of emptying the list.
|
||||
if (compare.inputs > 0 && this.parts.every((p) => this.encode(p.kind, compare) === null)) {
|
||||
encodeCompare(this.parts[0]?.kind ?? valueKind('double'), compare, this.texts[0], this.texts[1]);
|
||||
}
|
||||
const kept: ScanSession[] = [];
|
||||
const dropped = [...this.dropped];
|
||||
const narrowed: ScanSession[] = [];
|
||||
@@ -669,8 +860,19 @@ export class AutoScan implements Scan {
|
||||
dropped.push({ kind: part.kind, reason: 'cannot hold that value' });
|
||||
} else if (had > 0) {
|
||||
const base = done;
|
||||
await part.next(compare, value, (p) =>
|
||||
onProgress({ fraction: before ? (base + p.fraction * had) / before : 1, matches: found + p.matches, detail: `as ${part.kind.label.toLowerCase()}` }),
|
||||
await part.next(
|
||||
compare,
|
||||
value,
|
||||
(p) =>
|
||||
onProgress({
|
||||
fraction: before ? (base + p.fraction * had) / before : 1,
|
||||
matches: found + p.matches,
|
||||
detail: `as ${part.kind.label.toLowerCase()}`,
|
||||
done: base + (p.done ?? 0),
|
||||
total: before,
|
||||
unit: 'candidates',
|
||||
}),
|
||||
signal,
|
||||
);
|
||||
narrowed.push(part);
|
||||
if (part.matches > 0) kept.push(part);
|
||||
|
||||
+55
-1
@@ -24,7 +24,7 @@ import type { ActiveCode, CodeEntry, CodeRule } from './codecheat.ts';
|
||||
import { LocationError, describeLocation, imageBase, resolveLocation } from './locations.ts';
|
||||
import type { GameContext, Location } from './locations.ts';
|
||||
import { parsePattern } from './x86.ts';
|
||||
import { decodeValue, encodeValue, valueKind } from './scanner.ts';
|
||||
import { VALUE_KINDS, decodeValue, encodeValue, valueKind } from './scanner.ts';
|
||||
import type { ScriptHandle, ScriptStop } from './script.ts';
|
||||
import { loadJson, saveJson } from './storage.ts';
|
||||
|
||||
@@ -315,6 +315,9 @@ export function parseOwnTable(text: string, opts: { own?: boolean } = {}): OwnTa
|
||||
id: newId(),
|
||||
unreviewed: runsCode && !(opts.own && reviewed === true) ? true : undefined,
|
||||
} as Entry;
|
||||
// A file edited by hand, or cut short, can hold a cheat with its main parts
|
||||
// missing. Such a cheat is left out rather than let through to break the page.
|
||||
if (!wellFormed(entry)) continue;
|
||||
tidyExtras(entry);
|
||||
// "Keep on" is the user's own decision for their own console. A file from
|
||||
// someone else never switches it on, and neither does a file that asks for
|
||||
@@ -331,6 +334,57 @@ export function parseOwnTable(text: string, opts: { own?: boolean } = {}): OwnTa
|
||||
};
|
||||
}
|
||||
|
||||
function isLocation(v: unknown): v is Location {
|
||||
const l = v as Record<string, unknown> | null;
|
||||
if (!l || typeof l !== 'object') return false;
|
||||
if (l.module !== undefined && typeof l.module !== 'string') return false;
|
||||
switch (l.kind) {
|
||||
case 'module':
|
||||
return typeof l.offset === 'string';
|
||||
case 'signature':
|
||||
if (l.offset === undefined) l.offset = 0;
|
||||
return typeof l.pattern === 'string' && Number.isInteger(l.offset);
|
||||
case 'pointer':
|
||||
return typeof l.base === 'string' && Array.isArray(l.offsets) && l.offsets.every((o) => typeof o === 'string');
|
||||
case 'address':
|
||||
return typeof l.address === 'string';
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether an entry read from a file has the parts its kind cannot do without, each of the right shape. */
|
||||
function wellFormed(entry: Entry): boolean {
|
||||
const e = entry as Entry & Record<string, unknown>;
|
||||
const site = (x: unknown): boolean => {
|
||||
const p = x as PatchSite | null;
|
||||
return !!p && typeof p === 'object' && isLocation(p.at) && typeof p.on === 'string' && (p.off === undefined || typeof p.off === 'string');
|
||||
};
|
||||
switch (e.type) {
|
||||
case 'patch':
|
||||
if (!site(e)) return false;
|
||||
if (e.more !== undefined) {
|
||||
const more = Array.isArray(e.more) ? e.more.filter(site) : [];
|
||||
if (more.length) e.more = more;
|
||||
else delete e.more;
|
||||
}
|
||||
return true;
|
||||
case 'value':
|
||||
if (typeof e.value === 'number') e.value = String(e.value);
|
||||
return isLocation(e.at) && typeof e.value === 'string' && typeof e.valueType === 'string' && VALUE_KINDS.some((k) => k.id === e.valueType) && typeof e.freeze === 'boolean';
|
||||
case 'inject':
|
||||
return isLocation(e.at);
|
||||
case 'call':
|
||||
if (!Array.isArray(e.args)) return false;
|
||||
e.args = e.args.map((a) => (typeof a === 'number' ? String(a) : a));
|
||||
return isLocation(e.fn) && e.args.every((a) => typeof a === 'string');
|
||||
case 'script':
|
||||
return typeof e.code === 'string';
|
||||
case 'shared':
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/** Drops optional fields that are not the shape this version understands, so the rest of the app can trust them. */
|
||||
function tidyExtras(entry: Entry): void {
|
||||
const e = entry as Entry & Record<string, unknown>;
|
||||
|
||||
+108
-18
@@ -10,11 +10,13 @@
|
||||
// Ignoring case. The console's pattern search can ignore a whole byte but not
|
||||
// one bit of it, so "either case" cannot be asked for directly. Instead:
|
||||
// - with up to five ASCII letters, every mix of upper and lower case is
|
||||
// searched for (at most 32 spellings), which is exact;
|
||||
// - with more letters, four spellings are searched for: as typed, all lower,
|
||||
// ALL UPPER and Title Case. Odd mixes such as "hEaLtH" are then missed.
|
||||
// `allCases` in the result says which of the two happened.
|
||||
// Only the letters A to Z are treated this way; accented letters match as typed.
|
||||
// searched for (at most 32 spellings);
|
||||
// - with more letters, every mix of the first three is searched for and the
|
||||
// other letters are left open ("any byte here"). Each place the console
|
||||
// reports is then read back and kept only if it really is the text, in
|
||||
// whatever capitals. "godmode" finds "GodMode" this way.
|
||||
// Both are exact. Only the letters A to Z are treated this way; accented
|
||||
// letters match as typed.
|
||||
//
|
||||
// Limits. Plain text means UTF-8 and wide text means UTF-16 little-endian (two
|
||||
// bytes per character, what Unreal Engine uses); other encodings are not
|
||||
@@ -25,6 +27,7 @@ import type { Ps5Debug } from './ps5debug.ts';
|
||||
import { aobScanMulti, readMulti } from './ps5debug-extra.ts';
|
||||
import { mergeRanges, rangesBytes } from './regions.ts';
|
||||
import type { MemRange } from './regions.ts';
|
||||
import { ScanCanceledError, cancelable, liveDbg } from './scanner.ts';
|
||||
|
||||
export type TextEncoding = 'utf8' | 'utf16';
|
||||
|
||||
@@ -36,6 +39,10 @@ export interface TextNeedle {
|
||||
/** The spelling this pattern stands for. */
|
||||
text: string;
|
||||
bytes: Uint8Array;
|
||||
/** 1 where the byte has to match, 0 where any byte is accepted (a letter whose case is left open). */
|
||||
mask: Uint8Array;
|
||||
/** True when the mask leaves letters open, so each match has to be read back and checked. */
|
||||
loose: boolean;
|
||||
}
|
||||
|
||||
export interface TextHit {
|
||||
@@ -55,17 +62,20 @@ export interface TextSearchResult {
|
||||
/** True when the search stopped because it reached `maxHits`. The rest of memory was not searched, so there may be more. */
|
||||
capped: boolean;
|
||||
canceled: boolean;
|
||||
/** The spellings searched for. */
|
||||
/** The text searched for (kept as a list for older callers). */
|
||||
spellings: string[];
|
||||
/** False when ignore-case was asked for but only the common spellings could be searched. */
|
||||
/** Always true: ignoring case finds every mix of capitals. Kept for older callers. */
|
||||
allCases: boolean;
|
||||
bytesSearched: number;
|
||||
}
|
||||
|
||||
export interface TextProgress {
|
||||
/** 0..1 */
|
||||
/** 0..1: bytes searched over bytes to search. */
|
||||
fraction: number;
|
||||
hits: number;
|
||||
/** Bytes searched so far and in all. */
|
||||
done: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface TextSearchOptions {
|
||||
@@ -88,6 +98,8 @@ export interface TextSearchOptions {
|
||||
export const MAX_TEXT_CHARS = 128;
|
||||
/** With this many letters or fewer, ignore-case tries every mix of upper and lower case. */
|
||||
const ALL_CASES_LETTERS = 5;
|
||||
/** With more letters than that, this many leading letters are tried in every mix and the rest are left open. */
|
||||
const LEADING_LETTERS = 3;
|
||||
const DEFAULT_CHUNK = 16 * 1024 * 1024;
|
||||
/** Characters of context shown on each side of a hit. */
|
||||
const CONTEXT_CHARS = 40;
|
||||
@@ -134,10 +146,54 @@ export function textNeedles(text: string, opts: { encodings: TextEncoding[]; ign
|
||||
if ([...text].length < 2) throw new Error('One character is found almost everywhere. Type at least two.');
|
||||
if ([...text].length > MAX_TEXT_CHARS) throw new Error(`That is a lot of text. The first ${MAX_TEXT_CHARS} characters or fewer are enough to find it.`);
|
||||
if (opts.encodings.length === 0) throw new Error('Choose plain text, wide text, or both');
|
||||
const { spellings, allCases } = opts.ignoreCase ? caseSpellings(text) : { spellings: [text], allCases: true };
|
||||
const chars = [...text];
|
||||
const letters = chars.map((c, i) => (isLetter(c) ? i : -1)).filter((i) => i >= 0);
|
||||
const needles: TextNeedle[] = [];
|
||||
for (const encoding of opts.encodings) for (const s of spellings) needles.push({ encoding, text: s, bytes: encodeText(s, encoding) });
|
||||
return { needles, spellings, allCases };
|
||||
if (!opts.ignoreCase || letters.length <= ALL_CASES_LETTERS) {
|
||||
const spellings = opts.ignoreCase ? caseSpellings(text).spellings : [text];
|
||||
for (const encoding of opts.encodings) {
|
||||
for (const s of spellings) {
|
||||
const bytes = encodeText(s, encoding);
|
||||
needles.push({ encoding, text: s, bytes, mask: new Uint8Array(bytes.length).fill(1), loose: false });
|
||||
}
|
||||
}
|
||||
return { needles, spellings: [text], allCases: true };
|
||||
}
|
||||
// Many letters: every mix of the first few (so each pattern starts with fixed bytes, which
|
||||
// is what keeps the console's search fast), and any byte where the other letters are.
|
||||
const lead = letters.slice(0, LEADING_LETTERS);
|
||||
const open = new Set(letters.slice(LEADING_LETTERS));
|
||||
for (const encoding of opts.encodings) {
|
||||
for (let bits = 0; bits < 1 << lead.length; bits++) {
|
||||
const copy = [...chars];
|
||||
lead.forEach((at, k) => {
|
||||
copy[at] = bits & (1 << k) ? copy[at].toUpperCase() : copy[at].toLowerCase();
|
||||
});
|
||||
const parts = copy.map((c) => encodeText(c, encoding));
|
||||
const bytes = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
|
||||
const mask = new Uint8Array(bytes.length).fill(1);
|
||||
let o = 0;
|
||||
parts.forEach((p, i) => {
|
||||
bytes.set(p, o);
|
||||
// A letter is one byte, or in wide text one byte and a zero: only the letter's own byte is left open.
|
||||
if (open.has(i)) mask[o] = 0;
|
||||
o += p.length;
|
||||
});
|
||||
needles.push({ encoding, text: copy.join(''), bytes, mask, loose: true });
|
||||
}
|
||||
}
|
||||
return { needles, spellings: [text], allCases: true };
|
||||
}
|
||||
|
||||
/** True when `mem` holds the needle's text at `at`, counting upper and lower case of an open letter as the same. */
|
||||
export function looseMatch(needle: TextNeedle, mem: Uint8Array, at = 0): boolean {
|
||||
if (at + needle.bytes.length > mem.length) return false;
|
||||
for (let i = 0; i < needle.bytes.length; i++) {
|
||||
const want = needle.bytes[i];
|
||||
const got = mem[at + i];
|
||||
if (needle.mask[i] === 1 ? got !== want : (got | 0x20) !== (want | 0x20)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
interface RawHit {
|
||||
@@ -162,10 +218,14 @@ export async function searchText(opts: TextSearchOptions): Promise<TextSearchRes
|
||||
const perCall = needles.length > 16 ? 2 : 4;
|
||||
|
||||
const found = new Map<string, RawHit>();
|
||||
/** Places a loose pattern matched that turned out not to be the text. */
|
||||
const rejected = new Set<string>();
|
||||
let done = 0;
|
||||
let capped = false;
|
||||
let canceled = false;
|
||||
const report = (extra: number) => opts.onProgress?.({ fraction: Math.min(1, (done + extra) / total), hits: Math.min(found.size, maxHits) });
|
||||
const report = (extra: number) =>
|
||||
opts.onProgress?.({ fraction: Math.min(1, (done + extra) / total), hits: Math.min(found.size, maxHits), done: Math.min(total, done + extra), total });
|
||||
report(0);
|
||||
|
||||
/** Searches [start, end) completely, or until the cap or a cancel. */
|
||||
const searchPiece = async (range: MemRange, start: bigint, end: bigint): Promise<void> => {
|
||||
@@ -176,12 +236,25 @@ export async function searchText(opts: TextSearchOptions): Promise<TextSearchRes
|
||||
canceled = true;
|
||||
return;
|
||||
}
|
||||
const queries = active.flatMap((i) =>
|
||||
Array.from({ length: perCall }, (_, n) => ({ pattern: needles[i].bytes, mask: new Uint8Array(needles[i].bytes.length).fill(1), nth: n + 1 })),
|
||||
);
|
||||
const result = await aobScanMulti(opts.dbg(), { pid: opts.pid, address: from, length: Number(end - from), queries });
|
||||
const queries = active.flatMap((i) => Array.from({ length: perCall }, (_, n) => ({ pattern: needles[i].bytes, mask: needles[i].mask, nth: n + 1 })));
|
||||
const dbg = await liveDbg(opts.dbg);
|
||||
let result: bigint[];
|
||||
try {
|
||||
// A cancel gets through even when the console is slow to answer.
|
||||
result = await cancelable(
|
||||
dbg,
|
||||
opts.signal,
|
||||
() => null,
|
||||
() => aobScanMulti(dbg, { pid: opts.pid, address: from, length: Number(end - from), queries }),
|
||||
);
|
||||
} catch (e) {
|
||||
if (!(e instanceof ScanCanceledError)) throw e;
|
||||
canceled = true;
|
||||
return;
|
||||
}
|
||||
const more: number[] = [];
|
||||
let next: bigint | null = null;
|
||||
const fresh: RawHit[] = [];
|
||||
active.forEach((needleIndex, a) => {
|
||||
let last = 0n;
|
||||
let full = true;
|
||||
@@ -192,7 +265,9 @@ export async function searchText(opts: TextSearchOptions): Promise<TextSearchRes
|
||||
break;
|
||||
}
|
||||
const needle = needles[needleIndex];
|
||||
found.set(`${address.toString(16)}:${needle.encoding}`, { address, needle, range });
|
||||
const key = `${address.toString(16)}:${needle.encoding}`;
|
||||
if (!needle.loose) found.set(key, { address, needle, range });
|
||||
else if (!found.has(key) && !rejected.has(key)) fresh.push({ address, needle, range });
|
||||
last = address;
|
||||
}
|
||||
// Every slot filled: this pattern may have more matches after the last one reported.
|
||||
@@ -201,6 +276,21 @@ export async function searchText(opts: TextSearchOptions): Promise<TextSearchRes
|
||||
if (next === null || last + 1n < next) next = last + 1n;
|
||||
}
|
||||
});
|
||||
// Loose patterns also match other words of the same shape: read each new place back and keep the real ones.
|
||||
if (fresh.length > 0) {
|
||||
const reader = await liveDbg(opts.dbg);
|
||||
if (!reader.authed) await reader.auth();
|
||||
const data = await readMulti(
|
||||
reader,
|
||||
opts.pid,
|
||||
fresh.map((h) => ({ address: h.address, length: h.needle.bytes.length })),
|
||||
);
|
||||
fresh.forEach((h, i) => {
|
||||
const key = `${h.address.toString(16)}:${h.needle.encoding}`;
|
||||
if (looseMatch(h.needle, data[i])) found.set(key, h);
|
||||
else rejected.add(key);
|
||||
});
|
||||
}
|
||||
if (found.size >= maxHits) {
|
||||
capped = true;
|
||||
return;
|
||||
@@ -213,7 +303,7 @@ export async function searchText(opts: TextSearchOptions): Promise<TextSearchRes
|
||||
};
|
||||
|
||||
outer: for (const range of ranges) {
|
||||
for (let start = range.start; start < range.end; ) {
|
||||
for (let start = range.start; start < range.end;) {
|
||||
const left = range.end - start;
|
||||
const end = left > BigInt(chunkBytes) ? start + BigInt(chunkBytes) : range.end;
|
||||
await searchPiece(range, start, end);
|
||||
|
||||
+62
-10
@@ -9,7 +9,7 @@ import { LEARN_EVENT } from '../lib/learnlink.ts';
|
||||
import { Session, discoverConsoles, fetchServerInfo } from '../lib/session.ts';
|
||||
import type { ServerInfo, Target } from '../lib/session.ts';
|
||||
import { loadJson, saveJson } from '../lib/storage.ts';
|
||||
import { Ghost, Notice, Wordmark, errorText } from './bits.tsx';
|
||||
import { Ghost, Notice, Safe, Wordmark, errorText } from './bits.tsx';
|
||||
import { ConsoleView } from './ConsoleView.tsx';
|
||||
|
||||
// The Learn section is its own download, fetched the first time it is opened.
|
||||
@@ -23,6 +23,15 @@ type Phase =
|
||||
| { name: 'connected'; session: Session };
|
||||
|
||||
export function App() {
|
||||
// The last line of defense: a fault nothing closer caught still leaves words on the screen instead of a blank page.
|
||||
return (
|
||||
<Safe what="this page">
|
||||
<AppBody />
|
||||
</Safe>
|
||||
);
|
||||
}
|
||||
|
||||
function AppBody() {
|
||||
const [phase, setPhase] = useState<Phase>({ name: 'boot' });
|
||||
const [info, setInfo] = useState<ServerInfo | null>(null);
|
||||
const sessionRef = useRef<Session | null>(null);
|
||||
@@ -39,10 +48,17 @@ export function App() {
|
||||
return () => window.removeEventListener(LEARN_EVENT, open);
|
||||
}, []);
|
||||
|
||||
const connect = useCallback(async (target: Target | null, opts: { quiet?: boolean } = {}) => {
|
||||
setPhase({ name: 'connecting', label: target ? target.label || target.host : 'your PS5' });
|
||||
// `background`: a retry nobody pressed a button for. It changes nothing on screen unless it succeeds.
|
||||
const connect = useCallback(async (target: Target | null, opts: { quiet?: boolean; background?: boolean } = {}) => {
|
||||
if (!opts.background) setPhase({ name: 'connecting', label: target ? target.label || target.host : 'your PS5' });
|
||||
const started = Date.now();
|
||||
try {
|
||||
const session = await Session.connect(target);
|
||||
if (sessionRef.current) {
|
||||
// Another attempt got there first.
|
||||
session.close();
|
||||
return;
|
||||
}
|
||||
sessionRef.current = session;
|
||||
session.onLost = (reason) => {
|
||||
sessionRef.current = null;
|
||||
@@ -51,6 +67,10 @@ export function App() {
|
||||
if (target) saveJson('lastTarget', target);
|
||||
setPhase({ name: 'connected', session });
|
||||
} catch (e) {
|
||||
if (opts.background) return;
|
||||
// A console that refuses does so at once. Stay on "Connecting" long enough to be seen, or "Try again" looks like it did nothing.
|
||||
const shown = Date.now() - started;
|
||||
if (shown < 700) await new Promise((r) => setTimeout(r, 700 - shown));
|
||||
setPhase({ name: 'pick', error: opts.quiet ? undefined : errorText(e) });
|
||||
}
|
||||
}, []);
|
||||
@@ -91,6 +111,7 @@ export function App() {
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl px-4 pt-4 pb-[calc(2rem+env(safe-area-inset-bottom))]">
|
||||
{learn && (
|
||||
<Safe what="the Learn section">
|
||||
<Suspense
|
||||
fallback={
|
||||
<p className="flex items-center gap-2 py-10 text-sm text-muted-foreground">
|
||||
@@ -101,10 +122,12 @@ export function App() {
|
||||
<LearnPage
|
||||
preferLocal={!!info?.demo || import.meta.env.DEV}
|
||||
connected={phase.name === 'connected'}
|
||||
onConsole={info?.mode === 'console'}
|
||||
lesson={learn.lesson}
|
||||
onClose={() => setLearn(null)}
|
||||
/>
|
||||
</Suspense>
|
||||
</Safe>
|
||||
)}
|
||||
{phase.name === 'connected' ? (
|
||||
// Kept in place while the Learn section is open, so a search or a cheat on the real game carries on behind it.
|
||||
@@ -139,7 +162,13 @@ export function App() {
|
||||
</div>
|
||||
)}
|
||||
{phase.name === 'pick' && (
|
||||
<Picker info={info} error={phase.error} onPick={(t) => void connect(t)} consoleMode={info?.mode === 'console'} />
|
||||
<Picker
|
||||
info={info}
|
||||
error={phase.error}
|
||||
onPick={(t) => void connect(t)}
|
||||
onRetryQuietly={() => void connect(null, { background: true })}
|
||||
consoleMode={info?.mode === 'console'}
|
||||
/>
|
||||
)}
|
||||
{(phase.name === 'pick' || phase.name === 'unavailable') && <LearnDoor onOpen={() => setLearn({ lesson: null })} />}
|
||||
</div>
|
||||
@@ -182,7 +211,14 @@ function ConsoleRow(props: { title: string; detail: string; mono?: boolean; onCl
|
||||
);
|
||||
}
|
||||
|
||||
function Picker(props: { info: ServerInfo | null; error?: string; consoleMode: boolean; onPick: (t: Target | null) => void }) {
|
||||
function Picker(props: {
|
||||
info: ServerInfo | null;
|
||||
error?: string;
|
||||
consoleMode: boolean;
|
||||
onPick: (t: Target | null) => void;
|
||||
/** Console mode only: try once more without changing the screen. */
|
||||
onRetryQuietly?: () => void;
|
||||
}) {
|
||||
const [found, setFound] = useState<Target[] | null>(null);
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [ip, setIp] = useState(() => loadJson<Target | null>('lastTarget', null)?.host ?? '');
|
||||
@@ -203,16 +239,32 @@ function Picker(props: { info: ServerInfo | null; error?: string; consoleMode: b
|
||||
if (!props.consoleMode) void search();
|
||||
}, [props.consoleMode, search]);
|
||||
|
||||
// Served by the PS5: there is one console, so the page keeps trying it by
|
||||
// itself. Loading the debugger is then all the person has to do.
|
||||
const { consoleMode, onRetryQuietly } = props;
|
||||
useEffect(() => {
|
||||
if (!consoleMode || !onRetryQuietly) return;
|
||||
const timer = setInterval(() => {
|
||||
if (document.visibilityState !== 'hidden') onRetryQuietly();
|
||||
}, 4000);
|
||||
return () => clearInterval(timer);
|
||||
}, [consoleMode, onRetryQuietly]);
|
||||
|
||||
if (props.consoleMode) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
{props.error && <Notice kind="error">{props.error}</Notice>}
|
||||
<CardContent className="flex flex-col items-start gap-3">
|
||||
<h2 className="text-lg font-semibold">The debugger on your PS5 is not answering</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
The debugger is not answering. Load ps5debug-NG on the console, then try again.
|
||||
Load ps5debug-NG on the console; it shows a "loaded" notification when it is ready. This page connects by itself as soon as it
|
||||
is running.
|
||||
</p>
|
||||
<Button className="self-start" onClick={() => props.onPick(null)}>
|
||||
Try again
|
||||
<p className="flex items-center gap-2 text-sm text-muted-foreground" role="status">
|
||||
<Spinner className="size-4" /> Waiting for the debugger…
|
||||
</p>
|
||||
{props.error && <p className="text-xs break-words text-muted-foreground">What the console said: {props.error}</p>}
|
||||
<Button variant="outline" onClick={() => props.onPick(null)}>
|
||||
Try now
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
+105
-22
@@ -42,6 +42,19 @@ async function stateOf(session: Session, loaded: Pick<Loaded, 'pid' | 'resolver'
|
||||
return readModState(session.main, loaded.pid, loaded.resolver, mod);
|
||||
}
|
||||
|
||||
/** "A", "A or B", "A, B or C". */
|
||||
function orList(names: string[]): string {
|
||||
return names.length < 2 ? names.join('') : `${names.slice(0, -1).join(', ')} or ${names[names.length - 1]}`;
|
||||
}
|
||||
|
||||
/** A download that failed, in words that say what to do; the browser's own ("Failed to fetch", "HTTP 404") say nothing to most people. */
|
||||
function downloadProblem(e: unknown): string {
|
||||
const text = errorText(e);
|
||||
if (/^HTTP 404/.test(text)) return 'This table is listed in the collection, but its file is no longer there. Pick another table, or import a file below.';
|
||||
if (/^HTTP \d+|fetch|network|abort|timed out/i.test(text)) return `This table could not be downloaded (${text}). Check that this device is online, then press Try again.`;
|
||||
return text;
|
||||
}
|
||||
|
||||
const entryKey = (e: CatalogEntry) => `${e.sourceId}/${e.file}`;
|
||||
|
||||
export function CheatsTab(props: { session: Session; game: Game; localCheats: boolean; onGameName: (name: string) => void }) {
|
||||
@@ -54,6 +67,9 @@ export function CheatsTab(props: { session: Session; game: Game; localCheats: bo
|
||||
const [loaded, setLoaded] = useState<Loaded | null>(null);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
/** Bumped to load the selected table again after a failure. */
|
||||
const [loadAttempt, setLoadAttempt] = useState(0);
|
||||
const [lookingAgain, setLookingAgain] = useState(false);
|
||||
const [states, setStates] = useState<ModState[]>([]);
|
||||
const [busy, setBusy] = useState<number | null>(null);
|
||||
const [rowError, setRowError] = useState<{ index: number; text: string } | null>(null);
|
||||
@@ -141,7 +157,7 @@ export function CheatsTab(props: { session: Session; game: Game; localCheats: bo
|
||||
}
|
||||
})()
|
||||
.catch((e) => {
|
||||
if (!canceled) setLoadError(errorText(e));
|
||||
if (!canceled) setLoadError(downloadProblem(e));
|
||||
})
|
||||
.finally(() => {
|
||||
if (!canceled) setLoading(false);
|
||||
@@ -149,7 +165,16 @@ export function CheatsTab(props: { session: Session; game: Game; localCheats: bo
|
||||
return () => {
|
||||
canceled = true;
|
||||
};
|
||||
}, [selected, game.pid, session]);
|
||||
}, [selected, game.pid, session, loadAttempt]);
|
||||
|
||||
const lookAgain = async () => {
|
||||
setLookingAgain(true);
|
||||
try {
|
||||
await refreshCatalog();
|
||||
} finally {
|
||||
if (alive.current) setLookingAgain(false);
|
||||
}
|
||||
};
|
||||
|
||||
const setState = (index: number, s: ModState) =>
|
||||
setStates((prev) => {
|
||||
@@ -287,10 +312,29 @@ export function CheatsTab(props: { session: Session; game: Game; localCheats: bo
|
||||
setImportError(`That table is for ${incoming.titleId} and the running game is ${game.titleId}, so it was not imported.`);
|
||||
return;
|
||||
}
|
||||
// How many the file lists, to say so when some were not usable.
|
||||
let listed = incoming.entries.length;
|
||||
try {
|
||||
const doc = JSON.parse(text.replace(/^\uFEFF/, '')) as { cheats?: unknown; entries?: unknown };
|
||||
const list = Array.isArray(doc.cheats) ? doc.cheats : Array.isArray(doc.entries) ? doc.entries : [];
|
||||
listed = Math.max(listed, list.length);
|
||||
} catch {
|
||||
// Already parsed once above; nothing more to learn here.
|
||||
}
|
||||
const unreadable = listed - incoming.entries.length;
|
||||
if (incoming.entries.length === 0) {
|
||||
setImportError(
|
||||
unreadable > 0
|
||||
? `Nothing was imported from ${file.name}: none of its ${unreadable === 1 ? 'cheat' : `${unreadable} cheats`} could be read. The file may be damaged or edited by hand.`
|
||||
: `${file.name} has no cheats in it, so nothing was imported.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
wb.setTable((t) => ({ ...t, name: t.name || incoming.name, entries: [...t.entries, ...incoming.entries] }));
|
||||
const gated = incoming.entries.filter((e) => e.unreviewed).length;
|
||||
setImportNote(
|
||||
`Added ${incoming.entries.length} to your cheats.` +
|
||||
`Added ${incoming.entries.length} to your cheats, at the top of this page.` +
|
||||
(unreadable > 0 ? ` ${unreadable} more in the file could not be read and ${unreadable === 1 ? 'was' : 'were'} left out.` : '') +
|
||||
(gated ? ` ${gated} can run code in the game and ${gated === 1 ? 'needs' : 'need'} a look before first use: press Review.` : '') +
|
||||
(incoming.version && game.version && incoming.version !== game.version ? ` It was made on v${incoming.version}; you are running v${game.version}.` : ''),
|
||||
);
|
||||
@@ -298,13 +342,18 @@ export function CheatsTab(props: { session: Session; game: Game; localCheats: bo
|
||||
}
|
||||
const table = importTable(file.name, text);
|
||||
if (table.titleId !== game.titleId.toUpperCase()) {
|
||||
setImportError(`Saved, but that table is for ${table.titleId} and the running game is ${game.titleId}.`);
|
||||
setImportError(
|
||||
`${file.name} is for another game (${table.titleId}); the one running is ${game.titleId}. It is kept in this browser and will be offered when that game is running.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
setSelected(null);
|
||||
await refreshCatalog(file.name);
|
||||
setImportNote(`Imported ${file.name}: ${table.mods.length} ${table.mods.length === 1 ? 'cheat' : 'cheats'}. It is now the one chosen under "Cheat table" above.`);
|
||||
} catch (err) {
|
||||
setImportError(errorText(err));
|
||||
// Whatever went wrong inside a damaged file, the person needs to know which file and that nothing changed.
|
||||
const text = errorText(err);
|
||||
setImportError(/^(Cannot read|undefined is not|null is not|.* is not a function)/.test(text) ? `${file.name} is damaged or not a cheat table, so nothing was imported.` : `${file.name} was not imported. ${text}`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -313,24 +362,39 @@ export function CheatsTab(props: { session: Session; game: Game; localCheats: bo
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{(wb.canRestore || wb.restoring) && (
|
||||
<div className="flex flex-wrap items-center justify-between gap-x-3 gap-y-2 rounded-md border border-primary/40 px-3 py-2">
|
||||
<p className="min-w-0 flex-1 basis-52 text-sm text-muted-foreground">
|
||||
{/* Always here, so it does not push the cheats down under a finger the moment the first one is switched on. */}
|
||||
<div className={'flex flex-wrap items-center justify-between gap-x-3 gap-y-2 rounded-md border px-3 py-2' + (wb.canRestore || wb.restoring ? ' border-primary/40' : '')}>
|
||||
{/* Both wordings take up room, one of them unseen, so the bar is the same height either way. */}
|
||||
<p className="grid min-w-0 flex-1 basis-52 text-sm text-muted-foreground">
|
||||
<span className={'col-start-1 row-start-1' + (wb.canRestore || wb.restoring ? '' : ' invisible')} aria-hidden={!(wb.canRestore || wb.restoring)}>
|
||||
Something is on or was changed in this game. If it starts misbehaving, put it all back in one go.
|
||||
</p>
|
||||
<Button variant="outline" size="sm" disabled={wb.restoring || wb.busy !== null} onClick={() => void wb.restoreEverything()}>
|
||||
{wb.restoring ? <Spinner className="size-4" /> : <RotateCcwIcon />} Restore everything
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</span>
|
||||
<span className={'col-start-1 row-start-1' + (wb.canRestore || wb.restoring ? ' invisible' : '')} aria-hidden={wb.canRestore || wb.restoring}>
|
||||
Nothing is on or changed in this game yet. If a cheat makes it misbehave, put it all back in one go.
|
||||
</span>
|
||||
</p>
|
||||
<Button variant="outline" size="sm" disabled={!wb.canRestore || wb.restoring || wb.busy !== null} onClick={() => void wb.restoreEverything()}>
|
||||
{wb.restoring ? <Spinner className="size-4" /> : <RotateCcwIcon />} Restore everything
|
||||
</Button>
|
||||
</div>
|
||||
{wb.restoreReport && <RestoreSummary report={wb.restoreReport} onClose={wb.clearRestoreReport} />}
|
||||
<KeptSummary engine={wb.engine} game={game} kept={keptEntries(wb.table).length} />
|
||||
<OwnCheats />
|
||||
{catalogError && <Notice kind="error">Could not look up cheats: {catalogError}</Notice>}
|
||||
{catalog && catalog.failed.length > 0 && (
|
||||
<Notice kind="warn">
|
||||
Could not reach {catalog.failed.map((f) => f.source).join(' or ')}. This device needs internet access to download
|
||||
cheat tables; ones you have used before still work.
|
||||
<div className="flex flex-col items-start gap-2">
|
||||
<span>
|
||||
{catalog.failed.length === ONLINE_SOURCES.length
|
||||
? 'The online cheat collections could not be reached.'
|
||||
: `Could not reach ${orList(catalog.failed.map((f) => f.source))}.`}{' '}
|
||||
The phone or computer showing this page downloads the tables, so it needs to be online (the PS5 does not). Tables
|
||||
you have used before still work.
|
||||
</span>
|
||||
<Button variant="outline" size="sm" disabled={lookingAgain} onClick={() => void lookAgain()}>
|
||||
{lookingAgain ? <Spinner className="size-4" /> : <RotateCcwIcon />} {lookingAgain ? 'Looking…' : 'Try again'}
|
||||
</Button>
|
||||
</div>
|
||||
</Notice>
|
||||
)}
|
||||
{catalog && catalog.stale.length > 0 && catalog.failed.length === 0 && (
|
||||
@@ -347,10 +411,16 @@ export function CheatsTab(props: { session: Session; game: Game; localCheats: bo
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center gap-2 py-6 text-center">
|
||||
<Ghost size={56} className="text-muted-foreground" />
|
||||
<h2 className="text-lg font-semibold">No ready-made cheats for this game</h2>
|
||||
<h2 className="text-lg font-semibold">
|
||||
{catalog.failed.length > 0 ? 'No ready-made cheats to show yet' : 'No ready-made cheats for this game'}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Nothing for {game.titleId} in the online collections. You can import a table file below, or make your own starting
|
||||
from <strong className="text-foreground">Find</strong>.
|
||||
{catalog.failed.length === ONLINE_SOURCES.length
|
||||
? `The online collections could not be checked for ${game.titleId}.`
|
||||
: catalog.failed.length > 0
|
||||
? `Nothing for ${game.titleId} in the collections that answered.`
|
||||
: `Nothing for ${game.titleId} in the online collections.`}{' '}
|
||||
You can import a table file below, or make your own starting from <strong className="text-foreground">Find</strong>.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -365,7 +435,8 @@ export function CheatsTab(props: { session: Session; game: Game; localCheats: bo
|
||||
value={selected ? entryKey(selected) : ''}
|
||||
onValueChange={(key) => setSelected(entries.find((x) => entryKey(x) === key) ?? null)}
|
||||
>
|
||||
<SelectTrigger id="table" className="w-full">
|
||||
{/* Wraps on a narrow screen: "matches your game" at the end is the part that matters most. */}
|
||||
<SelectTrigger id="table" className="h-auto min-h-9 w-full py-1.5 text-left leading-snug whitespace-normal *:data-[slot=select-value]:whitespace-normal">
|
||||
<SelectValue placeholder="Choose a table…" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -398,7 +469,16 @@ export function CheatsTab(props: { session: Session; game: Game; localCheats: bo
|
||||
<Spinner className="size-4" /> Loading table…
|
||||
</p>
|
||||
)}
|
||||
{loadError && <Notice kind="error">{loadError}</Notice>}
|
||||
{loadError && (
|
||||
<Notice kind="error">
|
||||
<div className="flex flex-col items-start gap-2">
|
||||
<span>{loadError}</span>
|
||||
<Button variant="outline" size="sm" onClick={() => setLoadAttempt((n) => n + 1)}>
|
||||
Try again
|
||||
</Button>
|
||||
</div>
|
||||
</Notice>
|
||||
)}
|
||||
|
||||
{loaded && (
|
||||
<>
|
||||
@@ -462,7 +542,10 @@ export function CheatsTab(props: { session: Session; game: Game; localCheats: bo
|
||||
maxFiles={1}
|
||||
className="p-5"
|
||||
onDrop={(files) => files[0] && void onImport(files[0])}
|
||||
onError={(e) => setImportError(e.message)}
|
||||
onError={() => {
|
||||
setImportNote(null);
|
||||
setImportError('That is not a cheat table file. Choose one file ending in .cspector, .json, .shn or .mc4.');
|
||||
}}
|
||||
>
|
||||
<DropzoneEmptyState>
|
||||
<div className="flex flex-col items-center gap-1 text-center">
|
||||
|
||||
+46
-14
@@ -40,7 +40,7 @@ import { hotkeyClashes } from '../lib/hotkeys.ts';
|
||||
import type { ScriptCommand, ScriptHandle } from '../lib/script.ts';
|
||||
import { groupNames } from '../lib/table.ts';
|
||||
import type { CodeSite, CodeVariable, Entry, InjectEntry, SharedEntry } from '../lib/table.ts';
|
||||
import { HotkeyField, LocationFields, checkLocation } from './EntryEditor.tsx';
|
||||
import { HotkeyField, LocationFields, checkLocation, useCloseGuard } from './EntryEditor.tsx';
|
||||
import { Notice, errorText } from './bits.tsx';
|
||||
import { useWorkbench } from './workbench.tsx';
|
||||
|
||||
@@ -86,7 +86,13 @@ function CheckResult(props: { check: SourceCheck | null; checking: boolean }) {
|
||||
const good = check.status === 'ok';
|
||||
return (
|
||||
<div className="flex flex-col gap-2" role="status">
|
||||
<p className="text-sm">
|
||||
{/* What is below is about the place as it was a moment ago; say so until the new answer is in. */}
|
||||
{props.checking && (
|
||||
<p className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Spinner className="size-4" /> Looking at the game again…
|
||||
</p>
|
||||
)}
|
||||
<p className={cn('text-sm', props.checking && 'opacity-50')}>
|
||||
<span className={cn('font-semibold', good ? 'text-primary' : check.status === 'hooked' ? 'text-foreground' : 'text-destructive')}>
|
||||
{good && <CheckIcon className="mr-1 inline size-4 align-[-3px]" />}
|
||||
{STATUS_WORDS[check.status]}.
|
||||
@@ -123,8 +129,9 @@ function useSourceCheck(site: Pick<CodeSite, 'at' | 'off' | 'fallback'>): { chec
|
||||
try {
|
||||
checkLocation(site.at);
|
||||
} catch {
|
||||
// Still being typed.
|
||||
// Still being typed. A check that was under way for what was there before is dropped, so nothing is being looked at any more.
|
||||
setCheck(null);
|
||||
setChecking(false);
|
||||
return;
|
||||
}
|
||||
setChecking(true);
|
||||
@@ -208,6 +215,9 @@ function VariablesEditor(props: { value: CodeVariable[]; onChange: (list: CodeVa
|
||||
|
||||
const OBJECT_FIELD = 'field';
|
||||
|
||||
/** For a list whose choices are whole phrases: on a narrow screen the chosen one wraps instead of being cut short. */
|
||||
const WRAPPING = 'h-auto min-h-9 w-full py-1.5 text-left leading-snug whitespace-normal *:data-[slot=select-value]:whitespace-normal';
|
||||
|
||||
function operandId(target: RuleTarget, op: RuleOperand): string {
|
||||
const known = target.operands.find((c) => JSON.stringify(c.operand) === JSON.stringify(op));
|
||||
if (known) return known.id;
|
||||
@@ -262,7 +272,7 @@ function ActionFields(props: { which: 'then' | 'otherwise'; action: RuleAction;
|
||||
return (
|
||||
<>
|
||||
<Select value={action.do} onValueChange={pick}>
|
||||
<SelectTrigger className="w-full" aria-label={`${label}: what happens`}>
|
||||
<SelectTrigger className={WRAPPING} aria-label={`${label}: what happens`}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -336,7 +346,7 @@ function RuleBuilder(props: { rule: CodeRule; target: RuleTarget; onChange: (rul
|
||||
|
||||
<Step word="If">
|
||||
<Select value={test ? 'if' : 'always'} onValueChange={(v) => set(v === 'always' ? { test: undefined, otherwise: undefined } : { test: startTest(), otherwise: { do: 'original' } })}>
|
||||
<SelectTrigger className="w-full" aria-label="If: always, or only in some cases">
|
||||
<SelectTrigger className={WRAPPING} aria-label="If: always, or only in some cases">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -356,7 +366,7 @@ function RuleBuilder(props: { rule: CodeRule; target: RuleTarget; onChange: (rul
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full" aria-label="If: what to look at">
|
||||
<SelectTrigger className={WRAPPING} aria-label="If: what to look at">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -468,7 +478,9 @@ function TryButton(props: { entry: CodeEntry }) {
|
||||
const [state, setState] = useState<{ ok: boolean; text: string } | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const key = JSON.stringify(props.entry);
|
||||
useEffect(() => setState(null), [key]);
|
||||
useEffect(() => {
|
||||
setState(null);
|
||||
}, [key]);
|
||||
const run = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
@@ -585,11 +597,12 @@ export function CodeCheatEditor(props: { entry: CodeEntry; isNew: boolean }) {
|
||||
return props.entry.type === 'shared' ? <SharedDialog entry={props.entry} isNew={props.isNew} /> : <CheatDialog entry={props.entry} isNew={props.isNew} />;
|
||||
}
|
||||
|
||||
function Shell(props: { title: string; help: string; error: string | null; saving: boolean; onSave: () => void; children: ReactNode }) {
|
||||
function Shell(props: { title: string; help: string; error: string | null; saving: boolean; dirty: boolean; onSave: () => void; children: ReactNode }) {
|
||||
const wb = useWorkbench();
|
||||
const guard = useCloseGuard(props.dirty, wb.closeEditor);
|
||||
return (
|
||||
<Dialog open onOpenChange={(open) => !open && wb.closeEditor()}>
|
||||
<DialogContent className="max-h-[92dvh] grid-cols-[minmax(0,1fr)] overflow-y-auto sm:max-w-2xl">
|
||||
<Dialog open onOpenChange={guard.onOpenChange}>
|
||||
<DialogContent className="sm:max-w-2xl" {...guard.contentProps}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{props.title}</DialogTitle>
|
||||
<DialogDescription>{props.help}</DialogDescription>
|
||||
@@ -597,6 +610,7 @@ function Shell(props: { title: string; help: string; error: string | null; savin
|
||||
<div className="flex min-w-0 flex-col gap-5">
|
||||
{props.children}
|
||||
{props.error && <Notice kind="error">{props.error}</Notice>}
|
||||
{guard.warned && <Notice kind="warn">You have changes that are not saved. Press Escape again, or Cancel, to close without saving them.</Notice>}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={wb.closeEditor}>
|
||||
@@ -639,7 +653,7 @@ function Filing(props: { draft: CodeEntry; set: (patch: Partial<CodeEntry>) => v
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="code-group">Group (optional)</Label>
|
||||
<Input id="code-group" list="code-groups" placeholder={groups.length ? 'Pick one of your groups, or type a new name' : 'e.g. Weapons'} maxLength={60} value={draft.group ?? ''} onChange={(e) => set({ group: e.target.value || undefined })} />
|
||||
<Input id="code-group" list="code-groups" placeholder={groups.length ? 'Pick a group, or type a new one' : 'e.g. Weapons'} maxLength={60} value={draft.group ?? ''} onChange={(e) => set({ group: e.target.value || undefined })} />
|
||||
<datalist id="code-groups">
|
||||
{groups.map((g) => (
|
||||
<option key={g} value={g} />
|
||||
@@ -670,6 +684,10 @@ function CheatDialog(props: { entry: InjectEntry; isNew: boolean }) {
|
||||
const [mode, setMode] = useState<'rule' | 'code'>(props.entry.rule || (!props.entry.code.trim() && !legacy) ? 'rule' : 'code');
|
||||
const [showCode, setShowCode] = useState(false);
|
||||
const set = (patch: Partial<InjectEntry>) => setDraft((d) => ({ ...d, ...patch }));
|
||||
// What Save complained about is out of date as soon as anything is changed; left up, it would look like it still applies.
|
||||
useEffect(() => {
|
||||
setError(null);
|
||||
}, [draft, mode]);
|
||||
const { check, checking, again } = useSourceCheck(draft);
|
||||
|
||||
const target = useMemo(() => (check?.replaced?.[0] ? ruleTarget(check.replaced[0]) : null), [check]);
|
||||
@@ -697,7 +715,10 @@ function CheatDialog(props: { entry: InjectEntry; isNew: boolean }) {
|
||||
setError(null);
|
||||
let entry: InjectEntry;
|
||||
try {
|
||||
if (!draft.name.trim()) throw new Error('Give it a name');
|
||||
if (!draft.name.trim()) {
|
||||
document.getElementById('code-name')?.focus();
|
||||
throw new Error('Give it a name');
|
||||
}
|
||||
if (mode === 'rule') {
|
||||
if (!rule) throw new Error('The game has to be looked at before a rule can be made. Fill in where the game\'s line is, and wait for the check.');
|
||||
if (built?.problem) throw new Error(built.problem);
|
||||
@@ -746,6 +767,7 @@ function CheatDialog(props: { entry: InjectEntry; isNew: boolean }) {
|
||||
help="Sends one line of the game through new code that can decide what happens: who gets hurt, how much is spent, what number is stored."
|
||||
error={error}
|
||||
saving={saving}
|
||||
dirty={draft !== props.entry}
|
||||
onSave={() => void save()}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
@@ -915,12 +937,18 @@ function SharedDialog(props: { entry: SharedEntry; isNew: boolean }) {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const set = (patch: Partial<SharedEntry>) => setDraft((d) => ({ ...d, ...patch }));
|
||||
useEffect(() => {
|
||||
setError(null);
|
||||
}, [draft]);
|
||||
|
||||
const save = async () => {
|
||||
setError(null);
|
||||
let entry: SharedEntry;
|
||||
try {
|
||||
if (!draft.name.trim()) throw new Error('Give it a name');
|
||||
if (!draft.name.trim()) {
|
||||
document.getElementById('code-name')?.focus();
|
||||
throw new Error('Give it a name');
|
||||
}
|
||||
if (!draft.symbol) throw new Error('Give it a name in code; that is what other cheats call.');
|
||||
if (nameProblem(draft.symbol)) throw new Error(nameProblem(draft.symbol)!);
|
||||
if (!draft.code.trim()) throw new Error('The code is empty');
|
||||
@@ -947,6 +975,7 @@ function SharedDialog(props: { entry: SharedEntry; isNew: boolean }) {
|
||||
help="Code and variables that several code cheats use. It changes nothing in the game by itself; it is put into the game when the first cheat that uses it is switched on."
|
||||
error={error}
|
||||
saving={saving}
|
||||
dirty={draft !== props.entry}
|
||||
onSave={() => void save()}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
@@ -1057,7 +1086,10 @@ function useScriptHandle(id: string): { handle: ScriptHandle | null; commands: S
|
||||
const wb = useWorkbench();
|
||||
const handle = wb.runner.scriptHandle(id);
|
||||
const [, setTick] = useState(0);
|
||||
useEffect(() => handle?.subscribe(() => setTick((n) => n + 1)), [handle]);
|
||||
useEffect(() => {
|
||||
if (!handle) return;
|
||||
return handle.subscribe(() => setTick((n) => n + 1));
|
||||
}, [handle]);
|
||||
return { handle, commands: handle?.commands() ?? [], hotkeys: handle?.hotkeys() ?? [] };
|
||||
}
|
||||
|
||||
|
||||
+65
-15
@@ -13,13 +13,14 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
|
||||
import { Spinner } from '@/components/kibo-ui/spinner';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { bytesToHex, fmtAddr } from '../lib/bytes.ts';
|
||||
import { findPattern, imageRange, parseAddress } from '../lib/locations.ts';
|
||||
import { findPattern, imageRange, parseAddress, readableAt } from '../lib/locations.ts';
|
||||
import { codeCheatEntry, nopEntry } from '../lib/make.ts';
|
||||
import { Tracer } from '../lib/tracer.ts';
|
||||
import type { TraceHit } from '../lib/tracer.ts';
|
||||
import { signatureFromCode } from '../lib/x86.ts';
|
||||
import type { Insn } from '../lib/x86.ts';
|
||||
import { Notice, errorText } from './bits.tsx';
|
||||
import { copyText } from './copy.ts';
|
||||
import { useDebug } from './debughooks.tsx';
|
||||
import { useWorkbench } from './workbench.tsx';
|
||||
|
||||
@@ -49,6 +50,8 @@ function TracePanel() {
|
||||
const [note, setNote] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busyRow, setBusyRow] = useState<bigint | null>(null);
|
||||
/** A watch has been started and has ended, so the list (even an empty one) is an answer and not a blank form. */
|
||||
const [finished, setFinished] = useState(false);
|
||||
const tracer = useRef<Tracer | null>(null);
|
||||
/** Gives the debugger attach back; set while a trace holds it. */
|
||||
const release = useRef<(() => void) | null>(null);
|
||||
@@ -65,13 +68,24 @@ function TracePanel() {
|
||||
setError(null);
|
||||
setNote(null);
|
||||
setHits([]);
|
||||
setFinished(false);
|
||||
setStarting(true);
|
||||
try {
|
||||
await stop();
|
||||
// A watch on nothing never fires, and would sit there looking busy.
|
||||
if ((await readableAt(ctx, address, bytes, true)) === 'none') throw new Error(`The game has no memory at ${fmtAddr(address)}, so nothing can change it. Check the address.`);
|
||||
// Only one thing can hold the debugger. If pausing and stepping (or
|
||||
// the guide) has it, the user is asked whether to stop that first.
|
||||
await withAttach(async () => {
|
||||
const mine = gate.claim({ id: 'trace', label: 'What touches a value', stop });
|
||||
const mine = gate.claim({
|
||||
id: 'trace',
|
||||
label: 'What touches a value',
|
||||
// Something else asked for the debugger and the person agreed; say so here, or the list just stops growing.
|
||||
stop: async () => {
|
||||
await stop();
|
||||
setNote('Stopped, because another tool needed the debugger. What was found is still listed here.');
|
||||
},
|
||||
});
|
||||
const done = () => {
|
||||
mine();
|
||||
if (release.current === mine) release.current = null;
|
||||
@@ -84,6 +98,7 @@ function TracePanel() {
|
||||
if (ended !== undefined || !t.running) {
|
||||
done();
|
||||
setRunning(false);
|
||||
setFinished(true);
|
||||
if (ended) setNote(ended);
|
||||
}
|
||||
});
|
||||
@@ -114,8 +129,17 @@ function TracePanel() {
|
||||
void start(req.address, req.size, req.kind);
|
||||
}, [wb.trace, start]);
|
||||
|
||||
// Never leave the game attached when this goes away.
|
||||
useEffect(() => () => void stop(), [stop]);
|
||||
// Never leave the game attached when this goes away. Closing or reloading
|
||||
// the page does not unmount anything, so that gets the same goodbye: a
|
||||
// watchpoint left armed in a game nobody is debugging kills it on the next hit.
|
||||
useEffect(() => {
|
||||
const bye = () => void stop();
|
||||
window.addEventListener('pagehide', bye);
|
||||
return () => {
|
||||
window.removeEventListener('pagehide', bye);
|
||||
void stop();
|
||||
};
|
||||
}, [stop]);
|
||||
|
||||
const submit = async () => {
|
||||
try {
|
||||
@@ -196,6 +220,17 @@ function TracePanel() {
|
||||
)}
|
||||
{error && <Notice kind="error">{error}</Notice>}
|
||||
{note && <Notice>{note}</Notice>}
|
||||
{finished && !running && !starting && !error && hits.length === 0 && (
|
||||
<Notice kind="warn">
|
||||
Nothing {kind === 'write' ? 'changed' : 'touched'} the value while it was watched. Watch again and make it change in the game (get hit,
|
||||
fire, spend). If it changed and nothing showed up, the address is probably a copy of the value and not the value itself.
|
||||
</Notice>
|
||||
)}
|
||||
{finished && !running && !starting && hits.length > 0 && (
|
||||
<p className="text-sm text-muted-foreground" aria-live="polite">
|
||||
Stopped. {hits.length === 1 ? 'One instruction' : `${hits.length} instructions`} touched the value.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{hits.length > 0 && (
|
||||
<ul className="divide-y" aria-label="Instructions that touched the value">
|
||||
@@ -249,11 +284,14 @@ function Listing() {
|
||||
const [note, setNote] = useState<string | null>(null);
|
||||
const [focus, setFocus] = useState<bigint | null>(null);
|
||||
const [reload, setReload] = useState(0);
|
||||
/** Whether the game has memory where the listing is; reads of nothing come back as zeros, which decode as real-looking instructions. */
|
||||
const [mapped, setMapped] = useState<'all' | 'part' | 'none'>('all');
|
||||
const bps = desk.breakpoints();
|
||||
const bpKey = bps.map((b) => `${b.address.toString(16)}${b.enabled ? '+' : '-'}`).join(',');
|
||||
const stoppedAt = desk.stop?.rip ?? null;
|
||||
|
||||
const go = useCallback((address: bigint) => {
|
||||
const go = useCallback((to: bigint) => {
|
||||
const address = to < 0n ? 0n : to;
|
||||
setStart(address);
|
||||
setFocus(address);
|
||||
setText(fmtAddr(address));
|
||||
@@ -289,7 +327,10 @@ function Listing() {
|
||||
const list = await ctx.x86.disassemble(bytes, start, 40);
|
||||
// The last instruction may be cut off by the end of the window.
|
||||
if (list.length > 1 && list[list.length - 1].invalid) list.pop();
|
||||
let there = await readableAt(ctx, start, WINDOW);
|
||||
if (there !== 'all') there = await readableAt(ctx, start, WINDOW, true);
|
||||
if (canceled) return;
|
||||
setMapped(there);
|
||||
setImage(range);
|
||||
setInsns(list);
|
||||
setError(null);
|
||||
@@ -324,10 +365,14 @@ function Listing() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const match = await findPattern(ctx, pattern, 'code');
|
||||
if (!match) setNote('That pattern is not in the game\'s code.');
|
||||
if (!match) setError('No match: that pattern is not anywhere in the game\'s code. Check the bytes, or put ?? where a byte may differ between game versions.');
|
||||
else {
|
||||
go(match.address);
|
||||
if (!match.unique) setNote('That pattern matches in more than one place; this is the first. Add more bytes to make it unique.');
|
||||
setNote(
|
||||
match.unique
|
||||
? `Found at ${label(match.address)}, the top line below. It is the only place this pattern matches.`
|
||||
: `Found at ${label(match.address)}, the top line below, but the pattern matches in more than one place; this is the first. Add more bytes to make it unique.`,
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
setError(errorText(e));
|
||||
@@ -382,14 +427,12 @@ function Listing() {
|
||||
const copySignature = (index: number) =>
|
||||
act(async () => {
|
||||
const sig = signatureFromCode(insns!.slice(index), 12).text;
|
||||
// Copy first: browsers only allow it right after the click, and the search below can take a while.
|
||||
const copied = await copyText(sig);
|
||||
const match = await findPattern(ctx, sig, 'code');
|
||||
setPattern(sig);
|
||||
try {
|
||||
await navigator.clipboard.writeText(sig);
|
||||
setNote(`Copied: ${sig}${match && !match.unique ? ' (matches in more than one place; it needs more bytes)' : ''}`);
|
||||
} catch {
|
||||
setNote(`Signature: ${sig}`);
|
||||
}
|
||||
const caveat = match && !match.unique ? ' It matches in more than one place, so it needs more bytes to be of use.' : '';
|
||||
setNote(copied ? `Copied: ${sig}.${caveat}` : `This browser would not copy it. The signature is in the "Find a byte pattern" box above: ${sig}.${caveat}`);
|
||||
});
|
||||
|
||||
const last = insns && insns.length ? insns[insns.length - 1] : null;
|
||||
@@ -434,6 +477,12 @@ function Listing() {
|
||||
|
||||
{error && <Notice kind="error">{error}</Notice>}
|
||||
{note && <Notice>{note}</Notice>}
|
||||
{insns && mapped !== 'all' && (
|
||||
<Notice kind="warn">
|
||||
{mapped === 'none' ? 'The game has no memory at this address.' : 'The game has memory at only part of this page.'} Nothing there reads as
|
||||
zeros, which show up below as made-up instructions (usually "add [rax], al").
|
||||
</Notice>
|
||||
)}
|
||||
|
||||
{insns && (
|
||||
<>
|
||||
@@ -462,7 +511,8 @@ function Listing() {
|
||||
{!insn.invalid && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon-xs" aria-label={`Actions for ${insn.text}`}>
|
||||
{/* Bigger on a phone, where it is the only way into a line. */}
|
||||
<Button variant="ghost" size="icon-xs" className="max-sm:size-8" aria-label={`Actions for ${insn.text}`}>
|
||||
<MoreHorizontalIcon />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
@@ -489,7 +539,7 @@ function Listing() {
|
||||
</table>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" disabled={start === null} onClick={() => start !== null && go(start - 0x80n)}>
|
||||
<Button variant="outline" size="sm" disabled={start === null || start === 0n} onClick={() => start !== null && go(start - 0x80n)}>
|
||||
<ChevronLeftIcon /> Earlier
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" disabled={!last} onClick={() => last && go(last.address + BigInt(last.length))}>
|
||||
|
||||
+52
-16
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { LibraryIcon, TriangleAlertIcon } from "lucide-react";
|
||||
import { LibraryIcon, TriangleAlertIcon, UnplugIcon } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
@@ -28,7 +28,7 @@ import type { TitleMeta } from "../lib/library.ts";
|
||||
import { formatFirmware } from "../lib/session.ts";
|
||||
import { loadJson, removeKey, saveJson } from "../lib/storage.ts";
|
||||
import type { Game, Session } from "../lib/session.ts";
|
||||
import { Ghost, Notice, Wordmark, errorText } from "./bits.tsx";
|
||||
import { Ghost, Notice, Safe, Wordmark, errorText } from "./bits.tsx";
|
||||
import { CheatsTab } from "./CheatsTab.tsx";
|
||||
import { ScannerTab } from "./ScannerTab.tsx";
|
||||
import { MemoryTab } from "./MemoryTab.tsx";
|
||||
@@ -157,7 +157,9 @@ export function ConsoleView(props: {
|
||||
};
|
||||
}, [session, titleId, game?.pid]);
|
||||
// The library has done its job once the game it started is on screen.
|
||||
useEffect(() => setLibraryOpen(false), [titleId]);
|
||||
useEffect(() => {
|
||||
setLibraryOpen(false);
|
||||
}, [titleId]);
|
||||
|
||||
const displayName = meta?.name || catalogName;
|
||||
|
||||
@@ -167,7 +169,8 @@ export function ConsoleView(props: {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<header className="flex flex-wrap items-center gap-x-3 gap-y-2 py-1">
|
||||
{/* One line at every width: on a phone the buttons tighten up and "Disconnect" becomes an icon. */}
|
||||
<header className="flex items-center gap-x-1 py-1 sm:gap-x-3">
|
||||
<Wordmark />
|
||||
<Status
|
||||
status="online"
|
||||
@@ -184,19 +187,36 @@ export function ConsoleView(props: {
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="shrink-0"
|
||||
onClick={() => setLibraryOpen(true)}
|
||||
>
|
||||
<LibraryIcon /> Games
|
||||
</Button>
|
||||
)}
|
||||
{props.onLearn && (
|
||||
<Button variant="ghost" size="sm" onClick={props.onLearn}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="shrink-0 max-sm:px-2"
|
||||
onClick={props.onLearn}
|
||||
>
|
||||
Learn
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="ghost" size="sm" onClick={props.onDisconnect}>
|
||||
Disconnect
|
||||
</Button>
|
||||
{/* Served by the PS5 itself there is no other console to go to, so there is nothing to disconnect for. */}
|
||||
{!props.onConsole && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="shrink-0 max-sm:px-2"
|
||||
aria-label="Disconnect"
|
||||
title="Disconnect"
|
||||
onClick={props.onDisconnect}
|
||||
>
|
||||
<UnplugIcon className="sm:hidden" />
|
||||
<span className="max-sm:hidden">Disconnect</span>
|
||||
</Button>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{error && <Notice kind="error">{error}</Notice>}
|
||||
@@ -314,7 +334,8 @@ export function ConsoleView(props: {
|
||||
</WorkbenchProvider>
|
||||
|
||||
<Dialog open={libraryOpen} onOpenChange={setLibraryOpen}>
|
||||
<DialogContent className="max-h-[90dvh] grid-cols-[minmax(0,1fr)] overflow-y-auto sm:max-w-xl">
|
||||
{/* Pinned to the top instead of centered: the list changes length while searching, and a centered dialog would move the search box about under the finger. */}
|
||||
<DialogContent className="top-4 translate-y-0 sm:max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Your games</DialogTitle>
|
||||
<DialogDescription>
|
||||
@@ -457,50 +478,65 @@ function GameTabs(props: {
|
||||
forceMount
|
||||
className="data-[state=inactive]:hidden"
|
||||
>
|
||||
<CheatsTab
|
||||
<Safe what="the Cheats tab">
|
||||
<CheatsTab
|
||||
session={wb.session}
|
||||
game={wb.game}
|
||||
localCheats={props.localCheats}
|
||||
onGameName={props.onGameName}
|
||||
/>
|
||||
</Safe>
|
||||
</TabsContent>
|
||||
<TabsContent
|
||||
value="find"
|
||||
forceMount
|
||||
className="data-[state=inactive]:hidden"
|
||||
>
|
||||
<ScannerTab session={wb.session} game={wb.game} />
|
||||
<Safe what="the Find tab">
|
||||
<ScannerTab session={wb.session} game={wb.game} />
|
||||
</Safe>
|
||||
</TabsContent>
|
||||
<TabsContent
|
||||
value="memory"
|
||||
forceMount
|
||||
className="data-[state=inactive]:hidden"
|
||||
>
|
||||
<MemoryTab />
|
||||
<Safe what="the Memory tab">
|
||||
<MemoryTab />
|
||||
</Safe>
|
||||
</TabsContent>
|
||||
<TabsContent
|
||||
value="code"
|
||||
forceMount
|
||||
className="data-[state=inactive]:hidden"
|
||||
>
|
||||
<CodeTab />
|
||||
<Safe what="the Code tab">
|
||||
<CodeTab />
|
||||
</Safe>
|
||||
</TabsContent>
|
||||
<TabsContent
|
||||
value="debug"
|
||||
forceMount
|
||||
className="data-[state=inactive]:hidden"
|
||||
>
|
||||
<DebugTab />
|
||||
<Safe what="the Debug tab">
|
||||
<DebugTab />
|
||||
</Safe>
|
||||
</TabsContent>
|
||||
<TabsContent
|
||||
value="engine"
|
||||
forceMount
|
||||
className="data-[state=inactive]:hidden"
|
||||
>
|
||||
<EngineTab />
|
||||
<Safe what="the Engine tab">
|
||||
<EngineTab />
|
||||
</Safe>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
<EntryEditor />
|
||||
{/* A fault in an editor closes that editor; the cheat being edited is untouched. */}
|
||||
<Safe key={wb.editing?.entry.id ?? "none"} what="the cheat editor">
|
||||
<EntryEditor />
|
||||
</Safe>
|
||||
</>
|
||||
);
|
||||
}
|
||||
+31
-7
@@ -106,17 +106,27 @@ function PausedBanner(props: { onDebugTab: boolean }) {
|
||||
const [failed, setFailed] = useState<string | null>(null);
|
||||
const bar = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
// Push the page down by the bar's height so it covers nothing.
|
||||
// Push the page down by the bar's height so it covers nothing. Dialogs are
|
||||
// centered in the window and would slide under the bar on a short screen
|
||||
// (a phone on its side), hiding their title and close button, so they are
|
||||
// told to center themselves in what is left below it.
|
||||
useEffect(() => {
|
||||
const el = bar.current;
|
||||
if (!el) return;
|
||||
const before = document.body.style.paddingTop;
|
||||
const fit = () => (document.body.style.paddingTop = `${el.offsetHeight}px`);
|
||||
const rule = document.createElement('style');
|
||||
document.head.appendChild(rule);
|
||||
const fit = () => {
|
||||
const h = el.offsetHeight;
|
||||
document.body.style.paddingTop = `${h}px`;
|
||||
rule.textContent = `[data-slot="dialog-content"]{top:calc(50% + ${h / 2}px) !important;max-height:calc(100dvh - 2rem - ${h}px) !important}`;
|
||||
};
|
||||
fit();
|
||||
const ro = new ResizeObserver(fit);
|
||||
ro.observe(el);
|
||||
return () => {
|
||||
ro.disconnect();
|
||||
rule.remove();
|
||||
document.body.style.paddingTop = before;
|
||||
};
|
||||
}, []);
|
||||
@@ -134,7 +144,14 @@ function PausedBanner(props: { onDebugTab: boolean }) {
|
||||
};
|
||||
|
||||
return createPortal(
|
||||
<div ref={bar} role="alert" className="fixed inset-x-0 top-0 z-[70] border-b border-black/40 bg-primary text-primary-foreground shadow-lg">
|
||||
// pointer-events-auto: an open dialog switches clicks off for everything outside it, and Resume must never be out of reach.
|
||||
// The press is kept from the dialog, which would take it for a click outside itself and close, throwing away what was typed in it.
|
||||
<div
|
||||
ref={bar}
|
||||
role="alert"
|
||||
className="pointer-events-auto fixed inset-x-0 top-0 z-[70] border-b border-black/40 bg-primary text-primary-foreground shadow-lg"
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="mx-auto flex max-w-3xl flex-wrap items-center gap-x-3 gap-y-1.5 px-4 py-2">
|
||||
<PauseIcon className="size-5 shrink-0" aria-hidden="true" />
|
||||
<p className="min-w-0 flex-1 text-sm leading-snug font-semibold">
|
||||
@@ -199,7 +216,10 @@ function ControlSection() {
|
||||
|
||||
const add = () =>
|
||||
run('add', async () => {
|
||||
await desk.addBreakpoint(await parseAddress(ctx, text));
|
||||
const address = await parseAddress(ctx, text);
|
||||
// The library hands back the one already there, which on screen looks like the button did nothing.
|
||||
if (desk.breakpoints().some((b) => b.address === address)) throw new Error('There is already a breakpoint on that instruction. It is in the list below.');
|
||||
await desk.addBreakpoint(address);
|
||||
setText('');
|
||||
});
|
||||
|
||||
@@ -384,7 +404,9 @@ function StoppedView(props: { stop: DebugStop; run: Run; busy: string | null })
|
||||
const shown = useRef<{ lwpid: number; regs: Registers } | null>(null);
|
||||
|
||||
// A new stop always shows the thread that stopped.
|
||||
useEffect(() => setPicked(null), [stop.thread.lwpid, stop.rip, stop.cause.kind]);
|
||||
useEffect(() => {
|
||||
setPicked(null);
|
||||
}, [stop.thread.lwpid, stop.rip, stop.cause.kind]);
|
||||
const lwpid = picked ?? stop.thread.lwpid;
|
||||
const own = lwpid === stop.thread.lwpid;
|
||||
|
||||
@@ -505,7 +527,7 @@ function StoppedView(props: { stop: DebugStop; run: Run; busy: string | null })
|
||||
{!insn.invalid && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon-xs" aria-label={`Actions for ${insn.text}`}>
|
||||
<Button variant="ghost" size="icon-xs" className="max-sm:size-8" aria-label={`Actions for ${insn.text}`}>
|
||||
<MoreHorizontalIcon />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
@@ -684,7 +706,9 @@ function VectorGrid(props: { vectors: FpRegisters; lwpid: number }) {
|
||||
const [editing, setEditing] = useState<{ index: number; kind: 'float' | 'double' } | null>(null);
|
||||
const [draft, setDraft] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
useEffect(() => setLocal(props.vectors), [props.vectors]);
|
||||
useEffect(() => {
|
||||
setLocal(props.vectors);
|
||||
}, [props.vectors]);
|
||||
|
||||
const commit = async () => {
|
||||
if (!editing || !desk.session) return;
|
||||
|
||||
+11
-6
@@ -23,9 +23,9 @@ import { valueKind } from '../lib/scanner.ts';
|
||||
import type { ValueChoice } from '../lib/table.ts';
|
||||
import type { ConsoleVariable } from '../lib/unreal-cvars.ts';
|
||||
import { Notice, PelletTrack, errorText } from './bits.tsx';
|
||||
import { copyAndSay } from './copy.ts';
|
||||
import { useWorkbench } from './workbench.tsx';
|
||||
|
||||
const copy = (text: string) => void navigator.clipboard?.writeText(text).catch(() => undefined);
|
||||
|
||||
// ---- hidden settings ---------------------------------------------------------------
|
||||
|
||||
@@ -58,6 +58,8 @@ export function HiddenSettings(props: { helper: SettingsHelper; switcher: ReactN
|
||||
const [shortcuts, setShortcuts] = useState<ConsoleVariable[] | null>(null);
|
||||
const [found, setFound] = useState<ConsoleVariable[] | null>(null);
|
||||
const [added, setAdded] = useState<string | null>(null);
|
||||
/** Settings added so far, by name, so each row can say so itself: the notice is at the top and the row may be far down a long list. */
|
||||
const [addedNames, setAddedNames] = useState<Set<string>>(new Set());
|
||||
const [cheats, setCheats] = useState<Awaited<ReturnType<NonNullable<WithCheatManager['cheatManager']>>> | null>(null);
|
||||
const [checking, setChecking] = useState(false);
|
||||
|
||||
@@ -122,6 +124,7 @@ export function HiddenSettings(props: { helper: SettingsHelper; switcher: ReactN
|
||||
if (choices) entry.choices = choices;
|
||||
await wb.addEntry(entry, { enable: false });
|
||||
}
|
||||
setAddedNames((prev) => new Set(prev).add(v.name));
|
||||
setAdded(
|
||||
places.length > 1
|
||||
? `Added "${title}" to your cheats as ${places.length} entries, because the engine keeps the value in ${places.length} places. Switch them on together.`
|
||||
@@ -162,7 +165,7 @@ export function HiddenSettings(props: { helper: SettingsHelper; switcher: ReactN
|
||||
</div>
|
||||
{usable ? (
|
||||
<Button variant="outline" size="sm" className="shrink-0" onClick={() => void addAsCheat(v, shortcut)}>
|
||||
Add as a cheat
|
||||
{addedNames.has(v.name) ? 'Added. Add again' : 'Add as a cheat'}
|
||||
</Button>
|
||||
) : null}
|
||||
<DropdownMenu>
|
||||
@@ -174,7 +177,7 @@ export function HiddenSettings(props: { helper: SettingsHelper; switcher: ReactN
|
||||
<DropdownMenuContent align="end">
|
||||
{v.address !== null && <DropdownMenuItem onSelect={() => wb.showMemory(v.address!)}>Show the value in Memory</DropdownMenuItem>}
|
||||
<DropdownMenuItem onSelect={() => wb.showMemory(v.object)}>Show its object in Memory</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => copy(v.name)}>Copy name</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => void copyAndSay(v.name, 'name')}>Copy name</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</li>
|
||||
@@ -276,7 +279,9 @@ export function HiddenSettings(props: { helper: SettingsHelper; switcher: ReactN
|
||||
<Card>
|
||||
<CardContent>
|
||||
{found.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No setting has "{query}" in its name.</p>
|
||||
<p className="text-sm text-muted-foreground" role="status">
|
||||
{query.trim() ? `No setting has "${query.trim()}" in its name. Try a shorter piece, such as fps, shadow or blur.` : 'This game\'s engine lists no settings.'}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
{found.length >= 100 && <p className="pb-2 text-sm text-muted-foreground">Showing the first 100. Type more of the name for a shorter list.</p>}
|
||||
@@ -463,8 +468,8 @@ export function HiddenSwitches(props: { switcher: ReactNode }) {
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onSelect={() => wb.showMemory(h.address)}>Show in Memory</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => copy(h.text)}>Copy text</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => copy(fmtAddr(h.address))}>Copy address</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => void copyAndSay(h.text, 'text')}>Copy text</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => void copyAndSay(fmtAddr(h.address), 'address')}>Copy address</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
+63
-28
@@ -21,6 +21,7 @@ import { ENGINE_TITLES, hasSettings, helperNotes } from '../lib/engines.ts';
|
||||
import { valueEntry } from '../lib/make.ts';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Notice, PelletTrack, errorText } from './bits.tsx';
|
||||
import { copyAndSay } from './copy.ts';
|
||||
import { useDebug } from './debughooks.tsx';
|
||||
import { HiddenSettings, HiddenSwitches } from './EngineHidden.tsx';
|
||||
import { useWorkbench } from './workbench.tsx';
|
||||
@@ -38,7 +39,6 @@ function reportId(report: object | null): number {
|
||||
return id;
|
||||
}
|
||||
|
||||
const copy = (text: string) => void navigator.clipboard?.writeText(text).catch(() => undefined);
|
||||
const hex = (n: number) => '0x' + n.toString(16).toUpperCase();
|
||||
|
||||
export function EngineTab() {
|
||||
@@ -130,13 +130,17 @@ function Summary() {
|
||||
}
|
||||
|
||||
const engine = helper ? helper.engine : (report?.guess.engine ?? 'unknown');
|
||||
// Something went wrong on the way, which is not the same as a game that names nothing.
|
||||
const failed = !helper && report?.failed === true;
|
||||
const notes = helperNotes(helper);
|
||||
const evidence = report?.guess.evidence ?? [];
|
||||
const steps = report?.steps ?? [];
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{engine === 'unknown' ? 'This game\'s engine was not recognized' : `Built with ${ENGINE_TITLES[engine]}`}</CardTitle>
|
||||
<CardTitle>
|
||||
{failed ? 'Looking for this game\'s engine did not finish' : engine === 'unknown' ? 'This game\'s engine was not recognized' : `Built with ${ENGINE_TITLES[engine]}`}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{helper ? (
|
||||
<>
|
||||
@@ -144,6 +148,8 @@ function Summary() {
|
||||
can read them. So instead of a bare address you get{' '}
|
||||
{helper.engine === 'cpp' ? 'the kind of object that lives there.' : '"a Weapon, and this number is its fire rate".'}
|
||||
</>
|
||||
) : failed ? (
|
||||
'Reading the game was interrupted part of the way (the details are under "What was tried"). That says nothing about the game yet. Check that it is still running, then look again.'
|
||||
) : engine === 'unknown' ? (
|
||||
'Nothing that names classes or fields was found in this game. That is common, and nothing is wrong with the game or your console.'
|
||||
) : (
|
||||
@@ -160,7 +166,7 @@ function Summary() {
|
||||
)}
|
||||
{evidence.length > 0 && (
|
||||
<div className="text-sm">
|
||||
<p className="font-medium">How we can tell</p>
|
||||
<p className="font-medium">{failed ? 'What happened' : 'How we can tell'}</p>
|
||||
<ul className="mt-1 list-disc space-y-1 pl-5 text-muted-foreground">
|
||||
{evidence.map((line) => (
|
||||
<li key={line}>{line}</li>
|
||||
@@ -195,7 +201,7 @@ function Summary() {
|
||||
</ul>
|
||||
</details>
|
||||
<div>
|
||||
<Button variant="outline" size="sm" onClick={() => void wb.detectEngine(true)}>
|
||||
<Button variant={failed ? 'default' : 'outline'} size="sm" onClick={() => void wb.detectEngine(true)}>
|
||||
Look again
|
||||
</Button>
|
||||
</div>
|
||||
@@ -264,6 +270,20 @@ function Classes(props: { helper: EngineHelper; switcher: ReactNode }) {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [found, setFound] = useState<ClassInfo[] | null>(null);
|
||||
const [open, setOpen] = useState<{ info: ClassInfo; object?: ObjectInfo } | null>(null);
|
||||
const top = useRef<HTMLDivElement | null>(null);
|
||||
/** How far down the list the person was when they opened a class, to put them back there. */
|
||||
const cameFrom = useRef<number | null>(null);
|
||||
|
||||
// A class picked from far down a long list would otherwise open in the middle of its fields, title out of sight.
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
const el = top.current;
|
||||
if (el && el.getBoundingClientRect().top < 0) el.scrollIntoView();
|
||||
} else if (cameFrom.current !== null) {
|
||||
window.scrollTo(0, cameFrom.current);
|
||||
cameFrom.current = null;
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const search = async () => {
|
||||
setBusy(true);
|
||||
@@ -294,19 +314,22 @@ function Classes(props: { helper: EngineHelper; switcher: ReactNode }) {
|
||||
|
||||
if (open) {
|
||||
return (
|
||||
<ClassDetail
|
||||
key={`${open.info.fullName}:${open.object?.address ?? ''}`}
|
||||
helper={helper}
|
||||
info={open.info}
|
||||
object={open.object}
|
||||
busy={busy}
|
||||
error={error}
|
||||
onBack={() => {
|
||||
setOpen(null);
|
||||
setError(null);
|
||||
}}
|
||||
onOpen={openClass}
|
||||
/>
|
||||
<>
|
||||
<div ref={top} className="-mb-4 scroll-mt-20" />
|
||||
<ClassDetail
|
||||
key={`${open.info.fullName}:${open.object?.address ?? ''}`}
|
||||
helper={helper}
|
||||
info={open.info}
|
||||
object={open.object}
|
||||
busy={busy}
|
||||
error={error}
|
||||
onBack={() => {
|
||||
setOpen(null);
|
||||
setError(null);
|
||||
}}
|
||||
onOpen={openClass}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -357,7 +380,10 @@ function Classes(props: { helper: EngineHelper; switcher: ReactNode }) {
|
||||
type="button"
|
||||
className="flex w-full flex-col gap-0.5 rounded-sm py-2 text-left hover:bg-accent/40 focus-visible:outline-2 focus-visible:outline-ring"
|
||||
disabled={busy}
|
||||
onClick={() => void openClass(c.address ?? c.fullName)}
|
||||
onClick={() => {
|
||||
cameFrom.current = window.scrollY;
|
||||
void openClass(c.address ?? c.fullName);
|
||||
}}
|
||||
>
|
||||
<span className="font-medium break-all">{c.name}</span>
|
||||
<span className="text-sm break-all text-muted-foreground">
|
||||
@@ -433,13 +459,18 @@ function ClassDetail(props: {
|
||||
|
||||
const useAsCheat = async (f: FieldInfo, v: FieldValue) => {
|
||||
if (!picked || !v.kind) return;
|
||||
const address = picked.address + BigInt(f.offset);
|
||||
const entry = await valueEntry(wb.ctx, address, v.kind, v.input ?? '0');
|
||||
entry.name = `${picked.name ?? info.name}: ${f.name}`;
|
||||
if (entry.at.kind === 'address') {
|
||||
entry.note = `Found in the Engine tab (${info.name}.${f.name}). This address works until the game unloads the object or restarts; "Find what points to this" in Find can make it last.`;
|
||||
setError(null);
|
||||
try {
|
||||
const address = picked.address + BigInt(f.offset);
|
||||
const entry = await valueEntry(wb.ctx, address, v.kind, v.input ?? '0');
|
||||
entry.name = `${picked.name ?? info.name}: ${f.name}`;
|
||||
if (entry.at.kind === 'address') {
|
||||
entry.note = `Found in the Engine tab (${info.name}.${f.name}). This address works until the game unloads the object or restarts; "Make it survive restarts" in Find can make it last.`;
|
||||
}
|
||||
wb.edit(entry, true);
|
||||
} catch (e) {
|
||||
setError(errorText(e));
|
||||
}
|
||||
wb.edit(entry, true);
|
||||
};
|
||||
|
||||
const follow = async (p: bigint) => {
|
||||
@@ -522,7 +553,7 @@ function ClassDetail(props: {
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onSelect={() => wb.showMemory(o.address)}>Show in Memory</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => debug.showObjects([o.address])}>Look at it in Debug</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => copy(fmtAddr(o.address))}>Copy address</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => void copyAndSay(fmtAddr(o.address), 'address')}>Copy address</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</li>
|
||||
@@ -600,8 +631,8 @@ function ClassDetail(props: {
|
||||
{value?.kind && <DropdownMenuItem onSelect={() => void useAsCheat(f, value)}>Use this as a cheat</DropdownMenuItem>}
|
||||
{value?.pointer !== undefined && <DropdownMenuItem onSelect={() => void follow(value.pointer!)}>See what it points to</DropdownMenuItem>}
|
||||
{address !== null && <DropdownMenuItem onSelect={() => wb.showMemory(address)}>Show in Memory</DropdownMenuItem>}
|
||||
{address !== null && <DropdownMenuItem onSelect={() => copy(fmtAddr(address))}>Copy address</DropdownMenuItem>}
|
||||
<DropdownMenuItem onSelect={() => copy(hex(f.offset))}>Copy offset</DropdownMenuItem>
|
||||
{address !== null && <DropdownMenuItem onSelect={() => void copyAndSay(fmtAddr(address), 'address')}>Copy address</DropdownMenuItem>}
|
||||
<DropdownMenuItem onSelect={() => void copyAndSay(hex(f.offset), 'offset')}>Copy offset</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</li>
|
||||
@@ -672,7 +703,11 @@ function Functions(props: { helper: EngineHelper; switcher: ReactNode }) {
|
||||
tries={['Damage', 'Money', 'Ammo', 'Cheat', 'God']}
|
||||
/>
|
||||
{error && <Notice kind="error">{error}</Notice>}
|
||||
{found && !busy && found.length === 0 && <Notice kind="warn">No function has "{query}" in its name. Try a shorter piece or a different word.</Notice>}
|
||||
{found && !busy && found.length === 0 && (
|
||||
<Notice kind="warn">
|
||||
{query.trim() ? `No function has "${query.trim()}" in its name. Try a shorter piece or a different word.` : 'This game\'s engine lists no functions by name.'}
|
||||
</Notice>
|
||||
)}
|
||||
{found && !busy && found.length >= LIST_LIMIT && <Notice>Showing the first {LIST_LIMIT}. Type more of the name for a shorter list.</Notice>}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
+124
-22
@@ -58,6 +58,17 @@ function checkHex(text: string, what: string): void {
|
||||
if (!/^(0x)?[0-9a-fA-F]+$/.test(text.trim())) throw new Error(`${what} must be a hex number`);
|
||||
}
|
||||
|
||||
/**
|
||||
* The bytes a hex field holds, or an error in plain words. Stricter than
|
||||
* hexToBytes, which skips over anything that is not a hex digit: here a typo
|
||||
* ("9O" for "90") is refused instead of being quietly turned into other bytes.
|
||||
*/
|
||||
export function fieldBytes(text: string, what: string): Uint8Array {
|
||||
const clean = text.replace(/0x/gi, '').replace(/[\s,]+/g, '');
|
||||
if (!/^([0-9a-fA-F]{2})*$/.test(clean)) throw new Error(`${what} must be pairs of hex digits (0-9, A-F), like 90 90 90`);
|
||||
return hexToBytes(clean);
|
||||
}
|
||||
|
||||
export function checkLocation(loc: Location): void {
|
||||
switch (loc.kind) {
|
||||
case 'signature':
|
||||
@@ -78,6 +89,54 @@ export function checkLocation(loc: Location): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A box for a whole number that may be negative. It keeps what is typed as
|
||||
* text, so "-" on the way to "-4" is not wiped out by being read as a number.
|
||||
*/
|
||||
function WholeNumberInput(props: { label: string; value: number; onChange: (n: number) => void }) {
|
||||
const [text, setText] = useState(String(props.value));
|
||||
const shown = parseInt(text, 10) === props.value || (props.value === 0 && !/\d/.test(text)) ? text : String(props.value);
|
||||
return (
|
||||
<Input
|
||||
className="w-24 font-mono"
|
||||
aria-label={props.label}
|
||||
inputMode="text"
|
||||
value={shown}
|
||||
onChange={(e) => {
|
||||
const next = e.target.value.replace(/[^0-9-]/g, '');
|
||||
setText(next);
|
||||
props.onChange(parseInt(next, 10) || 0);
|
||||
}}
|
||||
onBlur={() => setText(String(props.value))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A box for a list typed with commas or spaces between the items. It keeps
|
||||
* the text as typed while it is being typed; turning it into a list and back
|
||||
* on every key would swallow the comma or space the moment it is pressed.
|
||||
*/
|
||||
function ListInput(props: { id?: string; label?: string; placeholder: string; value: string[]; onChange: (items: string[]) => void }) {
|
||||
const split = (t: string) => t.split(/[\s,]+/).filter(Boolean);
|
||||
const [text, setText] = useState(props.value.join(', '));
|
||||
const shown = split(text).join('\n') === props.value.join('\n') ? text : props.value.join(', ');
|
||||
return (
|
||||
<Input
|
||||
id={props.id}
|
||||
className="font-mono"
|
||||
aria-label={props.label}
|
||||
placeholder={props.placeholder}
|
||||
value={shown}
|
||||
onChange={(e) => {
|
||||
setText(e.target.value);
|
||||
props.onChange(split(e.target.value));
|
||||
}}
|
||||
onBlur={() => setText(props.value.join(', '))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function LocationFields(props: { label: string; value: Location; onChange: (loc: Location) => void; kinds?: Location['kind'][] }) {
|
||||
const loc = props.value;
|
||||
const kinds = LOCATION_KINDS.filter((k) => !props.kinds || props.kinds.includes(k.id));
|
||||
@@ -101,7 +160,7 @@ export function LocationFields(props: { label: string; value: Location; onChange
|
||||
<Input className="font-mono" aria-label="Byte pattern" placeholder="89 81 ?? ?? 00 00" value={loc.pattern} onChange={(e) => props.onChange({ ...loc, pattern: e.target.value })} />
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<span className="shrink-0">Bytes past the match</span>
|
||||
<Input className="w-24 font-mono" aria-label="Bytes past the match" inputMode="numeric" value={String(loc.offset)} onChange={(e) => props.onChange({ ...loc, offset: parseInt(e.target.value, 10) || 0 })} />
|
||||
<WholeNumberInput label="Bytes past the match" value={loc.offset} onChange={(offset) => props.onChange({ ...loc, offset })} />
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">Use ?? for bytes that can be anything. Keeps working after most game updates.</p>
|
||||
</>
|
||||
@@ -117,13 +176,7 @@ export function LocationFields(props: { label: string; value: Location; onChange
|
||||
<>
|
||||
<ModuleChoice loc={loc} onChange={props.onChange} />
|
||||
<Input className="font-mono" aria-label="Base offset" placeholder="Base offset, e.g. 2F4A10" value={loc.base} onChange={(e) => props.onChange({ ...loc, base: e.target.value })} />
|
||||
<Input
|
||||
className="font-mono"
|
||||
aria-label="Pointer offsets"
|
||||
placeholder="Offsets, e.g. 0x8, 0x340"
|
||||
value={loc.offsets.join(', ')}
|
||||
onChange={(e) => props.onChange({ ...loc, offsets: e.target.value.split(/[\s,]+/).filter(Boolean) })}
|
||||
/>
|
||||
<ListInput label="Pointer offsets" placeholder="Offsets, e.g. 0x8, 0x340" value={loc.offsets} onChange={(offsets) => props.onChange({ ...loc, offsets })} />
|
||||
<p className="text-xs text-muted-foreground">Reads the pointer stored at game+base, then follows each offset. For values that move every launch.</p>
|
||||
</>
|
||||
)}
|
||||
@@ -183,7 +236,7 @@ export function HotkeyField(props: { value: string | undefined; onChange: (hotke
|
||||
readOnly
|
||||
// Phones would open their keyboard for nothing; a hotkey needs a real one.
|
||||
inputMode="none"
|
||||
placeholder="Click here, then press the keys"
|
||||
placeholder="Click, then press the keys"
|
||||
value={value ?? ''}
|
||||
onKeyDown={(e) => {
|
||||
// Tab still moves on, and Escape still closes the dialog, when pressed alone.
|
||||
@@ -254,6 +307,29 @@ function ChoicesEditor(props: { value: ValueChoice[] | undefined; onChange: (cho
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps an editor from being closed by accident with work in it. A click or
|
||||
* tap outside never closes it, and Escape with unsaved changes asks once
|
||||
* first: the second Escape (or Cancel) closes.
|
||||
*/
|
||||
export function useCloseGuard(dirty: boolean, close: () => void) {
|
||||
const [warned, setWarned] = useState(false);
|
||||
return {
|
||||
warned: warned && dirty,
|
||||
contentProps: {
|
||||
onInteractOutside: (e: Event) => e.preventDefault(),
|
||||
onEscapeKeyDown: (e: KeyboardEvent) => {
|
||||
if (!dirty || warned) return;
|
||||
e.preventDefault();
|
||||
setWarned(true);
|
||||
},
|
||||
},
|
||||
onOpenChange: (open: boolean) => {
|
||||
if (!open) close();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function EntryEditor() {
|
||||
const wb = useWorkbench();
|
||||
const editing = wb.editing;
|
||||
@@ -270,19 +346,34 @@ function EditorDialog(props: { entry: Entry; isNew: boolean }) {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const info = TITLES[draft.type];
|
||||
const set = (patch: Partial<Entry>) => setDraft((d) => ({ ...d, ...patch }) as Entry);
|
||||
// Changing anything clears what Save last complained about; left up, it would look like it still applies.
|
||||
const set = (patch: Partial<Entry>) => {
|
||||
setError(null);
|
||||
setDraft((d) => ({ ...d, ...patch }) as Entry);
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
setError(null);
|
||||
try {
|
||||
if (!draft.name.trim()) throw new Error('Give it a name');
|
||||
if (!draft.name.trim()) {
|
||||
// The name box is at the top, possibly scrolled out of sight; go to it.
|
||||
document.getElementById('entry-name')?.focus();
|
||||
throw new Error('Give it a name');
|
||||
}
|
||||
switch (draft.type) {
|
||||
case 'patch':
|
||||
patchSites(draft).forEach((site, i) => {
|
||||
const which = i === 0 ? '' : ` for place ${i + 1}`;
|
||||
checkLocation(site.at);
|
||||
if (hexToBytes(site.on).length === 0) throw new Error(`Enter the bytes to write${which}`);
|
||||
if (site.off && hexToBytes(site.off).length === 0) throw new Error(`The original bytes${which} are not valid hex`);
|
||||
const on = fieldBytes(site.on, `The bytes when on${which}`);
|
||||
if (on.length === 0) throw new Error(`Enter the bytes to write${which}`);
|
||||
if (site.off) {
|
||||
const off = fieldBytes(site.off, `The original bytes${which}`);
|
||||
// Switching off writes the original bytes back over the new ones, so a different count would leave the game's code half restored.
|
||||
if (off.length !== on.length) {
|
||||
throw new Error(`The original bytes${which} must be as many as the bytes when on: ${on.length} there, ${off.length} here`);
|
||||
}
|
||||
}
|
||||
});
|
||||
break;
|
||||
case 'value': {
|
||||
@@ -348,13 +439,15 @@ function EditorDialog(props: { entry: Entry; isNew: boolean }) {
|
||||
}
|
||||
};
|
||||
|
||||
const guard = useCloseGuard(draft !== props.entry, wb.closeEditor);
|
||||
const groups = groupNames(wb.table);
|
||||
const keepWhyNot = keepProblem({ ...draft, unreviewed: undefined } as Entry);
|
||||
const keepable = draft.type === 'patch' || draft.type === 'value';
|
||||
// Not offered on a lesson's practice game: there is no PS5 behind it to keep anything on.
|
||||
const keepable = (draft.type === 'patch' || draft.type === 'value') && wb.session.target?.label !== 'Practice game';
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(open) => !open && wb.closeEditor()}>
|
||||
<DialogContent className="max-h-[90dvh] overflow-y-auto sm:max-w-xl">
|
||||
<Dialog open onOpenChange={guard.onOpenChange}>
|
||||
<DialogContent className="sm:max-w-xl" {...guard.contentProps}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{props.isNew ? 'New' : 'Edit'}: {info.title}
|
||||
@@ -362,7 +455,15 @@ function EditorDialog(props: { entry: Entry; isNew: boolean }) {
|
||||
<DialogDescription>{info.help}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* A form, so Enter in a one-line box saves, as people expect. */}
|
||||
<form
|
||||
id="entry-form"
|
||||
className="flex flex-col gap-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (!saving) void save();
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="entry-name">Name</Label>
|
||||
<Input id="entry-name" value={draft.name} placeholder="Infinite ammo" onChange={(e) => set({ name: e.target.value })} />
|
||||
@@ -465,7 +566,7 @@ function EditorDialog(props: { entry: Entry; isNew: boolean }) {
|
||||
<LocationFields label="Function" value={draft.fn} onChange={(fn) => set({ fn })} kinds={['signature', 'module', 'address']} />
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="entry-args">Arguments</Label>
|
||||
<Input id="entry-args" className="font-mono" placeholder="0x1F, 1" value={draft.args.join(', ')} onChange={(e) => set({ args: e.target.value.split(/[\s,]+/).filter(Boolean) })} />
|
||||
<ListInput id="entry-args" placeholder="0x1F, 1" value={draft.args} onChange={(args) => set({ args })} />
|
||||
<p className="text-xs text-muted-foreground">Up to six whole numbers, decimal or 0x hex, in the order the function takes them.</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
The function has to return within about two seconds. If it returns -1 the console takes that as a failure and
|
||||
@@ -492,7 +593,7 @@ function EditorDialog(props: { entry: Entry; isNew: boolean }) {
|
||||
<Input
|
||||
id="entry-group"
|
||||
list="entry-groups"
|
||||
placeholder={groups.length ? 'Pick one of your groups, or type a new name' : 'e.g. Weapons'}
|
||||
placeholder={groups.length ? 'Pick a group, or type a new one' : 'e.g. Weapons'}
|
||||
maxLength={60}
|
||||
value={draft.group ?? ''}
|
||||
onChange={(e) => set({ group: e.target.value || undefined })}
|
||||
@@ -541,13 +642,14 @@ function EditorDialog(props: { entry: Entry; isNew: boolean }) {
|
||||
</div>
|
||||
|
||||
{error && <Notice kind="error">{error}</Notice>}
|
||||
</div>
|
||||
{guard.warned && <Notice kind="warn">You have changes that are not saved. Press Escape again, or Cancel, to close without saving them.</Notice>}
|
||||
</form>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={wb.closeEditor}>
|
||||
<Button type="button" variant="outline" onClick={wb.closeEditor}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button disabled={saving} onClick={() => void save()}>
|
||||
<Button type="submit" form="entry-form" disabled={saving}>
|
||||
Save
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
||||
+208
-110
@@ -1,7 +1,7 @@
|
||||
// Find tab, "Group" mode: several values that sit close together in memory.
|
||||
// The searching itself is in lib/groupscan.ts.
|
||||
|
||||
import { useRef, useState } from 'react';
|
||||
import { useEffect, useId, useRef, useState } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { MoreHorizontalIcon, PlusIcon, XIcon } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -10,13 +10,16 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigge
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { fmtAddr, fmtBytes } from '../lib/bytes.ts';
|
||||
import { GROUP_MAX_DISTANCE, GROUP_MAX_MEMBERS, groupSearch } from '../lib/groupscan.ts';
|
||||
import { fmtAddr } from '../lib/bytes.ts';
|
||||
import { coverageWords, failedFeedback, groupFeedback } from '../lib/findfeedback.ts';
|
||||
import type { Feedback } from '../lib/findfeedback.ts';
|
||||
import { GROUP_MAX_DISTANCE, GROUP_MAX_MEMBERS, GroupTooBroadError, checkGroup, groupSearch } from '../lib/groupscan.ts';
|
||||
import type { GroupMember, GroupProgress, GroupResult } from '../lib/groupscan.ts';
|
||||
import { sliceRanges } from '../lib/regions.ts';
|
||||
import { ScanSession, VALUE_KINDS, decodeValue, valueKind } from '../lib/scanner.ts';
|
||||
import type { Scan } from '../lib/scanner.ts';
|
||||
import { Notice, PelletTrack, errorText } from './bits.tsx';
|
||||
import { Notice, errorText } from './bits.tsx';
|
||||
import { FeedbackNotice, SearchProgress, gameGone, useCopy, useProgress } from './findbits.tsx';
|
||||
import { WhereToSearch } from './WhereToSearch.tsx';
|
||||
import type { ScopeControl } from './WhereToSearch.tsx';
|
||||
import { useWorkbench } from './workbench.tsx';
|
||||
@@ -32,6 +35,8 @@ interface Row {
|
||||
export function GroupSearch(props: { switcher: ReactNode; scope: ScopeControl; onContinue: (scan: Scan, note: string) => void }) {
|
||||
const wb = useWorkbench();
|
||||
const { session, game } = wb;
|
||||
// This tool can be on the page twice (a lesson's practice game in front of the real one), so its ids are per copy.
|
||||
const uid = useId();
|
||||
const nextId = useRef(2);
|
||||
const [rows, setRows] = useState<Row[]>([
|
||||
{ id: 0, kindId: 'u32', text: '' },
|
||||
@@ -39,29 +44,61 @@ export function GroupSearch(props: { switcher: ReactNode; scope: ScopeControl; o
|
||||
]);
|
||||
const [distance, setDistance] = useState('64');
|
||||
const [running, setRunning] = useState(false);
|
||||
const [scopeText, setScopeText] = useState('');
|
||||
const [progress, setProgress] = useState<GroupProgress | null>(null);
|
||||
const [canceling, setCanceling] = useState(false);
|
||||
const [runInfo, setRunInfo] = useState({ startedAt: 0, what: '' });
|
||||
const [progress, setProgress] = useProgress<GroupProgress>();
|
||||
const [result, setResult] = useState<{ res: GroupResult; members: GroupMember[] } | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
/** Something wrong with what was typed or chosen, shown next to it. */
|
||||
const [formError, setFormError] = useState<{ field: 'values' | 'where'; text: string } | null>(null);
|
||||
/** How the last search ended. Shown where the results are. */
|
||||
const [feedback, setFeedback] = useState<Feedback | null>(null);
|
||||
const [carryError, setCarryError] = useState<string | null>(null);
|
||||
const [carry, setCarry] = useState('0');
|
||||
const stop = useRef<AbortController | null>(null);
|
||||
const busy = useRef(false);
|
||||
const { copy, toast } = useCopy();
|
||||
|
||||
const setRow = (id: number, patch: Partial<Row>) => setRows((prev) => prev.map((r) => (r.id === id ? { ...r, ...patch } : r)));
|
||||
|
||||
// A complaint about "Where to search" goes away as soon as that choice is changed.
|
||||
const scopeChoice = props.scope.choice;
|
||||
useEffect(() => {
|
||||
setFormError((e) => (e?.field === 'where' ? null : e));
|
||||
}, [scopeChoice]);
|
||||
|
||||
const run = async () => {
|
||||
setError(null);
|
||||
if (busy.current) return;
|
||||
setFormError(null);
|
||||
setCarryError(null);
|
||||
const members: GroupMember[] = rows.map((r) => ({ kind: valueKind(r.kindId), text: r.text }));
|
||||
const dist = distance.trim() === '' ? NaN : Number(distance.trim());
|
||||
try {
|
||||
checkGroup(members, dist);
|
||||
} catch (e) {
|
||||
setFormError({ field: 'values', text: errorText(e) });
|
||||
return;
|
||||
}
|
||||
busy.current = true;
|
||||
const startedAt = Date.now();
|
||||
setRunning(true);
|
||||
setCanceling(false);
|
||||
setProgress(null);
|
||||
setScopeText('');
|
||||
// The last search's results go now, so they cannot be mistaken for this one's.
|
||||
setResult(null);
|
||||
setFeedback(null);
|
||||
setRunInfo({ startedAt, what: "Reading the list of the game's memory…" });
|
||||
const controller = new AbortController();
|
||||
stop.current = controller;
|
||||
try {
|
||||
const members: GroupMember[] = rows.map((r) => ({ kind: valueKind(r.kindId), text: r.text }));
|
||||
if (members.some((m) => m.text.trim() === '')) throw new Error('Fill in every value, or remove the empty line');
|
||||
const dist = Number(distance.trim());
|
||||
if (!Number.isInteger(dist) || dist <= 0) throw new Error('The distance is a whole number of bytes, such as 64');
|
||||
const where = await props.scope.resolve();
|
||||
setScopeText(`Searching ${fmtBytes(where.bytes)} of ${fmtBytes(where.total)}`);
|
||||
let where: Awaited<ReturnType<ScopeControl['resolve']>>;
|
||||
try {
|
||||
where = await props.scope.resolve();
|
||||
} catch (e) {
|
||||
setFormError({ field: 'where', text: errorText(e) });
|
||||
return;
|
||||
}
|
||||
const cover = { bytes: where.bytes, total: where.total };
|
||||
setRunInfo({ startedAt, what: `Looking for ${members.map((m) => m.text.trim()).join(', ')} close together in ${coverageWords(cover)}` });
|
||||
const res = await groupSearch({
|
||||
dbg: () => session.scan,
|
||||
pid: game.pid,
|
||||
@@ -73,11 +110,38 @@ export function GroupSearch(props: { switcher: ReactNode; scope: ScopeControl; o
|
||||
});
|
||||
setResult({ res, members });
|
||||
setCarry('0');
|
||||
// A game that has just closed reads as empty memory, which looks exactly like "nothing found".
|
||||
if (res.hits.length === 0 && !res.canceled && (await gameGone(session, game)))
|
||||
setFeedback(failedFeedback(true, 'The game closed while the search was running', null, true));
|
||||
else
|
||||
setFeedback(
|
||||
groupFeedback(
|
||||
members.map((m) => m.text),
|
||||
dist,
|
||||
{
|
||||
groups: res.hits.length,
|
||||
anchorText: members[res.anchor].text.trim(),
|
||||
anchorMatches: res.anchorMatches,
|
||||
capped: res.capped,
|
||||
canceled: res.canceled,
|
||||
bytesSearched: res.bytesSearched,
|
||||
},
|
||||
cover,
|
||||
Date.now() - startedAt,
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
setError(errorText(e));
|
||||
if (e instanceof GroupTooBroadError) {
|
||||
const [title, ...tips] = e.message.split('. ');
|
||||
setFeedback({ tone: 'warn', title: `${title}. Nothing was kept.`, tips: [tips.join('. ')] });
|
||||
} else {
|
||||
setFeedback(failedFeedback(true, errorText(e), null, await gameGone(session, game)));
|
||||
}
|
||||
} finally {
|
||||
stop.current = null;
|
||||
busy.current = false;
|
||||
setRunning(false);
|
||||
setCanceling(false);
|
||||
setProgress(null);
|
||||
}
|
||||
};
|
||||
@@ -95,7 +159,7 @@ export function GroupSearch(props: { switcher: ReactNode; scope: ScopeControl; o
|
||||
);
|
||||
props.onContinue(scan, `These are the ${member.text} values from a group search. Change the value in the game, then narrow down as usual.`);
|
||||
} catch (e) {
|
||||
setError(errorText(e));
|
||||
setCarryError(errorText(e));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -108,8 +172,8 @@ export function GroupSearch(props: { switcher: ReactNode; scope: ScopeControl; o
|
||||
{props.switcher}
|
||||
<CardTitle>Find values that sit together</CardTitle>
|
||||
<CardDescription>
|
||||
A game keeps things about one character or item side by side. Type two or more numbers you can see right now, such as
|
||||
health 100, armor 50 and ammo 30, to find the places where all of them are close together, in any order.
|
||||
A game keeps things about one character or item side by side. Type two or more numbers you can see right now, such as health 100, armor 50 and ammo 30, to
|
||||
find the places where all of them are close together, in any order.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
@@ -130,12 +194,18 @@ export function GroupSearch(props: { switcher: ReactNode; scope: ScopeControl; o
|
||||
</Select>
|
||||
<Input
|
||||
className="min-w-0 flex-1 font-mono"
|
||||
inputMode="decimal"
|
||||
inputMode="text"
|
||||
aria-label={`Value ${i + 1}`}
|
||||
placeholder={i === 0 ? '100' : i === 1 ? '50' : '30'}
|
||||
value={r.text}
|
||||
disabled={running}
|
||||
onChange={(e) => setRow(r.id, { text: e.target.value })}
|
||||
autoCapitalize="off"
|
||||
autoCorrect="off"
|
||||
spellCheck={false}
|
||||
onChange={(e) => {
|
||||
setRow(r.id, { text: e.target.value });
|
||||
if (formError?.field === 'values') setFormError(null);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !running) void run();
|
||||
}}
|
||||
@@ -166,131 +236,159 @@ export function GroupSearch(props: { switcher: ReactNode; scope: ScopeControl; o
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="group-distance">All within this many bytes</Label>
|
||||
<Label htmlFor={`${uid}group-distance`}>All within this many bytes</Label>
|
||||
<div className="flex items-center gap-3">
|
||||
<Input
|
||||
id="group-distance"
|
||||
id={`${uid}group-distance`}
|
||||
className="w-28 font-mono"
|
||||
inputMode="numeric"
|
||||
value={distance}
|
||||
disabled={running}
|
||||
onChange={(e) => setDistance(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setDistance(e.target.value);
|
||||
if (formError?.field === 'values') setFormError(null);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !running) void run();
|
||||
}}
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">64 suits most games. Up to {GROUP_MAX_DISTANCE.toLocaleString()}.</span>
|
||||
<span className="text-sm text-muted-foreground">64 suits most games. Up to {GROUP_MAX_DISTANCE.toLocaleString('en-US')}.</span>
|
||||
</div>
|
||||
</div>
|
||||
{formError?.field === 'values' && (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{formError.text}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<WhereToSearch scope={props.scope} disabled={running} />
|
||||
{formError?.field === 'where' && <Notice kind="error">{formError.text}</Notice>}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button disabled={running} onClick={() => void run()}>
|
||||
{running ? 'Searching…' : 'Search'}
|
||||
</Button>
|
||||
{running && (
|
||||
<Button variant="outline" onClick={() => stop.current?.abort()}>
|
||||
Cancel
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={canceling}
|
||||
onClick={() => {
|
||||
setCanceling(true);
|
||||
stop.current?.abort();
|
||||
}}
|
||||
>
|
||||
{canceling ? 'Canceling…' : 'Cancel'}
|
||||
</Button>
|
||||
)}
|
||||
{result && !running && (
|
||||
<span className="text-sm text-muted-foreground max-sm:basis-full" aria-live="polite">
|
||||
<strong className="text-foreground">{hits.length.toLocaleString()}</strong> {hits.length === 1 ? 'group' : 'groups'}
|
||||
<strong className="text-foreground">{hits.length.toLocaleString('en-US')}</strong> {hits.length === 1 ? 'group' : 'groups'}
|
||||
{hits.length > SHOWN ? `, showing the first ${SHOWN}` : ''}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{running && (
|
||||
<div>
|
||||
<PelletTrack fraction={progress ? progress.fraction : null} label="Group search progress" />
|
||||
<p className="text-sm text-muted-foreground" aria-live="polite">
|
||||
{scopeText || 'Reading the memory map…'}
|
||||
{progress ? ` · ${progress.detail}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <Notice kind="error">{error}</Notice>}
|
||||
{result && !running && result.res.canceled && <Notice kind="warn">Canceled. The list below may be incomplete.</Notice>}
|
||||
{result && !running && result.res.capped && (
|
||||
<Notice kind="warn">
|
||||
Only the first {hits.length.toLocaleString()} groups were kept. Add another value or make the distance smaller to cut
|
||||
the list down.
|
||||
</Notice>
|
||||
)}
|
||||
{result && !running && hits.length === 0 && !result.res.canceled && (
|
||||
<Notice kind="warn">
|
||||
No place holds all of these within {distance} bytes. One of them may be stored as a different type (try Float), or
|
||||
they may be further apart: try a bigger distance.
|
||||
</Notice>
|
||||
)}
|
||||
{result && !running && hits.length > 0 && (
|
||||
<div className="flex flex-wrap items-end gap-2">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="group-carry">Keep narrowing with</Label>
|
||||
<Select value={carry} onValueChange={setCarry}>
|
||||
<SelectTrigger id="group-carry" className="w-56">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{result.members.map((m, i) => (
|
||||
<SelectItem key={i} value={String(i)}>
|
||||
{m.text} ({m.kind.label.toLowerCase()})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button variant="outline" onClick={carryOn}>
|
||||
Continue in Number search
|
||||
</Button>
|
||||
</div>
|
||||
<SearchProgress
|
||||
label="Group search progress"
|
||||
startedAt={runInfo.startedAt}
|
||||
what={runInfo.what}
|
||||
canceling={canceling}
|
||||
progress={
|
||||
progress && {
|
||||
fraction: progress.fraction,
|
||||
done: progress.done,
|
||||
total: progress.total,
|
||||
unit: progress.step === 1 ? 'bytes' : 'places',
|
||||
stage: progress.step === 1 ? `Step 1 of 2: ${progress.detail.toLowerCase()}` : 'Step 2 of 2: checking what is next to each one',
|
||||
stageOnly: true,
|
||||
found:
|
||||
progress.step === 1
|
||||
? `in ${progress.anchors.toLocaleString('en-US')} ${progress.anchors === 1 ? 'place' : 'places'} so far`
|
||||
: `${progress.groups.toLocaleString('en-US')} ${progress.groups === 1 ? 'group' : 'groups'} so far`,
|
||||
}
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{hits.length > 0 && (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<ul className="divide-y">
|
||||
{hits.slice(0, SHOWN).map((g) => (
|
||||
<li key={g.members.map((m) => m.address.toString(16)).join()} className="flex items-start gap-2 py-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-baseline gap-x-2">
|
||||
<span className="font-mono text-xs sm:text-sm">{fmtAddr(g.base)}</span>
|
||||
<span className="text-xs text-muted-foreground">{g.span} bytes from first to last</span>
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap gap-x-4 gap-y-1">
|
||||
{g.members.map((m, i) => (
|
||||
<span key={i} className="font-mono text-sm">
|
||||
{decodeValue(m.kind, m.value)}
|
||||
<span className="ml-1.5 text-xs text-muted-foreground">
|
||||
{m.address === g.base ? 'here' : `+${Number(m.address - g.base)}`} · {m.kind.label.toLowerCase()}
|
||||
{feedback && !running && <FeedbackNotice feedback={feedback} />}
|
||||
|
||||
{result && hits.length > 0 && !running && (
|
||||
<Card aria-label="Results">
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
{carryError && <Notice kind="error">{carryError}</Notice>}
|
||||
{result && hits.length > 0 && (
|
||||
<div className="flex flex-wrap items-end gap-2">
|
||||
<div className="flex min-w-0 flex-col gap-2 max-sm:w-full">
|
||||
<Label htmlFor={`${uid}group-carry`}>Keep narrowing with</Label>
|
||||
<Select value={carry} onValueChange={setCarry}>
|
||||
<SelectTrigger id={`${uid}group-carry`} className="w-full sm:w-56">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{result.members.map((m, i) => (
|
||||
<SelectItem key={i} value={String(i)}>
|
||||
{m.text} ({m.kind.label.toLowerCase()})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button variant="outline" onClick={carryOn}>
|
||||
Continue in Number search
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{hits.length > 0 && (
|
||||
<ul className="divide-y">
|
||||
{hits.slice(0, SHOWN).map((g) => (
|
||||
<li key={g.members.map((m) => m.address.toString(16)).join()} className="flex items-start gap-2 py-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-baseline gap-x-2">
|
||||
<span className="font-mono text-xs sm:text-sm">{fmtAddr(g.base)}</span>
|
||||
<span className="text-xs text-muted-foreground">{g.span} bytes from first to last</span>
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap gap-x-4 gap-y-1">
|
||||
{g.members.map((m, i) => (
|
||||
<span key={i} className="font-mono text-sm">
|
||||
{decodeValue(m.kind, m.value)}
|
||||
<span className="ml-1.5 text-xs text-muted-foreground">
|
||||
{m.address === g.base ? 'here' : `+${Number(m.address - g.base)}`} · {m.kind.label.toLowerCase()}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon-sm" className="shrink-0" aria-label={`More for ${fmtAddr(g.base)}`}>
|
||||
<MoreHorizontalIcon />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onSelect={() => wb.showMemory(g.base)}>Show in Memory</DropdownMenuItem>
|
||||
{g.members.map((m, i) => (
|
||||
<DropdownMenuItem key={i} onSelect={() => void navigator.clipboard?.writeText(fmtAddr(m.address)).catch(() => undefined)}>
|
||||
Copy the address of {decodeValue(m.kind, m.value)} ({fmtAddr(m.address)})
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon-sm" className="shrink-0" aria-label={`More for ${fmtAddr(g.base)}`}>
|
||||
<MoreHorizontalIcon />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onSelect={() => wb.showMemory(g.base)}>Show in Memory</DropdownMenuItem>
|
||||
{g.members.map((m, i) => (
|
||||
<DropdownMenuItem key={i} onSelect={() => copy(fmtAddr(m.address))}>
|
||||
Copy the address of {decodeValue(m.kind, m.value)} ({fmtAddr(m.address)})
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{hits.length > SHOWN && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Showing the first {SHOWN} of {hits.length.toLocaleString('en-US')} groups. Add another value or make the distance smaller to cut the list down.
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
{toast}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+143
-44
@@ -12,7 +12,7 @@ import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Spinner } from '@/components/kibo-ui/spinner';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { LearnError, LessonLibrary, ProgressStore, answerMatches, emptyProgress, parseText } from '../lib/learn.ts';
|
||||
import { LearnError, LessonLibrary, ProgressStore, answerMatches, emptyProgress, goalParts, parseText } from '../lib/learn.ts';
|
||||
import type { Course, Lesson, LessonRef, LoadedIndex, PracticeStep, Progress, Span, Step } from '../lib/learn.ts';
|
||||
import { Ghost, Notice, Wordmark } from './bits.tsx';
|
||||
|
||||
@@ -115,6 +115,8 @@ export interface LearnPageProps {
|
||||
preferLocal: boolean;
|
||||
/** A console is connected behind this page: the way out leads back to it. */
|
||||
connected: boolean;
|
||||
/** The page is served by the PS5 itself, which keeps the learner's progress. A computer's bridge does not, and is not asked. */
|
||||
onConsole: boolean;
|
||||
/** A lesson to open right away, when another screen sent the user here for it. */
|
||||
lesson?: string | null;
|
||||
onClose: () => void;
|
||||
@@ -131,18 +133,22 @@ export default function LearnPage(props: LearnPageProps) {
|
||||
|
||||
useEffect(() => {
|
||||
let canceled = false;
|
||||
void store.load().then((p) => !canceled && setProgress(p));
|
||||
void store.load(props.onConsole).then((p) => !canceled && setProgress(p));
|
||||
return () => {
|
||||
canceled = true;
|
||||
};
|
||||
// Where the page is served from does not change while it is open.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const latest = useRef<Progress | null>(null);
|
||||
latest.current = progress;
|
||||
const change = useCallback((update: (p: Progress) => Progress) => {
|
||||
setProgress((prev) => {
|
||||
const next = { ...update(prev ?? emptyProgress()), at: Date.now() };
|
||||
store.save(next);
|
||||
return next;
|
||||
});
|
||||
const next = { ...update(latest.current ?? emptyProgress()), at: Date.now() };
|
||||
// Kept here as well as in the state, so that two changes in one go build on each other.
|
||||
latest.current = next;
|
||||
setProgress(next);
|
||||
store.save(next);
|
||||
}, []);
|
||||
|
||||
// Nothing is downloaded until the user has said yes.
|
||||
@@ -160,7 +166,10 @@ export default function LearnPage(props: LearnPageProps) {
|
||||
};
|
||||
}, [wanted, library, attempt]);
|
||||
|
||||
useEffect(() => window.scrollTo(0, 0), [open]);
|
||||
useEffect(() => {
|
||||
// A block body on purpose: newer browsers return a promise from scrollTo, and an effect may only return a cleanup function.
|
||||
window.scrollTo(0, 0);
|
||||
}, [open]);
|
||||
|
||||
const leave = props.connected ? 'Back to your PS5' : 'Back';
|
||||
let body: ReactNode;
|
||||
@@ -169,7 +178,7 @@ export default function LearnPage(props: LearnPageProps) {
|
||||
} else if (progress.lessons === null) {
|
||||
body = <OptIn onLoad={() => change((p) => ({ ...p, lessons: 'on' }))} onDecline={() => change((p) => ({ ...p, lessons: 'off' }))} />;
|
||||
} else if (progress.lessons === 'off') {
|
||||
body = <SwitchedOff onLoad={() => change((p) => ({ ...p, lessons: 'on' }))} />;
|
||||
body = <SwitchedOff started={Object.keys(progress.done).length > 0} onLoad={() => change((p) => ({ ...p, lessons: 'on' }))} />;
|
||||
} else if (index.state === 'loading') {
|
||||
body = <Waiting>Downloading the lessons…</Waiting>;
|
||||
} else if (index.state === 'failed') {
|
||||
@@ -208,6 +217,24 @@ export default function LearnPage(props: LearnPageProps) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The heading of a screen that replaced another one. It takes the focus when
|
||||
* it appears, because the button that led here has just gone: someone using a
|
||||
* keyboard or a screen reader carries on from the top of what is new, not
|
||||
* from nowhere.
|
||||
*/
|
||||
function Arrived(props: { className: string; children: ReactNode }) {
|
||||
const ref = useRef<HTMLHeadingElement | null>(null);
|
||||
useEffect(() => {
|
||||
ref.current?.focus({ preventScroll: true });
|
||||
}, []);
|
||||
return (
|
||||
<h2 ref={ref} tabIndex={-1} className={cn('outline-none', props.className)}>
|
||||
{props.children}
|
||||
</h2>
|
||||
);
|
||||
}
|
||||
|
||||
function Waiting(props: { children: ReactNode }) {
|
||||
return (
|
||||
<p className="flex items-center gap-2 py-10 text-sm text-muted-foreground" role="status">
|
||||
@@ -224,7 +251,7 @@ function OptIn(props: { onLoad: () => void; onDecline: () => void }) {
|
||||
<div className="flex items-start gap-5">
|
||||
<Ghost size={56} floaty look className="mt-1 max-sm:hidden" />
|
||||
<div className="flex flex-col gap-3">
|
||||
<h2 className="text-3xl leading-tight font-bold tracking-tight text-balance">Learn to make your own cheats</h2>
|
||||
<Arrived className="text-3xl leading-tight font-bold tracking-tight text-balance">Learn to make your own cheats</Arrived>
|
||||
<p className="max-w-[60ch] text-base leading-relaxed text-foreground/85">
|
||||
Twelve short lessons, from "what is a game's memory?" to cheats that survive a game update. Each comes with a small practice game that
|
||||
runs here in the page, so you can try every step without a real game and without any risk to one.
|
||||
@@ -234,7 +261,7 @@ function OptIn(props: { onLoad: () => void; onDecline: () => void }) {
|
||||
<div className="rounded-lg border bg-card p-5">
|
||||
<h3 className="font-semibold">What loading the lessons does</h3>
|
||||
<ul className="mt-3 ml-5 flex max-w-[62ch] list-disc flex-col gap-2 text-sm leading-relaxed text-foreground/85 marker:text-primary">
|
||||
<li>Lessons are downloaded from CheatSpecter's page on GitHub, so this device needs to be online.</li>
|
||||
<li>Lessons are downloaded from CheatSpecter's page on GitHub, so this device needs an internet connection.</li>
|
||||
<li>They are text and descriptions of practice games. Nothing in them can run, on your PS5 or in this page.</li>
|
||||
<li>Nothing is stored on your PS5 except a note of which lessons you have finished.</li>
|
||||
<li>You can switch them off again from the lesson list.</li>
|
||||
@@ -252,12 +279,13 @@ function OptIn(props: { onLoad: () => void; onDecline: () => void }) {
|
||||
);
|
||||
}
|
||||
|
||||
function SwitchedOff(props: { onLoad: () => void }) {
|
||||
function SwitchedOff(props: { started: boolean; onLoad: () => void }) {
|
||||
return (
|
||||
<section className="flex flex-col items-start gap-4 py-10">
|
||||
<h2 className="text-2xl font-bold tracking-tight">Lessons are switched off</h2>
|
||||
<Arrived className="text-2xl font-bold tracking-tight">Lessons are switched off</Arrived>
|
||||
<p className="max-w-[56ch] text-base leading-relaxed text-muted-foreground">
|
||||
Nothing has been downloaded, and nothing will be until you ask. Loading them fetches the lessons from CheatSpecter's page on GitHub.
|
||||
Nothing is downloaded until you ask. Loading them fetches the lessons from CheatSpecter's page on GitHub.
|
||||
{props.started ? ' What you have already done in them is remembered.' : ''}
|
||||
</p>
|
||||
<Button variant="outline" onClick={props.onLoad}>
|
||||
<DownloadIcon /> Load lessons
|
||||
@@ -266,19 +294,20 @@ function SwitchedOff(props: { onLoad: () => void }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** What went wrong with a download, as a heading: for all the lessons, or for one. */
|
||||
const PROBLEM = {
|
||||
unpublished: '{what} {are} not published yet',
|
||||
offline: '{what} could not be downloaded',
|
||||
newer: '{what} {need} a newer CheatSpecter',
|
||||
invalid: 'The download was not a lesson',
|
||||
} as const;
|
||||
|
||||
function DownloadFailed(props: { error: LearnError; onRetry: () => void; onSwitchOff: () => void }) {
|
||||
const { problem } = props.error;
|
||||
const title =
|
||||
problem === 'unpublished'
|
||||
? 'The lessons are not published yet'
|
||||
: problem === 'offline'
|
||||
? 'The lessons could not be downloaded'
|
||||
: problem === 'newer'
|
||||
? 'These lessons need a newer CheatSpecter'
|
||||
: 'The download was not a lesson';
|
||||
const title = PROBLEM[problem].replace('{what}', 'The lessons').replace('{are}', 'are').replace('{need}', 'need');
|
||||
return (
|
||||
<section className="flex flex-col items-start gap-4 py-8">
|
||||
<h2 className="text-2xl font-bold tracking-tight">{title}</h2>
|
||||
<Arrived className="text-2xl font-bold tracking-tight">{title}</Arrived>
|
||||
<Notice kind={problem === 'unpublished' || problem === 'newer' ? 'info' : 'warn'} className="max-w-[62ch]">
|
||||
{props.error.message}
|
||||
</Notice>
|
||||
@@ -328,7 +357,7 @@ function LessonList(props: { loaded: LoadedIndex; progress: Progress; onOpen: (i
|
||||
return (
|
||||
<div className="flex flex-col gap-8">
|
||||
<div className="flex flex-col gap-2">
|
||||
<h2 className="text-3xl font-bold tracking-tight">Learn</h2>
|
||||
<Arrived className="text-3xl font-bold tracking-tight">Learn</Arrived>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{finished === 0
|
||||
? `${all.length} lessons. Start at the top; each one builds on the last.`
|
||||
@@ -408,6 +437,29 @@ function LessonRow(props: { lesson: LessonRef; number: number; state: 'new' | 's
|
||||
|
||||
// ---- one lesson ---------------------------------------------------------------------------
|
||||
|
||||
/** Goals that leave something behind for a later step: a list of matches, a caught instruction, a cheat, a changed value. */
|
||||
const LASTING = ['narrowed', 'caught', 'off', 'saved', 'value'];
|
||||
|
||||
/**
|
||||
* The practice steps that lead up to this one in the same game, earliest
|
||||
* first, that leave something behind: the steps whose results this one builds
|
||||
* on. Empty when the step starts from a new game anyway, or is the first to
|
||||
* use its game.
|
||||
*/
|
||||
function stepsBefore(lesson: Lesson, index: number): number[] {
|
||||
const out: number[] = [];
|
||||
for (let i = index; i > 0; ) {
|
||||
const here = lesson.steps[i] as PracticeStep;
|
||||
if (here.fresh) break;
|
||||
let before = i - 1;
|
||||
while (before >= 0 && lesson.steps[before].kind !== 'practice') before--;
|
||||
if (before < 0 || (lesson.steps[before] as PracticeStep).game !== here.game) break;
|
||||
if (goalParts((lesson.steps[before] as PracticeStep).goal).some((g) => LASTING.includes(g.check))) out.unshift(before);
|
||||
i = before;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function LessonView(props: {
|
||||
library: LessonLibrary;
|
||||
id: string;
|
||||
@@ -427,10 +479,13 @@ function LessonView(props: {
|
||||
/** Steps completed during this visit; what was done on an earlier one is in the saved progress. */
|
||||
const [done, setDone] = useState<Set<number>>(new Set());
|
||||
const saved = useRef(props.progress.done[props.id]);
|
||||
/** The practice step last shown. Its game stays alive, out of sight, through the readings and questions that follow it. */
|
||||
const [kept, setKept] = useState<{ index: number; step: PracticeStep } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let canceled = false;
|
||||
setError(null);
|
||||
setLesson(null);
|
||||
props.library.loadLesson(props.id).then(
|
||||
(l) => {
|
||||
if (canceled) return;
|
||||
@@ -446,7 +501,17 @@ function LessonView(props: {
|
||||
};
|
||||
}, [props.library, props.id, attempt]);
|
||||
|
||||
useEffect(() => window.scrollTo(0, 0), [at]);
|
||||
const heading = useRef<HTMLHeadingElement | null>(null);
|
||||
useEffect(() => {
|
||||
window.scrollTo(0, 0);
|
||||
// The button that brought the learner here is gone or far below: carry on from the top of the new step.
|
||||
heading.current?.focus({ preventScroll: true });
|
||||
}, [at, lesson]);
|
||||
|
||||
useEffect(() => {
|
||||
const now = lesson?.steps[at];
|
||||
if (now?.kind === 'practice') setKept({ index: at, step: now });
|
||||
}, [lesson, at]);
|
||||
|
||||
const { id, onProgress } = props;
|
||||
const reach = useCallback(
|
||||
@@ -466,12 +531,13 @@ function LessonView(props: {
|
||||
);
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col items-start gap-4">
|
||||
{back}
|
||||
<Notice kind={error.problem === 'offline' ? 'warn' : 'info'}>{error.message}</Notice>
|
||||
<Button className="self-start" onClick={() => setAttempt((n) => n + 1)}>
|
||||
Try again
|
||||
</Button>
|
||||
<Arrived className="text-2xl font-bold tracking-tight">{PROBLEM[error.problem].replace('{what}', `Lesson ${props.number}`).replace('{are}', 'is').replace('{need}', 'needs')}</Arrived>
|
||||
<Notice kind={error.problem === 'unpublished' || error.problem === 'newer' ? 'info' : 'warn'} className="max-w-[62ch]">
|
||||
{error.message}
|
||||
</Notice>
|
||||
<Button onClick={() => setAttempt((n) => n + 1)}>Try again</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -496,9 +562,11 @@ function LessonView(props: {
|
||||
{back}
|
||||
<div className="flex flex-col items-start gap-4 py-6">
|
||||
<Ghost size={64} floaty />
|
||||
<h2 className="text-3xl leading-tight font-bold tracking-tight text-balance">Lesson {props.number} done</h2>
|
||||
<h2 ref={heading} tabIndex={-1} className="text-3xl leading-tight font-bold tracking-tight text-balance outline-none">
|
||||
Lesson {props.number} done
|
||||
</h2>
|
||||
<p className="max-w-[56ch] text-base leading-relaxed text-foreground/85">
|
||||
That was "{lesson.title}". {props.next ? 'The next lesson picks up from here.' : 'That was the last lesson. The rest is practice, on games you enjoy.'}
|
||||
{props.next ? `That was "${lesson.title}". The next lesson picks up from here.` : `"${lesson.title}" was the last lesson. The rest is practice, on games you enjoy.`}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{props.next && (
|
||||
@@ -521,6 +589,10 @@ function LessonView(props: {
|
||||
}
|
||||
|
||||
const step = lesson.steps[at];
|
||||
// A practice step stays on the page while a reading or a question that follows it is showing,
|
||||
// hidden, so the next practice step in the same game finds everything as the learner left it.
|
||||
const practice = step.kind === 'practice' ? { index: at, step } : kept;
|
||||
const needs = practice ? stepsBefore(lesson, practice.index) : [];
|
||||
const stepDone = step.kind === 'read' || step.kind === 'soon' || done.has(at);
|
||||
const kind = step.kind === 'practice' ? (step.challenge ? 'on your own' : 'try it yourself') : step.kind === 'read' ? 'read' : step.kind === 'soon' ? 'coming soon' : 'a question';
|
||||
|
||||
@@ -534,26 +606,42 @@ function LessonView(props: {
|
||||
<Trail total={total} done={at} label="Steps of this lesson" onPick={go} />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-2xl leading-tight font-bold tracking-tight text-balance">{step.title}</h2>
|
||||
<h2 ref={heading} tabIndex={-1} className="text-2xl leading-tight font-bold tracking-tight text-balance outline-none">
|
||||
{step.title}
|
||||
</h2>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Step {at + 1} of {total}, {kind}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{step.kind === 'practice' ? (
|
||||
<Suspense fallback={<Waiting>Starting the practice game…</Waiting>}>
|
||||
{/* Keyed by game, not by step: the next step of the same game carries on where this one stopped. */}
|
||||
<LearnPractice key={step.game} lesson={lesson} step={step as PracticeStep} index={at} done={done.has(at)} onDone={() => complete(at)} onNext={() => go(at + 1)} />
|
||||
</Suspense>
|
||||
) : (
|
||||
{step.kind !== 'practice' && (
|
||||
<>
|
||||
{step.text && <LearnText text={step.text} />}
|
||||
{step.kind === 'choice' && <ChoiceStep key={at} step={step} done={done.has(at)} onDone={() => complete(at)} />}
|
||||
{step.kind === 'answer' && <AnswerStep key={at} step={step} done={done.has(at)} onDone={() => complete(at)} />}
|
||||
</>
|
||||
)}
|
||||
{practice && (
|
||||
<div hidden={step.kind !== 'practice'}>
|
||||
<Suspense fallback={<Waiting>Starting the practice game…</Waiting>}>
|
||||
{/* Keyed by game, not by step: the next step of the same game carries on where this one stopped. */}
|
||||
<LearnPractice
|
||||
key={practice.step.game}
|
||||
lesson={lesson}
|
||||
step={practice.step}
|
||||
index={practice.index}
|
||||
done={done.has(practice.index)}
|
||||
last={practice.index + 1 === total}
|
||||
needs={needs}
|
||||
onDone={() => complete(practice.index)}
|
||||
onNext={() => go(practice.index + 1)}
|
||||
onGoto={go}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<footer className="mt-4 flex items-center gap-2 border-t pt-4">
|
||||
<footer className="mt-4 flex flex-wrap items-center gap-2 border-t pt-4">
|
||||
<Button variant="outline" disabled={at === 0} onClick={() => go(at - 1)}>
|
||||
<ArrowLeftIcon /> Back
|
||||
</Button>
|
||||
@@ -596,14 +684,17 @@ function ChoiceStep(props: { step: Extract<Step, { kind: 'choice' }>; done: bool
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={picked === i}
|
||||
disabled={right}
|
||||
// Not `disabled`: that would drop the focus from the option just chosen.
|
||||
aria-disabled={right}
|
||||
className={cn(
|
||||
'flex items-start gap-3 rounded-lg border border-input bg-input/30 p-4 text-left text-base leading-snug outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50',
|
||||
!right && 'hover:border-primary/60',
|
||||
picked === i && (right ? 'border-primary bg-primary/10' : 'border-destructive/70'),
|
||||
right && 'cursor-default',
|
||||
right && picked !== i && 'opacity-50',
|
||||
)}
|
||||
onClick={() => {
|
||||
if (right) return;
|
||||
setPicked(i);
|
||||
if (i === step.answer) props.onDone();
|
||||
}}
|
||||
@@ -627,6 +718,7 @@ function AnswerStep(props: { step: Extract<Step, { kind: 'answer' }>; done: bool
|
||||
const [typed, setTyped] = useState(props.done ? step.accept[0] : '');
|
||||
const [checked, setChecked] = useState<boolean | null>(props.done ? true : null);
|
||||
const [tries, setTries] = useState(0);
|
||||
const box = useRef<HTMLInputElement | null>(null);
|
||||
const check = () => {
|
||||
const right = answerMatches(step.accept, typed);
|
||||
setChecked(right);
|
||||
@@ -638,7 +730,7 @@ function AnswerStep(props: { step: Extract<Step, { kind: 'answer' }>; done: bool
|
||||
className="flex flex-col gap-3"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (typed.trim()) check();
|
||||
if (typed.trim() && checked !== true) check();
|
||||
}}
|
||||
>
|
||||
<label htmlFor="learn-answer" className="max-w-[68ch] text-lg leading-snug font-semibold">
|
||||
@@ -646,13 +738,14 @@ function AnswerStep(props: { step: Extract<Step, { kind: 'answer' }>; done: bool
|
||||
</label>
|
||||
<div className="flex max-w-md gap-2">
|
||||
<Input
|
||||
ref={box}
|
||||
id="learn-answer"
|
||||
className="h-11 font-mono text-base md:text-base"
|
||||
autoComplete="off"
|
||||
autoCapitalize="off"
|
||||
spellCheck={false}
|
||||
value={typed}
|
||||
disabled={checked === true}
|
||||
readOnly={checked === true}
|
||||
onChange={(e) => {
|
||||
setTyped(e.target.value);
|
||||
setChecked(null);
|
||||
@@ -664,7 +757,13 @@ function AnswerStep(props: { step: Extract<Step, { kind: 'answer' }>; done: bool
|
||||
</div>
|
||||
{checked !== null && <Verdict right={checked} explain={step.explain} />}
|
||||
{checked === false && tries >= 2 && (
|
||||
<button type="button" className="self-start text-sm font-medium text-primary underline-offset-4 hover:underline" onClick={() => (setTyped(step.accept[0]), setChecked(null))}>
|
||||
<button type="button" className="self-start text-sm font-medium text-primary underline-offset-4 hover:underline" onClick={() => {
|
||||
setTyped(step.accept[0]);
|
||||
setChecked(null);
|
||||
// This button goes away; the answer it filled in is where to carry on (Enter checks it).
|
||||
box.current?.focus();
|
||||
}}
|
||||
>
|
||||
Show the answer
|
||||
</button>
|
||||
)}
|
||||
|
||||
+175
-46
@@ -1,6 +1,6 @@
|
||||
// A practice step of a lesson: the goal, the real tools pointed at a practice
|
||||
// game, and the game itself docked at the bottom of the window where a thumb
|
||||
// reaches it.
|
||||
// game, and the game itself: beside the lesson in a wide window, and docked
|
||||
// at the bottom of a narrow one, where a thumb reaches it.
|
||||
//
|
||||
// The tools are the same components the Find, Memory, Code and Cheats tabs
|
||||
// use on a console. They get a Session whose other end is the practice game
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { MutableRefObject } from 'react';
|
||||
import { ArrowRightIcon, CheckIcon, CircleIcon, DownloadIcon, LightbulbIcon, PlayIcon, RotateCcwIcon } from 'lucide-react';
|
||||
import { ArrowLeftIcon, ArrowRightIcon, CheckIcon, CircleIcon, DownloadIcon, LightbulbIcon, PlayIcon, RotateCcwIcon } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Spinner } from '@/components/kibo-ui/spinner';
|
||||
@@ -38,13 +38,6 @@ const NO_TOOLS = { pid: -1 } as GameContext;
|
||||
/** How often the goal is looked at when nothing in the game has said it changed (a cheat renamed, a list narrowed). */
|
||||
const LOOK_MS = 400;
|
||||
|
||||
/** A title ID for a practice game: stable per lesson and game, so "Your cheats" for it has somewhere to live while the lesson is open. */
|
||||
function practiceTitle(lesson: string, game: string): string {
|
||||
let h = 0;
|
||||
for (const ch of `${lesson}/${game}`) h = (Math.imul(h, 31) + ch.charCodeAt(0)) >>> 0;
|
||||
return 'CSPR' + String(h % 100000).padStart(5, '0');
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops everything the tools kept in the browser about a practice game (its
|
||||
* cheats, saved pointer paths, where to search), so practice leaves nothing behind.
|
||||
@@ -69,8 +62,13 @@ export default function LearnPractice(props: {
|
||||
index: number;
|
||||
/** Already completed earlier in this visit. */
|
||||
done: boolean;
|
||||
/** This is the lesson's last step, so what follows is the end of the lesson. */
|
||||
last: boolean;
|
||||
/** The earlier steps this one builds on: what was found or made there is what it works with. Empty when it stands alone. */
|
||||
needs: number[];
|
||||
onDone: () => void;
|
||||
onNext: () => void;
|
||||
onGoto: (index: number) => void;
|
||||
}) {
|
||||
const { lesson, step } = props;
|
||||
const [engine, setEngine] = useState<Engine | null>(null);
|
||||
@@ -83,11 +81,19 @@ export default function LearnPractice(props: {
|
||||
const [link, setLink] = useState<{ session: Session; game: Game } | null>(null);
|
||||
const [tracker, setTracker] = useState<GoalTracker | null>(null);
|
||||
const [reached, setReached] = useState<boolean[]>([]);
|
||||
/** How far along the part being worked on is ("2 of 3"), and anything the learner needs telling about their search. */
|
||||
const [count, setCount] = useState<string | null>(null);
|
||||
const [advice, setAdvice] = useState<string | null>(null);
|
||||
/** The steps whose goal was reached in the game that is running now, to know whether it has what a later step builds on. */
|
||||
const [doneHere, setDoneHere] = useState<number[]>([]);
|
||||
const [lines, setLines] = useState<string[]>([]);
|
||||
const [hints, setHints] = useState(0);
|
||||
const [showing, setShowing] = useState(false);
|
||||
const [problem, setProblem] = useState<string | null>(null);
|
||||
const tools = useRef<Workbench | null>(null);
|
||||
/** The tool tab the learner was on, so a restart of the game does not throw them back to the step's first tab. */
|
||||
const lastTab = useRef<{ step: number; tab: TabId } | null>(null);
|
||||
const trackerRef = useRef<GoalTracker | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let canceled = false;
|
||||
@@ -104,7 +110,7 @@ export default function LearnPractice(props: {
|
||||
const roomKey = `${step.fresh ? props.index : 'shared'}/${resets}`;
|
||||
useEffect(() => {
|
||||
if (!engine) return;
|
||||
const title = practiceTitle(lesson.id, step.game);
|
||||
const title = engine.practiceTitle(lesson.id, step.game);
|
||||
const def = lesson.games[step.game];
|
||||
// A new game starts with no cheats; nothing from an earlier visit is carried in.
|
||||
forget(title);
|
||||
@@ -114,11 +120,14 @@ export default function LearnPractice(props: {
|
||||
if (e.type === 'restart' || e.type === 'update') {
|
||||
setCrashed(null);
|
||||
setLaunch((n) => n + 1);
|
||||
// A list of matches, a caught instruction and a value changed by hand belong to the game that just closed. Cheats stay.
|
||||
setDoneHere((prev) => prev.filter((i) => goalParts((lesson.steps[i] as PracticeStep).goal).some((g) => g.check === 'saved' || g.check === 'off')));
|
||||
} else if (e.type === 'crash') {
|
||||
setCrashed(e.reason);
|
||||
}
|
||||
});
|
||||
setCrashed(null);
|
||||
setDoneHere([]);
|
||||
setRoom({ con, def });
|
||||
return () => {
|
||||
stop();
|
||||
@@ -182,21 +191,34 @@ export default function LearnPractice(props: {
|
||||
useEffect(() => {
|
||||
if (!engine || !bench) return;
|
||||
const t = new engine.GoalTracker(step.goal, bench);
|
||||
const index = props.index;
|
||||
const sync = () => {
|
||||
setReached(t.items.map((i) => i.done));
|
||||
if (t.done) onDone();
|
||||
if (!t.done) return;
|
||||
setDoneHere((prev) => (prev.includes(index) ? prev : [...prev, index]));
|
||||
onDone();
|
||||
};
|
||||
const look = async () => {
|
||||
await t.check();
|
||||
if (trackerRef.current !== t) return;
|
||||
setCount(t.progress);
|
||||
setAdvice(t.advice());
|
||||
};
|
||||
t.onChange = sync;
|
||||
trackerRef.current = t;
|
||||
setTracker(t);
|
||||
setLines([]);
|
||||
setHints(0);
|
||||
setProblem(null);
|
||||
setCount(null);
|
||||
setAdvice(null);
|
||||
sync();
|
||||
const timer = setInterval(() => void t.check(), LOOK_MS);
|
||||
void t.check();
|
||||
const timer = setInterval(() => void look(), LOOK_MS);
|
||||
void look();
|
||||
return () => {
|
||||
clearInterval(timer);
|
||||
t.stop();
|
||||
if (trackerRef.current === t) trackerRef.current = null;
|
||||
setTracker(null);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -209,16 +231,19 @@ export default function LearnPractice(props: {
|
||||
try {
|
||||
// Part by part, waiting each time until the goal itself has seen it done.
|
||||
for (let guard = 0; !tracker.done && guard <= tracker.items.length; guard++) {
|
||||
// The learner moved to another step while this was going: it is no longer theirs to watch.
|
||||
if (trackerRef.current !== tracker) return;
|
||||
const item = tracker.current!;
|
||||
await engine.showMe(item.goal, bench);
|
||||
for (const end = Date.now() + engine.patience(item.goal); !item.done && Date.now() < end; ) {
|
||||
await tracker.check();
|
||||
if (!item.done) await new Promise((r) => setTimeout(r, 100));
|
||||
}
|
||||
if (trackerRef.current !== tracker) return;
|
||||
if (!item.done) throw new Error('That did not get there. Press "Start this step again" and try once more.');
|
||||
}
|
||||
} catch (e) {
|
||||
setProblem(errorText(e));
|
||||
if (trackerRef.current === tracker) setProblem(errorText(e));
|
||||
} finally {
|
||||
setShowing(false);
|
||||
}
|
||||
@@ -228,11 +253,30 @@ export default function LearnPractice(props: {
|
||||
|
||||
const parts = goalParts(step.goal);
|
||||
const allDone = reached.length > 0 && reached.every(Boolean);
|
||||
const current = parts[Math.max(0, reached.indexOf(false))];
|
||||
const at = Math.max(0, reached.indexOf(false));
|
||||
const current = parts[at];
|
||||
const ready = !!link && !crashed;
|
||||
const next = props.last ? 'Finish the lesson' : 'Next step';
|
||||
// A step that builds on earlier ones, in a game where those were not done: the learner skipped
|
||||
// ahead, came back to the lesson later, or started the step again. A challenge always starts from nothing.
|
||||
const missing = allDone || step.challenge || !room ? undefined : props.needs.find((i) => !doneHere.includes(i));
|
||||
const behind = missing === undefined ? null : { index: missing, title: lesson.steps[missing].title };
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{behind && (
|
||||
<Notice>
|
||||
<div className="flex flex-col items-start gap-2">
|
||||
<span>
|
||||
This step carries on from step {behind.index + 1}, "{behind.title}", and that one has not been done in the practice game as it is running now. Go back and
|
||||
work forward from there, or do the same here before you follow the text below.
|
||||
</span>
|
||||
<Button variant="outline" size="sm" onClick={() => props.onGoto(behind.index)}>
|
||||
<ArrowLeftIcon /> Go to step {behind.index + 1}
|
||||
</Button>
|
||||
</div>
|
||||
</Notice>
|
||||
)}
|
||||
<LearnText text={step.text} />
|
||||
|
||||
<section aria-label="Your goal" className={cn('flex flex-col gap-3 rounded-lg border bg-card p-4', allDone && 'border-primary')}>
|
||||
@@ -241,7 +285,10 @@ export default function LearnPractice(props: {
|
||||
{parts.map((part, i) => (
|
||||
<li key={i} className={cn('flex items-start gap-2.5 text-sm leading-snug', reached[i] ? 'text-foreground' : 'text-foreground/80')}>
|
||||
{reached[i] ? <CheckIcon className="mt-0.5 size-4 shrink-0 text-primary" aria-label="Done" /> : <CircleIcon className="mt-0.5 size-4 shrink-0 text-muted-foreground" aria-label="Not done yet" />}
|
||||
<span>{part.label}</span>
|
||||
<span>
|
||||
{part.label}
|
||||
{i === at && count && !allDone && <span className="whitespace-nowrap text-muted-foreground tabular-nums"> ({count} so far)</span>}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
@@ -250,11 +297,17 @@ export default function LearnPractice(props: {
|
||||
<div className="flex flex-col items-start gap-3" role="status">
|
||||
<LearnText text={step.done} className="text-[0.95em]" />
|
||||
<Button onClick={props.onNext}>
|
||||
Next step <ArrowRightIcon />
|
||||
{next} <ArrowRightIcon />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{advice && !allDone && !showing && (
|
||||
<p className="max-w-[68ch] border-l-2 border-destructive py-0.5 pl-3 text-sm leading-relaxed text-foreground/85" role="status">
|
||||
{advice}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{step.hints.slice(0, hints).map((hint, i) => (
|
||||
<p key={i} className="flex max-w-[68ch] gap-2.5 border-l-2 border-primary py-0.5 pl-3 text-sm leading-relaxed text-foreground/85">
|
||||
<span className="shrink-0 font-semibold text-primary">Hint {i + 1}</span>
|
||||
@@ -300,12 +353,12 @@ export default function LearnPractice(props: {
|
||||
|
||||
{link && room && !crashed ? (
|
||||
<WorkbenchProvider key={link.game.pid} session={link.session} game={link.game} gameName={room.def.name} practice>
|
||||
<PracticeTools tools={tools} tool={step.tool} step={props.index} session={link.session} game={link.game} />
|
||||
<PracticeTools tools={tools} lastTab={lastTab} tool={step.tool} step={props.index} session={link.session} game={link.game} />
|
||||
</WorkbenchProvider>
|
||||
) : (
|
||||
<p className="flex items-center gap-2 rounded-lg border border-dashed p-6 text-sm text-muted-foreground" role="status">
|
||||
{crashed ? (
|
||||
'The tools have nothing to look at: the practice game is not running. Restart it below.'
|
||||
'The tools have nothing to look at: the practice game is not running. Press "Restart game" on its panel.'
|
||||
) : (
|
||||
<>
|
||||
<Spinner className="size-4" /> Starting the practice game…
|
||||
@@ -314,7 +367,7 @@ export default function LearnPractice(props: {
|
||||
</p>
|
||||
)}
|
||||
|
||||
{room && <GameDock con={room.con} def={room.def} crashed={crashed} goal={allDone ? null : current.label} onNext={allDone ? props.onNext : undefined} />}
|
||||
{room && <GameDock con={room.con} def={room.def} crashed={crashed} goal={allDone ? null : current.label} count={allDone ? null : count} next={next} onNext={allDone ? props.onNext : undefined} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -328,15 +381,35 @@ const TOOLS: { id: Tool; label: string }[] = [
|
||||
{ id: 'cheats', label: 'Your cheats' },
|
||||
];
|
||||
|
||||
function PracticeTools(props: { tools: MutableRefObject<Workbench | null>; tool: Tool; step: number; session: Session; game: Game }) {
|
||||
function PracticeTools(props: {
|
||||
tools: MutableRefObject<Workbench | null>;
|
||||
lastTab: MutableRefObject<{ step: number; tab: TabId } | null>;
|
||||
tool: Tool;
|
||||
step: number;
|
||||
session: Session;
|
||||
game: Game;
|
||||
}) {
|
||||
const wb = useWorkbench();
|
||||
// The lesson's "Show me" and goal checks reach the tools through this.
|
||||
props.tools.current = wb;
|
||||
useEffect(() => () => void (props.tools.current = null), [props.tools]);
|
||||
|
||||
// Each step opens on the tool it is about; after that the learner (and the tools' own "show in Memory" links) decide.
|
||||
// The tools are made anew when the game restarts, and then stay on the tab the learner was looking at.
|
||||
const { setTab } = wb;
|
||||
useEffect(() => setTab(props.tool), [props.tool, props.step]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
const { lastTab } = props;
|
||||
const asked = useRef<TabId | null>(null);
|
||||
useEffect(() => {
|
||||
const was = lastTab.current;
|
||||
asked.current = was && was.step === props.step ? was.tab : props.tool;
|
||||
setTab(asked.current);
|
||||
}, [props.tool, props.step]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
useEffect(() => {
|
||||
// Not before the tab asked for above is the one showing: until then this is the tools' own starting tab, not the learner's choice.
|
||||
if (asked.current !== null && wb.tab !== asked.current) return;
|
||||
asked.current = null;
|
||||
lastTab.current = { step: props.step, tab: wb.tab };
|
||||
}, [lastTab, props.step, wb.tab]);
|
||||
|
||||
const tab: Tool = TOOLS.some((t) => t.id === wb.tab) ? (wb.tab as Tool) : props.tool;
|
||||
return (
|
||||
@@ -416,7 +489,7 @@ function HudValue(props: { hud: HudDef; value: number }) {
|
||||
const max = hud.max ?? 100;
|
||||
return (
|
||||
<div
|
||||
className="h-4 w-36 max-w-full overflow-hidden rounded-sm border border-primary/50 bg-background sm:w-48"
|
||||
className="h-4 w-36 max-w-full overflow-hidden rounded-sm border border-primary/50 bg-background sm:w-48 xl:w-full"
|
||||
role="meter"
|
||||
aria-label={hud.label}
|
||||
aria-valuemin={0}
|
||||
@@ -429,17 +502,24 @@ function HudValue(props: { hud: HudDef; value: number }) {
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="font-mono text-2xl leading-none font-bold tabular-nums">
|
||||
<span className="font-mono text-2xl leading-none font-bold tabular-nums [@media(max-height:560px)_and_(max-width:1279px)]:text-lg">
|
||||
{hud.show === 'time' ? clock(value) : value.toLocaleString('en-US', { minimumFractionDigits: hud.digits ?? 0, maximumFractionDigits: hud.digits ?? 0, useGrouping: false })}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// The game's panel has three shapes, all from the classes below:
|
||||
// - beside the lesson, in a window wide enough to have room next to the text (Tailwind's xl, 1280px);
|
||||
// - docked at the bottom of anything narrower, over the end of the page;
|
||||
// - and docked but one line high when the window is short (a phone on its side, or one whose
|
||||
// keyboard has pushed the page up), so that it never takes the room the tools need.
|
||||
// SHORT is written out in full each time because the class names have to appear whole in the source.
|
||||
|
||||
/**
|
||||
* The practice game: what it shows, and its buttons. It stays at the bottom
|
||||
* of the window while the lesson and the tools scroll above it.
|
||||
* The practice game: what it shows, and its buttons. It stays in view while
|
||||
* the lesson and the tools scroll.
|
||||
*/
|
||||
const GameDock = memo(function GameDock(props: { con: PracticeConsole; def: GameDef; crashed: string | null; goal: string | null; onNext?: () => void }) {
|
||||
const GameDock = memo(function GameDock(props: { con: PracticeConsole; def: GameDef; crashed: string | null; goal: string | null; count: string | null; next: string; onNext?: () => void }) {
|
||||
const { con, def } = props;
|
||||
const { game } = con;
|
||||
// Redrawn whenever the game says something happened, and a few times a second for what changes it from outside.
|
||||
@@ -453,41 +533,83 @@ const GameDock = memo(function GameDock(props: { con: PracticeConsole; def: Game
|
||||
clearInterval(timer);
|
||||
};
|
||||
}, [con]);
|
||||
|
||||
// Docked at the bottom, the panel lies over the last lines of the window. The browser is told
|
||||
// how tall it is, so that a box taking the focus (or anything else scrolled into view) stops above it.
|
||||
const ref = useRef<HTMLElement | null>(null);
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
const root = document.documentElement;
|
||||
const apply = () => {
|
||||
const height = el.getBoundingClientRect().height;
|
||||
const docked = height > 0 && getComputedStyle(el).position === 'sticky';
|
||||
root.style.scrollPaddingBottom = docked ? `${Math.ceil(height) + 12}px` : '';
|
||||
};
|
||||
// Not every browser keeps to that when the box is already partly in view, so the same is done by hand.
|
||||
const clear = (e: FocusEvent) => {
|
||||
const target = e.target instanceof Element ? e.target : null;
|
||||
if (!target || el.contains(target) || getComputedStyle(el).position !== 'sticky') return;
|
||||
const under = target.getBoundingClientRect().bottom + 12 - el.getBoundingClientRect().top;
|
||||
// Only what lies under the panel: something below it altogether is the end of the page, where the panel is not over anything.
|
||||
if (under > 0 && target.getBoundingClientRect().top < el.getBoundingClientRect().bottom) window.scrollBy(0, under);
|
||||
};
|
||||
apply();
|
||||
const watch = typeof ResizeObserver === 'function' ? new ResizeObserver(apply) : null;
|
||||
watch?.observe(el);
|
||||
window.addEventListener('resize', apply);
|
||||
document.addEventListener('focusin', clear);
|
||||
return () => {
|
||||
watch?.disconnect();
|
||||
window.removeEventListener('resize', apply);
|
||||
document.removeEventListener('focusin', clear);
|
||||
root.style.scrollPaddingBottom = '';
|
||||
};
|
||||
}, []);
|
||||
|
||||
const press = useCallback((id: string) => con.press(id), [con]);
|
||||
const buttons = def.actions.filter((a) => !a.hidden && !a.every);
|
||||
|
||||
return (
|
||||
<section
|
||||
ref={ref}
|
||||
aria-label={`Practice game: ${def.name}`}
|
||||
className="sticky bottom-0 z-30 -mx-4 mt-2 flex flex-col gap-2.5 border-t-2 border-primary bg-[#0c0c0b] px-4 pt-2.5 pb-[calc(0.75rem+env(safe-area-inset-bottom))] shadow-[0_-12px_24px_rgba(0,0,0,0.6)] sm:mx-0 sm:rounded-t-lg sm:border-x"
|
||||
className={cn(
|
||||
'sticky bottom-0 z-30 -mx-4 mt-2 flex flex-col gap-2.5 border-t-2 border-primary bg-[#0c0c0b] px-4 pt-2.5 pb-[calc(0.75rem+env(safe-area-inset-bottom))] shadow-[0_-8px_16px_rgba(0,0,0,0.5)] sm:mx-0 sm:rounded-t-lg sm:border-x',
|
||||
'xl:fixed xl:top-20 xl:bottom-auto xl:left-[calc(50%+25rem)] xl:mt-0 xl:max-h-[calc(100dvh-6rem)] xl:w-[calc(50%-26rem)] xl:max-w-80 xl:overflow-y-auto xl:rounded-lg xl:border-2 xl:pb-3 xl:shadow-none',
|
||||
'[@media(max-height:560px)_and_(max-width:1279px)]:flex-row [@media(max-height:560px)_and_(max-width:1279px)]:flex-wrap [@media(max-height:560px)_and_(max-width:1279px)]:items-center [@media(max-height:560px)_and_(max-width:1279px)]:gap-x-3 [@media(max-height:560px)_and_(max-width:1279px)]:gap-y-1.5 [@media(max-height:560px)_and_(max-width:1279px)]:pt-1.5 [@media(max-height:560px)_and_(max-width:1279px)]:pb-[calc(0.375rem+env(safe-area-inset-bottom))]',
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs">
|
||||
<span className="font-semibold text-primary">Practice game</span>
|
||||
<span className="text-muted-foreground">
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs [@media(max-height:560px)_and_(max-width:1279px)]:order-3">
|
||||
<span className="font-semibold text-primary [@media(max-height:560px)_and_(max-width:1279px)]:hidden">Practice game</span>
|
||||
<span className="text-muted-foreground [@media(max-height:560px)_and_(max-width:1279px)]:hidden">
|
||||
{def.name}
|
||||
{game.updates > 0 ? `, version ${game.version}` : ''}
|
||||
</span>
|
||||
<span className="flex-1" />
|
||||
<span className="flex-1 [@media(max-height:560px)_and_(max-width:1279px)]:hidden" />
|
||||
{def.update && game.updates === 0 && (
|
||||
<Button variant="ghost" size="xs" onClick={() => con.update()}>
|
||||
<Button variant="ghost" size="xs" className="h-8" onClick={() => con.update()}>
|
||||
<DownloadIcon /> Install game update
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="ghost" size="xs" onClick={() => con.restart()}>
|
||||
<Button variant="ghost" size="xs" className="h-8" onClick={() => con.restart()}>
|
||||
<RotateCcwIcon /> Restart game
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{props.crashed ? (
|
||||
<div className="flex flex-col gap-1 py-1" role="alert">
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1 py-1" role="alert">
|
||||
<p className="font-semibold text-destructive">The practice game crashed</p>
|
||||
<p className="max-w-[68ch] text-sm leading-relaxed text-foreground/85">{props.crashed} Nothing is harmed: press "Restart game" and it is back as it shipped.</p>
|
||||
<Button size="sm" className="mt-1 self-start" onClick={() => con.restart()}>
|
||||
<RotateCcwIcon /> Restart game
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-wrap items-end gap-x-6 gap-y-3">
|
||||
<dl className="flex flex-wrap items-end gap-x-6 gap-y-2">
|
||||
<div className="flex min-w-0 flex-wrap items-end gap-x-6 gap-y-3 xl:flex-col xl:items-stretch [@media(max-height:560px)_and_(max-width:1279px)]:flex-1 [@media(max-height:560px)_and_(max-width:1279px)]:items-center [@media(max-height:560px)_and_(max-width:1279px)]:gap-x-4">
|
||||
<dl className="flex flex-wrap items-end gap-x-6 gap-y-2 [@media(max-height:560px)_and_(max-width:1279px)]:gap-x-4">
|
||||
{def.hud.map((hud) => (
|
||||
<div key={hud.field} className="flex flex-col gap-1">
|
||||
<div key={hud.field} className="flex flex-col gap-1 [@media(max-height:560px)_and_(max-width:1279px)]:gap-0">
|
||||
<dt className="text-xs text-muted-foreground">{hud.label}</dt>
|
||||
<dd>
|
||||
<HudValue hud={hud} value={game.get(hud.field)} />
|
||||
@@ -497,7 +619,11 @@ const GameDock = memo(function GameDock(props: { con: PracticeConsole; def: Game
|
||||
</dl>
|
||||
<div className="flex min-w-[min(100%,12rem)] flex-1 flex-wrap justify-end gap-2">
|
||||
{buttons.map((a) => (
|
||||
<Button key={a.id} className="h-11 min-w-28 flex-1 px-4 text-base font-semibold whitespace-normal sm:flex-none" onClick={() => press(a.id)}>
|
||||
<Button
|
||||
key={a.id}
|
||||
className="h-auto min-h-11 min-w-28 flex-1 px-4 py-1.5 text-base leading-tight font-semibold whitespace-normal sm:flex-none xl:flex-1 [@media(max-height:560px)_and_(max-width:1279px)]:min-h-9 [@media(max-height:560px)_and_(max-width:1279px)]:min-w-0 [@media(max-height:560px)_and_(max-width:1279px)]:px-3 [@media(max-height:560px)_and_(max-width:1279px)]:py-1 [@media(max-height:560px)_and_(max-width:1279px)]:text-sm"
|
||||
onClick={() => press(a.id)}
|
||||
>
|
||||
{a.label}
|
||||
</Button>
|
||||
))}
|
||||
@@ -507,18 +633,21 @@ const GameDock = memo(function GameDock(props: { con: PracticeConsole; def: Game
|
||||
|
||||
{/* The goal, where it can be seen without scrolling back up. */}
|
||||
{props.onNext ? (
|
||||
<div className="flex items-center gap-2 border-t pt-2 text-sm">
|
||||
<div className="flex items-center gap-2 border-t pt-2 text-sm xl:flex-wrap xl:gap-y-2 [@media(max-height:560px)_and_(max-width:1279px)]:order-4 [@media(max-height:560px)_and_(max-width:1279px)]:border-t-0 [@media(max-height:560px)_and_(max-width:1279px)]:pt-0" role="status">
|
||||
<CheckIcon className="size-4 shrink-0 text-primary" />
|
||||
<span className="min-w-0 flex-1 font-medium">Goal reached</span>
|
||||
<Button size="sm" onClick={props.onNext}>
|
||||
Next step <ArrowRightIcon />
|
||||
<span className="min-w-0 flex-1 font-medium whitespace-nowrap [@media(max-height:560px)_and_(max-width:1279px)]:sr-only">Goal reached</span>
|
||||
<Button size="sm" className="xl:w-full" onClick={props.onNext}>
|
||||
{props.next} <ArrowRightIcon />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
props.goal && (
|
||||
<p className="flex items-start gap-2 border-t pt-2 text-xs leading-snug text-muted-foreground">
|
||||
<p className="flex items-start gap-2 border-t pt-2 text-xs leading-snug text-muted-foreground [@media(max-height:560px)_and_(max-width:1279px)]:hidden">
|
||||
<CircleIcon className="mt-px size-3.5 shrink-0" />
|
||||
<span className="min-w-0">{props.goal}</span>
|
||||
<span className="min-w-0">
|
||||
{props.goal}
|
||||
{props.count && <span className="whitespace-nowrap tabular-nums"> ({props.count} so far)</span>}
|
||||
</span>
|
||||
</p>
|
||||
)
|
||||
)}
|
||||
|
||||
+39
-13
@@ -49,7 +49,10 @@ export function LibraryView(props: {
|
||||
const [query, setQuery] = useState('');
|
||||
const [confirm, setConfirm] = useState<LibraryTitle | null>(null);
|
||||
const [starting, setStarting] = useState<string | null>(null);
|
||||
const [startError, setStartError] = useState<string | null>(null);
|
||||
/** What went wrong starting a game, and which one, so it can be said on that game's row. */
|
||||
const [startError, setStartError] = useState<{ id: string; text: string } | null>(null);
|
||||
/** The game that was asked for has still not shown up after a good while. */
|
||||
const [slow, setSlow] = useState(false);
|
||||
const [startedId, setStartedId] = useState<string | null>(null);
|
||||
const generation = useRef(0);
|
||||
|
||||
@@ -95,6 +98,14 @@ export function LibraryView(props: {
|
||||
if (startedId && running?.titleId === startedId) setStartedId(null);
|
||||
}, [running?.titleId, startedId]);
|
||||
|
||||
// A game normally shows up within seconds. If it has not after a minute, say so instead of "Starting" forever.
|
||||
useEffect(() => {
|
||||
setSlow(false);
|
||||
if (!startedId) return;
|
||||
const timer = setTimeout(() => setSlow(true), 60_000);
|
||||
return () => clearTimeout(timer);
|
||||
}, [startedId]);
|
||||
|
||||
const rows = useMemo(() => {
|
||||
if (state.name !== 'ready') return [];
|
||||
const needle = query.trim().toLowerCase();
|
||||
@@ -114,11 +125,13 @@ export function LibraryView(props: {
|
||||
setStartedId(title.id);
|
||||
props.onStarted?.();
|
||||
} catch (e) {
|
||||
setStartError(
|
||||
e instanceof LibraryUnavailable
|
||||
? 'The CheatSpecter payload on the console stopped answering. Send cheatspecter.elf to the PS5 again.'
|
||||
: `${metas[title.id]?.name ?? title.id} did not start. ${errorText(e)}`,
|
||||
);
|
||||
setStartError({
|
||||
id: title.id,
|
||||
text:
|
||||
e instanceof LibraryUnavailable
|
||||
? 'The CheatSpecter payload on the console stopped answering. Send cheatspecter.elf to the PS5 again.'
|
||||
: `${metas[title.id]?.name ?? title.id} did not start. ${errorText(e)}`,
|
||||
});
|
||||
} finally {
|
||||
setStarting(null);
|
||||
}
|
||||
@@ -170,9 +183,6 @@ export function LibraryView(props: {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{startError && <Notice kind="error">{startError}</Notice>}
|
||||
{startedId && <Notice>Starting {metas[startedId]?.name ?? startedId}. It will appear here as soon as it is running.</Notice>}
|
||||
|
||||
{state.titles.length === 0 ? (
|
||||
<p className="py-4 text-center text-sm text-muted-foreground">The console did not report any installed games.</p>
|
||||
) : rows.length === 0 ? (
|
||||
@@ -183,12 +193,11 @@ export function LibraryView(props: {
|
||||
const isRunning = running?.titleId === title.id;
|
||||
const size = formatSize(title.size);
|
||||
return (
|
||||
<li key={title.id} className="flex items-center gap-3 p-2.5">
|
||||
<li key={title.id} className="flex flex-wrap items-center gap-x-3 gap-y-2 p-2.5">
|
||||
<TitleIcon target={target} id={title.id} />
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<span className="truncate font-medium" title={name || title.id}>
|
||||
{name || title.id}
|
||||
</span>
|
||||
{/* Wraps rather than cutting off: on a phone there is no hovering to read the rest. */}
|
||||
<span className="font-medium break-words">{name || title.id}</span>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<Pill className={title.platform === 'PS5' ? 'border-primary/60 text-xs text-primary' : 'text-xs'} variant="outline">
|
||||
{title.platform}
|
||||
@@ -203,6 +212,11 @@ export function LibraryView(props: {
|
||||
</div>
|
||||
{isRunning ? (
|
||||
<span className="shrink-0 px-2 text-sm font-medium text-primary">Running</span>
|
||||
) : startedId === title.id && !slow ? (
|
||||
// Said on the row that was pressed: in a long list, a note at the top would be out of sight.
|
||||
<span className="flex shrink-0 items-center gap-1.5 px-2 text-sm font-medium text-primary" role="status">
|
||||
<Spinner className="size-4" /> Starting…
|
||||
</span>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -215,6 +229,18 @@ export function LibraryView(props: {
|
||||
{starting === title.id ? <Spinner className="size-4" /> : <PlayIcon />} Start
|
||||
</Button>
|
||||
)}
|
||||
{startedId === title.id && (
|
||||
<p className="basis-full text-sm text-muted-foreground" role="status">
|
||||
{slow
|
||||
? 'The PS5 accepted it, but the game has not shown up yet. Look at the PS5: it may be asking you something, or the game may still be loading.'
|
||||
: 'The PS5 is starting it. This page moves on by itself as soon as the game is running.'}
|
||||
</p>
|
||||
)}
|
||||
{startError?.id === title.id && (
|
||||
<p className="basis-full text-sm text-destructive" role="alert">
|
||||
{startError.text}
|
||||
</p>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
|
||||
+91
-29
@@ -1,7 +1,8 @@
|
||||
// A live hex view of the game's memory, with a panel that reads the selected
|
||||
// bytes as the usual number types and lets them be changed.
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useId, useRef, useState } from 'react';
|
||||
import type { KeyboardEvent } from 'react';
|
||||
import { ChevronLeftIcon, ChevronRightIcon } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
@@ -9,8 +10,8 @@ import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { fmtAddr, hexToBytes, view } from '../lib/bytes.ts';
|
||||
import { imageRange, parseAddress } from '../lib/locations.ts';
|
||||
import { fmtAddr, parseTypedBytes, view } from '../lib/bytes.ts';
|
||||
import { imageRange, parseAddress, readableAt } from '../lib/locations.ts';
|
||||
import { valueEntry } from '../lib/make.ts';
|
||||
import { PROT } from '../lib/ps5debug.ts';
|
||||
import { VALUE_KINDS, decodeValue, encodeValue, valueKind } from '../lib/scanner.ts';
|
||||
@@ -19,6 +20,8 @@ import { useDebug } from './debughooks.tsx';
|
||||
import { useWorkbench } from './workbench.tsx';
|
||||
|
||||
const REFRESH_MS = 1000;
|
||||
/** The last address a page can start at: addresses are 64 bits. */
|
||||
const TOP = 0xffff_ffff_ffff_ffffn;
|
||||
|
||||
function useColumns(): number {
|
||||
const [cols, setCols] = useState(() => (typeof window !== 'undefined' && window.innerWidth < 640 ? 8 : 16));
|
||||
@@ -34,6 +37,8 @@ function useColumns(): number {
|
||||
|
||||
export function MemoryTab() {
|
||||
const wb = useWorkbench();
|
||||
// This tool can be on the page twice (a lesson's practice game in front of the real one), so its ids are per copy.
|
||||
const uid = useId();
|
||||
const { ctx } = wb;
|
||||
const { desk, showObjects } = useDebug();
|
||||
const cols = useColumns();
|
||||
@@ -44,22 +49,36 @@ export function MemoryTab() {
|
||||
const [changed, setChanged] = useState<Set<number>>(new Set());
|
||||
const [selected, setSelected] = useState(0);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
/** What was wrong with the address typed. Kept apart from `error`, which every successful refresh clears. */
|
||||
const [addressError, setAddressError] = useState<string | null>(null);
|
||||
/** Whether the game has memory where the page is; reads of nothing come back as zeros. */
|
||||
const [mapped, setMapped] = useState<'all' | 'part' | 'none'>('all');
|
||||
const [wrote, setWrote] = useState<string | null>(null);
|
||||
const [kindId, setKindId] = useState('u32');
|
||||
const [newValue, setNewValue] = useState('');
|
||||
const [newBytes, setNewBytes] = useState('');
|
||||
const [writeError, setWriteError] = useState<string | null>(null);
|
||||
const previous = useRef<Uint8Array | null>(null);
|
||||
// Read by `go` without making it a new function when the phone is turned, which would replay the last "show this address" request.
|
||||
const lengthRef = useRef(length);
|
||||
lengthRef.current = length;
|
||||
|
||||
const go = useCallback(
|
||||
(address: bigint, select = true) => {
|
||||
// Kept inside what an address can be, so paging past either end stops there.
|
||||
const top = TOP - BigInt(lengthRef.current) + 1n;
|
||||
const wanted = address < 0n ? 0n : address > top ? top : address;
|
||||
// Rows start on a 16-byte boundary so addresses are easy to read down the side.
|
||||
const start = address & ~0xfn;
|
||||
const start = wanted & ~0xfn;
|
||||
setBase(start);
|
||||
setText(fmtAddr(address));
|
||||
if (select) setSelected(Number(address - start));
|
||||
setText(fmtAddr(wanted));
|
||||
if (select) setSelected(Number(wanted - start));
|
||||
previous.current = null;
|
||||
setData(null);
|
||||
setError(null);
|
||||
setAddressError(null);
|
||||
setWriteError(null);
|
||||
setWrote(null);
|
||||
},
|
||||
[],
|
||||
);
|
||||
@@ -97,7 +116,11 @@ export function MemoryTab() {
|
||||
try {
|
||||
// Where a breakpoint sits the game holds a marker byte; show what the game's code really is.
|
||||
const bytes = desk.unpatch(base, await ctx.dbg().read(ctx.pid, base, length));
|
||||
// The memory map is in hand already (asking again every second would be wasteful); a page that looks empty is worth one fresh look.
|
||||
let there = await readableAt(ctx, base, length);
|
||||
if (there !== 'all') there = await readableAt(ctx, base, length, true);
|
||||
if (stopped) return;
|
||||
setMapped(there);
|
||||
const prev = previous.current;
|
||||
const diff = new Set<number>();
|
||||
if (prev && prev.length === bytes.length) for (let i = 0; i < bytes.length; i++) if (prev[i] !== bytes[i]) diff.add(i);
|
||||
@@ -121,7 +144,7 @@ export function MemoryTab() {
|
||||
try {
|
||||
go(await parseAddress(ctx, text));
|
||||
} catch (e) {
|
||||
setError(errorText(e));
|
||||
setAddressError(errorText(e));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -135,13 +158,16 @@ export function MemoryTab() {
|
||||
const write = async (bytes: Uint8Array) => {
|
||||
if (selAddress === null) return;
|
||||
setWriteError(null);
|
||||
setWrote(null);
|
||||
try {
|
||||
if (mapped === 'none') throw new Error('The game has no memory at this address, so there is nothing to change.');
|
||||
if (desk.breakpointsIn(selAddress, bytes.length).length > 0) {
|
||||
throw new Error('There is a breakpoint inside these bytes. Remove it on the Debug tab first, or the first byte of what you write is undone when the breakpoint goes.');
|
||||
}
|
||||
// Through the workbench, which remembers what was there so "Restore everything" can put it back.
|
||||
await wb.write(selAddress, bytes);
|
||||
previous.current = null;
|
||||
setWrote(`Wrote ${bytes.length} ${bytes.length === 1 ? 'byte' : 'bytes'} at ${fmtAddr(selAddress)}. "Restore everything" on the Cheats tab puts back what was there.`);
|
||||
} catch (e) {
|
||||
setWriteError(errorText(e));
|
||||
}
|
||||
@@ -151,28 +177,62 @@ export function MemoryTab() {
|
||||
try {
|
||||
void write(encodeValue(kind, newValue));
|
||||
} catch (e) {
|
||||
setWrote(null);
|
||||
setWriteError(errorText(e));
|
||||
}
|
||||
};
|
||||
|
||||
const writeRaw = () => {
|
||||
try {
|
||||
const bytes = hexToBytes(newBytes);
|
||||
if (bytes.length === 0) throw new Error('Enter some bytes, for example: 90 90 90');
|
||||
void write(bytes);
|
||||
void write(parseTypedBytes(newBytes));
|
||||
} catch (e) {
|
||||
setWrote(null);
|
||||
setWriteError(errorText(e));
|
||||
}
|
||||
};
|
||||
|
||||
const [saving, setSaving] = useState(false);
|
||||
const saveCheat = async () => {
|
||||
const bytes = at(kind.size);
|
||||
if (!bytes || selAddress === null) return;
|
||||
setWriteError(null);
|
||||
setSaving(true);
|
||||
try {
|
||||
const entry = await valueEntry(ctx, selAddress, kind, decodeValue(kind, bytes));
|
||||
await wb.addEntry(entry, { enable: false });
|
||||
wb.edit(entry, false);
|
||||
} catch (e) {
|
||||
setWriteError(errorText(e));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const rows = data ? Array.from({ length: data.length / cols }, (_, r) => r) : [];
|
||||
|
||||
// The grid is one stop for the Tab key (the selected byte), and the arrow
|
||||
// keys move within it. Without this a keyboard user had to tab through
|
||||
// every byte on the page to reach the panel below.
|
||||
const grid = useRef<HTMLTableElement | null>(null);
|
||||
const onGridKey = (e: KeyboardEvent) => {
|
||||
const step = e.key === 'ArrowLeft' ? -1 : e.key === 'ArrowRight' ? 1 : e.key === 'ArrowUp' ? -cols : e.key === 'ArrowDown' ? cols : 0;
|
||||
if (step === 0) return;
|
||||
e.preventDefault();
|
||||
const next = sel + step;
|
||||
if (next < 0 || next >= length) return;
|
||||
setSelected(next);
|
||||
grid.current?.querySelector<HTMLButtonElement>(`button[data-index="${next}"]`)?.focus();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Memory</CardTitle>
|
||||
<CardDescription>The game's memory as it is right now. Bytes that just changed are highlighted. Click one to read or change it.</CardDescription>
|
||||
<CardDescription>
|
||||
The game's memory as it is right now. Bytes that just changed are highlighted. Click one to read or change it.
|
||||
<span className="max-sm:hidden"> The arrow keys move from byte to byte.</span>
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
<form
|
||||
@@ -184,18 +244,26 @@ export function MemoryTab() {
|
||||
>
|
||||
<Input className="font-mono" aria-label="Address" placeholder="Address, or game+1A2B3C" value={text} onChange={(e) => setText(e.target.value)} />
|
||||
<Button type="submit">Go</Button>
|
||||
<Button type="button" variant="outline" size="icon" aria-label="Previous page" disabled={base === null} onClick={() => base !== null && go(base - BigInt(length), false)}>
|
||||
<Button type="button" variant="outline" size="icon" aria-label="Previous page" disabled={base === null || base === 0n} onClick={() => base !== null && go(base - BigInt(length), false)}>
|
||||
<ChevronLeftIcon />
|
||||
</Button>
|
||||
<Button type="button" variant="outline" size="icon" aria-label="Next page" disabled={base === null} onClick={() => base !== null && go(base + BigInt(length), false)}>
|
||||
<ChevronRightIcon />
|
||||
</Button>
|
||||
</form>
|
||||
{addressError && <Notice kind="error">{addressError}</Notice>}
|
||||
{error && <Notice kind="error">{error}</Notice>}
|
||||
{data && mapped !== 'all' && (
|
||||
<Notice kind="warn">
|
||||
{mapped === 'none'
|
||||
? 'The game has no memory at this address. The zeros below are placeholders, not something the game holds.'
|
||||
: 'The game has memory at only part of this page. Zeros outside it are placeholders, not something the game holds.'}
|
||||
</Notice>
|
||||
)}
|
||||
|
||||
{data && base !== null && (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="font-mono text-xs leading-6 sm:text-sm" aria-label="Memory bytes">
|
||||
<table ref={grid} className="font-mono text-xs leading-8 sm:text-sm sm:leading-6" aria-label="Memory bytes" onKeyDown={onGridKey}>
|
||||
<tbody>
|
||||
{rows.map((r) => (
|
||||
<tr key={r}>
|
||||
@@ -209,8 +277,11 @@ export function MemoryTab() {
|
||||
type="button"
|
||||
aria-label={`Byte at ${fmtAddr(base + BigInt(i))}`}
|
||||
aria-pressed={i === sel}
|
||||
data-index={i}
|
||||
tabIndex={i === sel ? 0 : -1}
|
||||
className={cn(
|
||||
'rounded-sm px-[3px] hover:bg-accent',
|
||||
// Wider on a phone, where a finger has to hit one byte.
|
||||
'rounded-sm px-[7px] hover:bg-accent sm:px-[3px]',
|
||||
c === 8 && 'ml-2',
|
||||
data[i] === 0 && 'text-muted-foreground/60',
|
||||
changed.has(i) && 'text-primary',
|
||||
@@ -272,7 +343,7 @@ export function MemoryTab() {
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="mem-value">Change it as a number</Label>
|
||||
<Label htmlFor={`${uid}mem-value`}>Change it as a number</Label>
|
||||
<div className="flex gap-2">
|
||||
<Select value={kindId} onValueChange={setKindId}>
|
||||
<SelectTrigger className="w-36 shrink-0" aria-label="Number type">
|
||||
@@ -286,16 +357,16 @@ export function MemoryTab() {
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Input id="mem-value" className="min-w-0 font-mono" value={newValue} onChange={(e) => setNewValue(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && writeValue()} />
|
||||
<Input id={`${uid}mem-value`} className="min-w-0 font-mono" value={newValue} onChange={(e) => setNewValue(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && writeValue()} />
|
||||
<Button variant="outline" disabled={!newValue.trim()} onClick={writeValue}>
|
||||
Write
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="mem-bytes">Or as raw bytes</Label>
|
||||
<Label htmlFor={`${uid}mem-bytes`}>Or as raw bytes</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input id="mem-bytes" className="min-w-0 font-mono" placeholder="90 90 90" value={newBytes} onChange={(e) => setNewBytes(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && writeRaw()} />
|
||||
<Input id={`${uid}mem-bytes`} className="min-w-0 font-mono" placeholder="90 90 90" value={newBytes} onChange={(e) => setNewBytes(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && writeRaw()} />
|
||||
<Button variant="outline" disabled={!newBytes.trim()} onClick={writeRaw}>
|
||||
Write
|
||||
</Button>
|
||||
@@ -303,22 +374,13 @@ export function MemoryTab() {
|
||||
</div>
|
||||
</div>
|
||||
{writeError && <Notice kind="error">{writeError}</Notice>}
|
||||
{wrote && !writeError && <Notice>{wrote}</Notice>}
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => wb.startTrace(selAddress, kind.size as 1 | 2 | 4 | 8, 'write')}>
|
||||
Find what writes to this
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
const bytes = at(kind.size);
|
||||
if (!bytes) return;
|
||||
const entry = await valueEntry(ctx, selAddress, kind, decodeValue(kind, bytes));
|
||||
await wb.addEntry(entry, { enable: false });
|
||||
wb.edit(entry, false);
|
||||
}}
|
||||
>
|
||||
<Button variant="outline" size="sm" disabled={saving || !at(kind.size)} onClick={() => void saveCheat()}>
|
||||
Save to your cheats
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => wb.showCode(selAddress)}>
|
||||
|
||||
+40
-16
@@ -107,9 +107,11 @@ export function OwnCheats() {
|
||||
} catch (e) {
|
||||
say(
|
||||
null,
|
||||
e instanceof Ps5StorageUnavailable
|
||||
? 'Saving on the PS5 needs the CheatSpecter payload running on the console. Send payload/cheatspecter.elf to the PS5 (the same way you load ps5debug), then save again. "Save to this device" works without it.'
|
||||
: `The PS5 did not save the file. ${e instanceof Error ? e.message : String(e)}`,
|
||||
e instanceof Ps5StorageUnavailable && wb.session.target?.label === 'Practice game'
|
||||
? 'The practice game is not on a PS5, so there is nowhere on a console to save its cheats. "Save to this device" works here.'
|
||||
: e instanceof Ps5StorageUnavailable
|
||||
? 'Saving on the PS5 needs the CheatSpecter payload running on the console. Send payload/cheatspecter.elf to the PS5 (the same way you load ps5debug), then save again. "Save to this device" works without it.'
|
||||
: `The PS5 did not save the file. ${e instanceof Error ? e.message : String(e)}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -132,6 +134,12 @@ export function OwnCheats() {
|
||||
}
|
||||
};
|
||||
|
||||
const exported = (name: string, out: { included: number; skipped: number }) =>
|
||||
`Exported ${out.included} ${out.included === 1 ? 'cheat' : 'cheats'} as ${name}.` +
|
||||
(out.skipped
|
||||
? ` ${out.skipped} ${out.skipped === 1 ? 'was' : 'were'} left out: that format only holds patches at a fixed offset with known original bytes.`
|
||||
: '');
|
||||
|
||||
const exportCommunity = async () => {
|
||||
const out = exportCommunityTable(wb.fileTable());
|
||||
if (out.included === 0) {
|
||||
@@ -139,7 +147,7 @@ export function OwnCheats() {
|
||||
return;
|
||||
}
|
||||
const name = await saveToDevice(`${wb.game.titleId}_${wb.game.version || 'unknown'}.json`, out.text, 'GoldHEN / etaHEN cheats');
|
||||
if (name) say(out.skipped ? `Exported ${out.included}. ${out.skipped} could not be written in that format and were left out.` : `Exported as ${name}.`);
|
||||
if (name) say(exported(name, out));
|
||||
};
|
||||
|
||||
// The same cheats in the two older formats cheat menus read. Named the way the community collections name them.
|
||||
@@ -150,7 +158,7 @@ export function OwnCheats() {
|
||||
return;
|
||||
}
|
||||
const name = await saveToDevice(`${wb.game.titleId}_${wb.game.version || 'unknown'}.${kind}`, out.text, `${kind.toUpperCase()} cheats`);
|
||||
if (name) say(out.skipped ? `Exported ${out.included}. ${out.skipped} could not be written in that format and were left out.` : `Exported as ${name}.`);
|
||||
if (name) say(exported(name, out));
|
||||
};
|
||||
|
||||
const setGroup = (ids: string[], group: string | undefined) =>
|
||||
@@ -179,15 +187,14 @@ export function OwnCheats() {
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-start justify-between gap-2">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<CardTitle>Your cheats</CardTitle>
|
||||
<CardDescription>
|
||||
{table.entries.length === 0
|
||||
? 'Cheats you make or import appear here. The quickest way to make one: find a value, ask what writes to it, and disable that.'
|
||||
: where}
|
||||
</CardDescription>
|
||||
</div>
|
||||
{/* The description sits under the title and buttons, not beside them: on a phone the buttons would squeeze it into a sliver. */}
|
||||
<CardHeader className="flex flex-row flex-wrap items-center justify-between gap-x-2 gap-y-1.5">
|
||||
<CardTitle>Your cheats</CardTitle>
|
||||
<CardDescription className="order-last basis-full break-words">
|
||||
{table.entries.length === 0
|
||||
? 'Cheats you make or import appear here. The quickest way to make one: find a value, ask what writes to it, and disable that.'
|
||||
: where}
|
||||
</CardDescription>
|
||||
<div className="flex shrink-0 gap-2">
|
||||
{table.entries.length > 0 && (
|
||||
<DropdownMenu>
|
||||
@@ -250,7 +257,7 @@ export function OwnCheats() {
|
||||
file for this version.
|
||||
</Notice>
|
||||
)}
|
||||
{(otherVersion || misfit) && (
|
||||
{otherVersion && (
|
||||
<Notice kind="warn">
|
||||
<div className="flex flex-col items-start gap-2">
|
||||
<span>
|
||||
@@ -287,6 +294,22 @@ export function OwnCheats() {
|
||||
onMove={(entry) => setGroupTask({ kind: 'move', entry })}
|
||||
/>
|
||||
))}
|
||||
{/* Under the list, not above it: it appears when a switch is pressed, and above would push the switches down under the finger. */}
|
||||
{misfit && !otherVersion && (
|
||||
<Notice kind="warn">
|
||||
<div className="flex flex-col items-start gap-2">
|
||||
<span>
|
||||
{otherVersion
|
||||
? `These cheats were made on v${table.version} of the game, and you are running v${wb.game.version}. An update moves the game's code, so some may have stopped working.`
|
||||
: 'A cheat no longer fits this game. That usually means the game was updated and its code moved.'}{' '}
|
||||
CheatSpecter can look for each one's new place. Nothing in the game is changed while it looks.
|
||||
</span>
|
||||
<Button size="sm" onClick={() => setRefinding(true)}>
|
||||
Re-find my cheats
|
||||
</Button>
|
||||
</div>
|
||||
</Notice>
|
||||
)}
|
||||
{hasHotkeys && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Hotkeys work while this page is open and in front, on the device showing it. They do not work from the controller.
|
||||
@@ -550,7 +573,8 @@ function EntryRow(props: { entry: Entry; onGroup: () => void }) {
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className={'truncate text-xs text-muted-foreground' + (plain ? '' : ' font-mono')} title={describeEntry(entry)}>
|
||||
{/* Wraps instead of being cut off: a phone has no hovering to read the rest. */}
|
||||
<div className={'text-xs break-words text-muted-foreground' + (plain ? '' : ' font-mono')} title={describeEntry(entry)}>
|
||||
{how}
|
||||
</div>
|
||||
{choices && !entry.unreviewed && <div className="mt-1.5 sm:hidden">{picker}</div>}
|
||||
|
||||
+59
-12
@@ -7,7 +7,7 @@
|
||||
// candidates lead to its new address. The candidates are kept per game in the
|
||||
// browser so that works across the restart.
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Label } from '@/components/ui/label';
|
||||
@@ -73,19 +73,35 @@ export function PointerFinder(props: { address: bigint; kind: ValueKind; value:
|
||||
const [checking, setChecking] = useState<string | null>(null);
|
||||
const [checked, setChecked] = useState<Checked | null>(null);
|
||||
const [notSaved, setNotSaved] = useState(false);
|
||||
const [canceled, setCanceled] = useState(false);
|
||||
const [stopping, setStopping] = useState(false);
|
||||
const stop = useRef<AbortController | null>(null);
|
||||
|
||||
// If the dialog goes away while a search runs (the game closed), the search should not carry on behind it.
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
stop.current?.abort();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const search = async () => {
|
||||
setError(null);
|
||||
setPaths(null);
|
||||
setChecked(null);
|
||||
setIsStatic(false);
|
||||
setCanceled(false);
|
||||
setStopping(false);
|
||||
setRunning(true);
|
||||
const controller = new AbortController();
|
||||
stop.current = controller;
|
||||
try {
|
||||
if (await moduleLocation(wb.ctx, props.address)) {
|
||||
// Already inside a module: its offset is all that is needed.
|
||||
setIsStatic(true);
|
||||
return;
|
||||
}
|
||||
const found = await findPointerPaths(wb.ctx, props.address, { maxDepth: Number(depth) }, setStatus);
|
||||
const found = await findPointerPaths(wb.ctx, props.address, { maxDepth: Number(depth), signal: controller.signal }, setStatus);
|
||||
setCanceled(controller.signal.aborted);
|
||||
setPaths(found);
|
||||
if (found.length > 0) {
|
||||
// Kept for the check after a restart; that check is what separates real paths from lucky ones.
|
||||
@@ -96,7 +112,9 @@ export function PointerFinder(props: { address: bigint; kind: ValueKind; value:
|
||||
} catch (e) {
|
||||
setError(errorText(e));
|
||||
} finally {
|
||||
stop.current = null;
|
||||
setRunning(false);
|
||||
setStopping(false);
|
||||
setStatus('');
|
||||
}
|
||||
};
|
||||
@@ -122,10 +140,15 @@ export function PointerFinder(props: { address: bigint; kind: ValueKind; value:
|
||||
};
|
||||
|
||||
const use = async (path?: PointerPath) => {
|
||||
const entry = await valueEntry(wb.ctx, props.address, props.kind, props.value, path?.location);
|
||||
await wb.addEntry(entry, { enable: false });
|
||||
props.onClose();
|
||||
wb.edit(entry, false);
|
||||
setError(null);
|
||||
try {
|
||||
const entry = await valueEntry(wb.ctx, props.address, props.kind, props.value, path?.location);
|
||||
await wb.addEntry(entry, { enable: false });
|
||||
props.onClose();
|
||||
wb.edit(entry, false);
|
||||
} catch (e) {
|
||||
setError(errorText(e));
|
||||
}
|
||||
};
|
||||
|
||||
const busy = running || checking !== null;
|
||||
@@ -233,12 +256,31 @@ export function PointerFinder(props: { address: bigint; kind: ValueKind; value:
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button variant={saved.length > 0 ? 'outline' : 'default'} disabled={busy} onClick={() => void search()}>
|
||||
{running ? <Spinner className="size-4" /> : null}
|
||||
{running ? 'Searching…' : 'Search'}
|
||||
</Button>
|
||||
{running ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={stopping}
|
||||
onClick={() => {
|
||||
setStopping(true);
|
||||
stop.current?.abort();
|
||||
}}
|
||||
>
|
||||
<Spinner className="size-4" />
|
||||
{stopping ? 'Stopping…' : 'Cancel'}
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant={saved.length > 0 ? 'outline' : 'default'} disabled={busy} onClick={() => void search()}>
|
||||
Search
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{running && <p className="text-sm text-muted-foreground">{status || 'Starting…'} Each level scans the game's memory again.</p>}
|
||||
{running && (
|
||||
<p className="text-sm text-muted-foreground" aria-live="polite">
|
||||
{stopping
|
||||
? 'Stopping after the part of memory being searched right now. On a large game that can take up to a minute.'
|
||||
: `${status || 'Starting…'} Each level scans the game's memory again, which can take minutes on a large game.`}
|
||||
</p>
|
||||
)}
|
||||
{error && <Notice kind="error">{error}</Notice>}
|
||||
|
||||
{isStatic && (
|
||||
@@ -250,7 +292,12 @@ export function PointerFinder(props: { address: bigint; kind: ValueKind; value:
|
||||
</Notice>
|
||||
)}
|
||||
|
||||
{paths && paths.length === 0 && (
|
||||
{paths && canceled && (
|
||||
<Notice kind="warn">
|
||||
Canceled before all of memory was searched{paths.length > 0 ? '. What was found up to then is listed below.' : ', and nothing had been found yet.'}
|
||||
</Notice>
|
||||
)}
|
||||
{paths && paths.length === 0 && !canceled && (
|
||||
<Notice kind="warn">
|
||||
No path found at this depth. Try one level deeper, or find what writes to the value and patch that code instead, which does not depend on
|
||||
where the value lives.
|
||||
|
||||
+107
-33
@@ -2,7 +2,7 @@
|
||||
// happen in the game, copy it again and list what changed. The comparing is in
|
||||
// lib/memdiff.ts.
|
||||
|
||||
import { useMemo, useRef, useState } from 'react';
|
||||
import { useId, useMemo, useRef, useState } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { MoreHorizontalIcon } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -14,11 +14,14 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { fmtAddr, fmtBytes } from '../lib/bytes.ts';
|
||||
import { parseAddress } from '../lib/locations.ts';
|
||||
import { SNAPSHOT_MAX_BYTES, diffHits, diffSnapshots, takeSnapshot } from '../lib/memdiff.ts';
|
||||
import { failedFeedback } from '../lib/findfeedback.ts';
|
||||
import type { Feedback } from '../lib/findfeedback.ts';
|
||||
import { SNAPSHOT_MAX_BYTES, SnapshotCanceledError, diffHits, diffSnapshots, takeSnapshot } from '../lib/memdiff.ts';
|
||||
import type { DiffFilter, MemSnapshot } from '../lib/memdiff.ts';
|
||||
import { ScanSession, VALUE_KINDS, decodeValue, valueKind } from '../lib/scanner.ts';
|
||||
import type { Scan } from '../lib/scanner.ts';
|
||||
import { Notice, PelletTrack, errorText } from './bits.tsx';
|
||||
import { Notice, errorText } from './bits.tsx';
|
||||
import { FeedbackNotice, SearchProgress, gameGone, useCopy } from './findbits.tsx';
|
||||
import type { ScopeControl } from './WhereToSearch.tsx';
|
||||
import { useWorkbench } from './workbench.tsx';
|
||||
|
||||
@@ -31,6 +34,8 @@ const clock = (ms: number) => new Date(ms).toLocaleTimeString();
|
||||
export function RegionCompare(props: { switcher: ReactNode; scope: ScopeControl; onContinue: (scan: Scan, note: string) => void }) {
|
||||
const wb = useWorkbench();
|
||||
const { session, game, ctx } = wb;
|
||||
// This tool can be on the page twice (a lesson's practice game in front of the real one), so its ids are per copy.
|
||||
const uid = useId();
|
||||
const [where, setWhere] = useState(AROUND);
|
||||
const [address, setAddress] = useState('');
|
||||
const [size, setSize] = useState(String(64 * 1024));
|
||||
@@ -41,9 +46,15 @@ export function RegionCompare(props: { switcher: ReactNode; scope: ScopeControl;
|
||||
const [kindId, setKindId] = useState('u32');
|
||||
const [filter, setFilter] = useState<DiffFilter>('changed');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [fraction, setFraction] = useState<number | null>(null);
|
||||
const [canceling, setCanceling] = useState(false);
|
||||
const [run, setRun] = useState<{ startedAt: number; what: string; bytes: number; fraction: number | null }>({ startedAt: 0, what: '', bytes: 0, fraction: null });
|
||||
/** Something wrong with the area or address chosen, shown next to it. */
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
/** How the last read ended when it did not produce a snapshot. */
|
||||
const [feedback, setFeedback] = useState<Feedback | null>(null);
|
||||
const stop = useRef<AbortController | null>(null);
|
||||
const working = useRef(false);
|
||||
const { copy, toast } = useCopy();
|
||||
|
||||
// Whole areas small enough to snapshot in one go: the game's own data, a library's data, a named buffer.
|
||||
const areas = useMemo(
|
||||
@@ -58,23 +69,57 @@ export function RegionCompare(props: { switcher: ReactNode; scope: ScopeControl;
|
||||
if (!area) throw new Error('That area is no longer there. Choose another one.');
|
||||
return { start: area.ranges[0].start, length: area.bytes };
|
||||
}
|
||||
const at = await parseAddress(ctx, address);
|
||||
if (address.trim() === '')
|
||||
throw new Error('Type the address to look around. You can copy one from a search result: open its menu (the three dots) and choose "Copy address".');
|
||||
let at: bigint;
|
||||
try {
|
||||
at = await parseAddress(ctx, address);
|
||||
} catch (e) {
|
||||
// "game+1A2B" and module names have their own, clearer messages.
|
||||
if (address.includes('+')) throw e;
|
||||
throw new Error(`"${address.trim()}" is not an address. An address looks like 0x880012340; copy one from a search result or the Memory tab.`);
|
||||
}
|
||||
const length = Number(size);
|
||||
// Center the block on the address, on a 16-byte boundary so every number type lines up.
|
||||
const half = BigInt(length / 2);
|
||||
const start = (at > half ? at - half : 0n) & ~0xfn;
|
||||
// Memory that does not exist reads as zeros, which would look like "nothing changed" forever.
|
||||
const end = start + BigInt(length);
|
||||
const items = await props.scope.refresh();
|
||||
if (!items.some((i) => i.ranges.some((r) => r.start < end && start < r.end))) {
|
||||
throw new Error(`The game has no memory at ${fmtAddr(at)}. Check the address; it should be one shown in a search result or in the Memory tab.`);
|
||||
}
|
||||
return { start, length };
|
||||
};
|
||||
|
||||
const snap = async () => {
|
||||
if (working.current) return;
|
||||
setError(null);
|
||||
setFeedback(null);
|
||||
let t: { start: bigint; length: number };
|
||||
try {
|
||||
t = await target();
|
||||
} catch (e) {
|
||||
setError(errorText(e));
|
||||
return;
|
||||
}
|
||||
working.current = true;
|
||||
const startedAt = Date.now();
|
||||
const info = { startedAt, what: `Reading ${fmtBytes(t.length)} from ${fmtAddr(t.start)}`, bytes: t.length };
|
||||
setBusy(true);
|
||||
setFraction(null);
|
||||
setCanceling(false);
|
||||
setRun({ ...info, fraction: null });
|
||||
const controller = new AbortController();
|
||||
stop.current = controller;
|
||||
try {
|
||||
const t = await target();
|
||||
const taken = await takeSnapshot(() => session.main, game.pid, t.start, t.length, setFraction, controller.signal);
|
||||
const taken = await takeSnapshot(
|
||||
() => session.main,
|
||||
game.pid,
|
||||
t.start,
|
||||
t.length,
|
||||
(fraction) => setRun({ ...info, fraction }),
|
||||
controller.signal,
|
||||
);
|
||||
if (!first) {
|
||||
setFirst(taken);
|
||||
} else {
|
||||
@@ -82,11 +127,21 @@ export function RegionCompare(props: { switcher: ReactNode; scope: ScopeControl;
|
||||
setLatest(taken);
|
||||
}
|
||||
} catch (e) {
|
||||
setError(errorText(e));
|
||||
const keptWords = !first
|
||||
? 'No snapshot was taken.'
|
||||
: latest
|
||||
? 'Your earlier snapshots are unchanged; the comparison below is still the last one.'
|
||||
: 'Your first snapshot is still there.';
|
||||
if (e instanceof SnapshotCanceledError) setFeedback({ tone: 'warn', title: `Canceled. ${keptWords}`, tips: [] });
|
||||
else {
|
||||
const f = failedFeedback(true, errorText(e), null, await gameGone(session, game));
|
||||
setFeedback({ ...f, title: f.title.replace('The search', 'Reading the memory'), tips: [keptWords, ...f.tips.slice(1)] });
|
||||
}
|
||||
} finally {
|
||||
stop.current = null;
|
||||
working.current = false;
|
||||
setBusy(false);
|
||||
setFraction(null);
|
||||
setCanceling(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -96,6 +151,7 @@ export function RegionCompare(props: { switcher: ReactNode; scope: ScopeControl;
|
||||
setLatest(null);
|
||||
setAgainst('first');
|
||||
setError(null);
|
||||
setFeedback(null);
|
||||
};
|
||||
|
||||
const kind = valueKind(kindId);
|
||||
@@ -119,17 +175,17 @@ export function RegionCompare(props: { switcher: ReactNode; scope: ScopeControl;
|
||||
{props.switcher}
|
||||
<CardTitle>See what changed</CardTitle>
|
||||
<CardDescription>
|
||||
Take a snapshot of one area of memory, do something in the game, then compare. Good for exploring around a value you
|
||||
already found: open its address here and watch what else moves with it.
|
||||
Take a snapshot of one area of memory, do something in the game, then compare. Good for exploring around a value you already found: open its address here and
|
||||
watch what else moves with it.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
{!first ? (
|
||||
<div className="grid gap-3 sm:grid-cols-[1fr_1fr_150px]">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="cmp-where">Area</Label>
|
||||
<Label htmlFor={`${uid}cmp-where`}>Area</Label>
|
||||
<Select value={where} disabled={busy} onValueChange={setWhere}>
|
||||
<SelectTrigger id="cmp-where" className="w-full">
|
||||
<SelectTrigger id={`${uid}cmp-where`} className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -145,25 +201,28 @@ export function RegionCompare(props: { switcher: ReactNode; scope: ScopeControl;
|
||||
{where === AROUND && (
|
||||
<>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="cmp-address">Address</Label>
|
||||
<Label htmlFor={`${uid}cmp-address`}>Address</Label>
|
||||
<Input
|
||||
id="cmp-address"
|
||||
id={`${uid}cmp-address`}
|
||||
className="font-mono"
|
||||
placeholder="0x880012340"
|
||||
autoCapitalize="off"
|
||||
spellCheck={false}
|
||||
value={address}
|
||||
disabled={busy}
|
||||
onChange={(e) => setAddress(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setAddress(e.target.value);
|
||||
setError(null);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !busy) void snap();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="cmp-size">How much</Label>
|
||||
<Label htmlFor={`${uid}cmp-size`}>How much</Label>
|
||||
<Select value={size} disabled={busy} onValueChange={setSize}>
|
||||
<SelectTrigger id="cmp-size" className="w-full">
|
||||
<SelectTrigger id={`${uid}cmp-size`} className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -180,8 +239,8 @@ export function RegionCompare(props: { switcher: ReactNode; scope: ScopeControl;
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm">
|
||||
Comparing <strong>{fmtBytes(first.bytes.length)}</strong> from <span className="font-mono">{fmtAddr(first.start)}</span>. First
|
||||
snapshot taken at {clock(first.takenAt)}
|
||||
Comparing <strong>{fmtBytes(first.bytes.length)}</strong> from <span className="font-mono">{fmtAddr(first.start)}</span>. First snapshot taken at{' '}
|
||||
{clock(first.takenAt)}
|
||||
{latest ? `, latest at ${clock(latest.takenAt)}.` : '. Now do something in the game, then compare.'}
|
||||
</p>
|
||||
)}
|
||||
@@ -191,8 +250,15 @@ export function RegionCompare(props: { switcher: ReactNode; scope: ScopeControl;
|
||||
{busy ? 'Reading…' : !first ? 'Take a snapshot' : latest ? 'Compare again' : 'Compare'}
|
||||
</Button>
|
||||
{busy && (
|
||||
<Button variant="outline" onClick={() => stop.current?.abort()}>
|
||||
Cancel
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={canceling}
|
||||
onClick={() => {
|
||||
setCanceling(true);
|
||||
stop.current?.abort();
|
||||
}}
|
||||
>
|
||||
{canceling ? 'Canceling…' : 'Cancel'}
|
||||
</Button>
|
||||
)}
|
||||
{first && !busy && (
|
||||
@@ -202,16 +268,25 @@ export function RegionCompare(props: { switcher: ReactNode; scope: ScopeControl;
|
||||
)}
|
||||
</div>
|
||||
|
||||
{busy && <PelletTrack fraction={fraction} label="Reading memory" />}
|
||||
{busy && (
|
||||
<SearchProgress
|
||||
label="Reading memory"
|
||||
startedAt={run.startedAt}
|
||||
what={run.what}
|
||||
canceling={canceling}
|
||||
progress={run.fraction === null ? null : { fraction: run.fraction, done: Math.round(run.fraction * run.bytes), total: run.bytes, unit: 'bytes' }}
|
||||
/>
|
||||
)}
|
||||
{error && <Notice kind="error">{error}</Notice>}
|
||||
{feedback && !busy && <FeedbackNotice feedback={feedback} />}
|
||||
|
||||
{diff && (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-[170px_1fr_190px]">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="cmp-kind">Read values as</Label>
|
||||
<Label htmlFor={`${uid}cmp-kind`}>Read values as</Label>
|
||||
<Select value={kindId} onValueChange={setKindId}>
|
||||
<SelectTrigger id="cmp-kind" className="w-full">
|
||||
<SelectTrigger id={`${uid}cmp-kind`} className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -234,9 +309,9 @@ export function RegionCompare(props: { switcher: ReactNode; scope: ScopeControl;
|
||||
</Tabs>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="cmp-against">Compared with</Label>
|
||||
<Label htmlFor={`${uid}cmp-against`}>Compared with</Label>
|
||||
<Select value={against} onValueChange={(v) => setAgainst(v as 'first' | 'previous')}>
|
||||
<SelectTrigger id="cmp-against" className="w-full">
|
||||
<SelectTrigger id={`${uid}cmp-against`} className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -258,8 +333,8 @@ export function RegionCompare(props: { switcher: ReactNode; scope: ScopeControl;
|
||||
</div>
|
||||
{diff.total === 0 && (
|
||||
<Notice kind="warn">
|
||||
Nothing here {filter === 'changed' ? 'changed' : filter === 'up' ? 'went up' : 'went down'}. Do something in the game
|
||||
that should affect this area and compare again, or try another type.
|
||||
Nothing here {filter === 'changed' ? 'changed' : filter === 'up' ? 'went up' : 'went down'}. Do something in the game that should affect this area and
|
||||
compare again, or try another type.
|
||||
</Notice>
|
||||
)}
|
||||
</>
|
||||
@@ -291,9 +366,7 @@ export function RegionCompare(props: { switcher: ReactNode; scope: ScopeControl;
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onSelect={() => wb.showMemory(r.address)}>Show in Memory</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => void navigator.clipboard?.writeText(fmtAddr(r.address)).catch(() => undefined)}>
|
||||
Copy address
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => copy(fmtAddr(r.address))}>Copy address</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</li>
|
||||
@@ -302,6 +375,7 @@ export function RegionCompare(props: { switcher: ReactNode; scope: ScopeControl;
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
{toast}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+588
-324
File diff suppressed because it is too large.
Load diff
+54
-29
@@ -16,7 +16,7 @@ import { Switch } from '@/components/ui/switch';
|
||||
import { Spinner } from '@/components/kibo-ui/spinner';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { fmtAddr } from '../lib/bytes.ts';
|
||||
import { parseAddress } from '../lib/locations.ts';
|
||||
import { parseAddress, readableAt } from '../lib/locations.ts';
|
||||
import { valueEntry } from '../lib/make.ts';
|
||||
import { valueKind } from '../lib/scanner.ts';
|
||||
import { canBeWide, compareStructures, guessAsValue, guessLabel, readStructure, withPinnedKinds } from '../lib/structure.ts';
|
||||
@@ -53,6 +53,10 @@ export function StructureView(props: { visible: boolean }) {
|
||||
const [changed, setChanged] = useState<Set<number>>(new Set());
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
/** What was wrong with the last thing asked for. Kept apart from `error`, which every successful refresh clears. */
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
/** Objects at addresses where the game has no memory; they read as all zeros. */
|
||||
const [missing, setMissing] = useState<bigint[]>([]);
|
||||
const [className, setClassName] = useState<string | null>(null);
|
||||
const [engineFields, setEngineFields] = useState<FieldLabel[]>([]);
|
||||
const last = useRef<Map<number, string>>(new Map());
|
||||
@@ -65,6 +69,8 @@ export function StructureView(props: { visible: boolean }) {
|
||||
setChanged(new Set());
|
||||
last.current = new Map();
|
||||
setError(null);
|
||||
setFormError(null);
|
||||
setMissing([]);
|
||||
};
|
||||
|
||||
// Another tab (or the access list) asked to look at these.
|
||||
@@ -134,6 +140,12 @@ export function StructureView(props: { visible: boolean }) {
|
||||
setCompared(c);
|
||||
setSingle(null);
|
||||
}
|
||||
if (firstRound) {
|
||||
const gone: bigint[] = [];
|
||||
for (const o of objects) if ((await readableAt(ctx, o, 8, true)) === 'none') gone.push(o);
|
||||
if (stopped) return;
|
||||
setMissing(gone);
|
||||
}
|
||||
const diff = new Set<number>();
|
||||
if (!firstRound) for (const [off, value] of now) if (last.current.has(off) && last.current.get(off) !== value) diff.add(off);
|
||||
last.current = now;
|
||||
@@ -155,7 +167,7 @@ export function StructureView(props: { visible: boolean }) {
|
||||
}, [props.visible, objectKey, sideKey, size, fields, ctx]);
|
||||
|
||||
const add = async () => {
|
||||
setError(null);
|
||||
setFormError(null);
|
||||
try {
|
||||
const address = await parseAddress(ctx, text);
|
||||
if (objects.includes(address)) throw new Error('That object is already shown.');
|
||||
@@ -164,12 +176,12 @@ export function StructureView(props: { visible: boolean }) {
|
||||
if (objects.length === 1) setOnlyDiff(true);
|
||||
setText('');
|
||||
} catch (e) {
|
||||
setError(errorText(e));
|
||||
setFormError(errorText(e));
|
||||
}
|
||||
};
|
||||
|
||||
const save = async (address: bigint, guess: Guess, name: string) => {
|
||||
setError(null);
|
||||
setFormError(null);
|
||||
try {
|
||||
const v = guessAsValue(guess);
|
||||
if (!v) throw new Error('Text cannot be held by a cheat. Change the row\'s type to a number first.');
|
||||
@@ -177,7 +189,7 @@ export function StructureView(props: { visible: boolean }) {
|
||||
await wb.addEntry(entry, { enable: false });
|
||||
wb.edit(entry, false);
|
||||
} catch (e) {
|
||||
setError(errorText(e));
|
||||
setFormError(errorText(e));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -250,7 +262,14 @@ export function StructureView(props: { visible: boolean }) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{formError && <Notice kind="error">{formError}</Notice>}
|
||||
{error && <Notice kind="error">{error}</Notice>}
|
||||
{missing.length > 0 && (
|
||||
<Notice kind="warn">
|
||||
The game has no memory at {missing.map((m) => fmtAddr(m)).join(', ')}. {missing.length === 1 ? 'It reads' : 'They read'} as nothing but zeros,
|
||||
which is not an object. Check the address.
|
||||
</Notice>
|
||||
)}
|
||||
|
||||
{objects.length > 0 && (
|
||||
<ul className="flex flex-wrap gap-2" aria-label="Objects shown">
|
||||
@@ -308,8 +327,10 @@ export function StructureView(props: { visible: boolean }) {
|
||||
<tr className="text-left text-xs text-muted-foreground">
|
||||
<th className="py-1 pr-3 font-normal">Offset</th>
|
||||
{named && <th className="py-1 pr-3 font-normal">Name</th>}
|
||||
<th className="py-1 pr-3 font-normal">Read as</th>
|
||||
<th className="py-1 pr-3 font-normal">Value</th>
|
||||
<th className="py-1 pr-3 font-normal max-sm:hidden">Read as</th>
|
||||
<th className="py-1 pr-3 font-normal">
|
||||
Value<span className="sm:hidden">, and what it is read as</span>
|
||||
</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -384,7 +405,7 @@ export function StructureView(props: { visible: boolean }) {
|
||||
{row.verdict === 'groups' && <span className="mr-1 text-xs font-semibold text-primary">tells them apart</span>}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon-xs" aria-label={`Actions for the field at ${hexOff(row.offset)}`}>
|
||||
<Button variant="ghost" size="icon-xs" className="max-sm:size-8" aria-label={`Actions for the field at ${hexOff(row.offset)}`}>
|
||||
<MoreHorizontalIcon />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
@@ -407,6 +428,7 @@ export function StructureView(props: { visible: boolean }) {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{compared.objects.length > 3 && <p className="text-xs text-muted-foreground sm:hidden">Swipe the table sideways to see every object.</p>}
|
||||
{shownRows.length === 0 && <Notice>These objects are identical in the part that was read. Read more of them, or check they are different objects.</Notice>}
|
||||
</>
|
||||
)}
|
||||
@@ -430,36 +452,39 @@ function FieldRow(props: {
|
||||
const g = row.guesses[0];
|
||||
const other = row.guesses[1];
|
||||
const target = g.kind === 'pointer' ? g.target : undefined;
|
||||
const readAs = (className: string) => (
|
||||
<Select value={props.pinned} onValueChange={(v) => props.onPin(v as PinnedKind | 'auto')}>
|
||||
<SelectTrigger size="sm" className={className} aria-label={`How to read the field at ${hexOff(row.offset)}`}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="auto">{guessLabel(g)} (guess)</SelectItem>
|
||||
<SelectItem value="int">Whole number</SelectItem>
|
||||
<SelectItem value="float">Decimal</SelectItem>
|
||||
{props.wide && <SelectItem value="double">Decimal, 8 bytes</SelectItem>}
|
||||
{props.wide && <SelectItem value="pointer">Pointer</SelectItem>}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
return (
|
||||
<tr className="border-b last:border-0">
|
||||
<td className="py-1 pr-3 font-mono whitespace-nowrap text-muted-foreground">{hexOff(row.offset)}</td>
|
||||
{props.named && <td className="py-1 pr-3 break-words">{row.field?.name}</td>}
|
||||
<td className="py-1 pr-3">
|
||||
<Select value={props.pinned} onValueChange={(v) => props.onPin(v as PinnedKind | 'auto')}>
|
||||
<SelectTrigger size="sm" className="h-7 w-[7.5rem] text-xs sm:w-[9.5rem]" aria-label={`How to read the field at ${hexOff(row.offset)}`}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="auto">{guessLabel(g)} (guess)</SelectItem>
|
||||
<SelectItem value="int">Whole number</SelectItem>
|
||||
<SelectItem value="float">Decimal</SelectItem>
|
||||
{props.wide && <SelectItem value="double">Decimal, 8 bytes</SelectItem>}
|
||||
{props.wide && <SelectItem value="pointer">Pointer</SelectItem>}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</td>
|
||||
<td className="max-w-[16rem] py-1 pr-3">
|
||||
<span className={cn('block font-mono break-words', props.changed && 'text-primary')}>{g.text}</span>
|
||||
<tr className="border-b align-top last:border-0 sm:align-middle">
|
||||
<td className="py-1 pr-3 font-mono whitespace-nowrap text-muted-foreground max-sm:pt-1.5">{hexOff(row.offset)}</td>
|
||||
{props.named && <td className="py-1 pr-3 break-words max-sm:pt-1.5">{row.field?.name}</td>}
|
||||
<td className="py-1 pr-3 max-sm:hidden">{readAs('h-7 w-[9.5rem] text-xs')}</td>
|
||||
<td className="py-1 pr-3 sm:max-w-[16rem]">
|
||||
<span className={cn('block font-mono break-all', props.changed && 'text-primary')}>{g.text}</span>
|
||||
{props.pinned === 'auto' && other && other.kind !== g.kind && g.kind !== 'pointer' && !isEmpty(row) && (
|
||||
<span className="block truncate text-xs text-muted-foreground">
|
||||
<span className="block text-xs break-all text-muted-foreground sm:truncate">
|
||||
or {other.text} as {guessLabel(other).toLowerCase()}
|
||||
</span>
|
||||
)}
|
||||
{/* A phone has no room for a column of its own, so the choice sits under the value. */}
|
||||
<span className="mt-1 block sm:hidden">{readAs('h-8 w-full max-w-[11rem] text-xs')}</span>
|
||||
</td>
|
||||
<td className="py-0.5 text-right">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon-xs" aria-label={`Actions for the field at ${hexOff(row.offset)}`}>
|
||||
<Button variant="ghost" size="icon-xs" className="max-sm:size-8" aria-label={`Actions for the field at ${hexOff(row.offset)}`}>
|
||||
<MoreHorizontalIcon />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
+127
-62
@@ -1,7 +1,7 @@
|
||||
// Find tab, "Text" mode: look for text (names, menu labels, messages) in the
|
||||
// game's memory. The searching itself is in lib/textsearch.ts.
|
||||
|
||||
import { useRef, useState } from 'react';
|
||||
import { useEffect, useId, useRef, useState } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { MoreHorizontalIcon } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -10,10 +10,13 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { fmtAddr, fmtBytes } from '../lib/bytes.ts';
|
||||
import { TEXT_ENCODING_LABELS, searchText } from '../lib/textsearch.ts';
|
||||
import { fmtAddr } from '../lib/bytes.ts';
|
||||
import { coverageWords, failedFeedback, textFeedback } from '../lib/findfeedback.ts';
|
||||
import type { Feedback, TextQuery } from '../lib/findfeedback.ts';
|
||||
import { TEXT_ENCODING_LABELS, searchText, textNeedles } from '../lib/textsearch.ts';
|
||||
import type { TextEncoding, TextProgress, TextSearchResult } from '../lib/textsearch.ts';
|
||||
import { Notice, PelletTrack, errorText } from './bits.tsx';
|
||||
import { Notice, errorText } from './bits.tsx';
|
||||
import { FeedbackNotice, SearchProgress, gameGone, useCopy, useProgress } from './findbits.tsx';
|
||||
import { WhereToSearch } from './WhereToSearch.tsx';
|
||||
import type { ScopeControl } from './WhereToSearch.tsx';
|
||||
import { useWorkbench } from './workbench.tsx';
|
||||
@@ -23,29 +26,66 @@ const MAX_HITS = 200;
|
||||
export function TextSearch(props: { switcher: ReactNode; scope: ScopeControl; onPointsTo: (address: bigint) => void }) {
|
||||
const wb = useWorkbench();
|
||||
const { session, game } = wb;
|
||||
// This tool can be on the page twice (a lesson's practice game in front of the real one), so its ids are per copy.
|
||||
const uid = useId();
|
||||
const [text, setText] = useState('');
|
||||
const [plain, setPlain] = useState(true);
|
||||
const [wide, setWide] = useState(true);
|
||||
const [ignoreCase, setIgnoreCase] = useState(true);
|
||||
const [fixed, setFixed] = useState(false);
|
||||
const [running, setRunning] = useState(false);
|
||||
const [scopeText, setScopeText] = useState('');
|
||||
const [progress, setProgress] = useState<TextProgress | null>(null);
|
||||
const [canceling, setCanceling] = useState(false);
|
||||
const [runInfo, setRunInfo] = useState({ startedAt: 0, what: '' });
|
||||
const [progress, setProgress] = useProgress<TextProgress>();
|
||||
const [result, setResult] = useState<TextSearchResult | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
/** Something wrong with what was typed or chosen, shown next to it. */
|
||||
const [formError, setFormError] = useState<{ field: 'text' | 'where'; text: string } | null>(null);
|
||||
/** How the last search ended. Shown where the results are. */
|
||||
const [feedback, setFeedback] = useState<Feedback | null>(null);
|
||||
const stop = useRef<AbortController | null>(null);
|
||||
const busy = useRef(false);
|
||||
const textInput = useRef<HTMLInputElement | null>(null);
|
||||
const { copy, toast } = useCopy();
|
||||
|
||||
// A complaint about "Where to search" goes away as soon as that choice is changed.
|
||||
const scopeChoice = props.scope.choice;
|
||||
useEffect(() => {
|
||||
setFormError((e) => (e?.field === 'where' ? null : e));
|
||||
}, [scopeChoice]);
|
||||
|
||||
const run = async () => {
|
||||
setError(null);
|
||||
if (busy.current) return;
|
||||
setFormError(null);
|
||||
const encodings: TextEncoding[] = [...(plain ? (['utf8'] as const) : []), ...(wide ? (['utf16'] as const) : [])];
|
||||
const query: TextQuery = { text, plain, wide, ignoreCase, fixed };
|
||||
try {
|
||||
textNeedles(text, { encodings, ignoreCase });
|
||||
} catch (e) {
|
||||
setFormError({ field: 'text', text: errorText(e) });
|
||||
textInput.current?.focus();
|
||||
return;
|
||||
}
|
||||
busy.current = true;
|
||||
const startedAt = Date.now();
|
||||
setRunning(true);
|
||||
setCanceling(false);
|
||||
setProgress(null);
|
||||
setScopeText('');
|
||||
// The last search's results go now, so they cannot be mistaken for this one's.
|
||||
setResult(null);
|
||||
setFeedback(null);
|
||||
setRunInfo({ startedAt, what: "Reading the list of the game's memory…" });
|
||||
const controller = new AbortController();
|
||||
stop.current = controller;
|
||||
try {
|
||||
const encodings: TextEncoding[] = [...(plain ? (['utf8'] as const) : []), ...(wide ? (['utf16'] as const) : [])];
|
||||
const where = await props.scope.resolve({ fixed });
|
||||
setScopeText(`Searching ${fmtBytes(where.bytes)} of ${fmtBytes(where.total)}`);
|
||||
let where: Awaited<ReturnType<ScopeControl['resolve']>>;
|
||||
try {
|
||||
where = await props.scope.resolve({ fixed });
|
||||
} catch (e) {
|
||||
setFormError({ field: 'where', text: errorText(e) });
|
||||
return;
|
||||
}
|
||||
const cover = { bytes: where.bytes, total: where.total };
|
||||
setRunInfo({ startedAt, what: `Looking for "${text}" in ${coverageWords(cover)}` });
|
||||
const res = await searchText({
|
||||
dbg: () => session.scan,
|
||||
pid: game.pid,
|
||||
@@ -58,11 +98,17 @@ export function TextSearch(props: { switcher: ReactNode; scope: ScopeControl; on
|
||||
onProgress: setProgress,
|
||||
});
|
||||
setResult(res);
|
||||
// A game that has just closed reads as empty memory, which looks exactly like "nothing found".
|
||||
if (res.hits.length === 0 && !res.canceled && (await gameGone(session, game)))
|
||||
setFeedback(failedFeedback(true, 'The game closed while the search was running', null, true));
|
||||
else setFeedback(textFeedback(query, { ...res, hits: res.hits.length }, cover, Date.now() - startedAt));
|
||||
} catch (e) {
|
||||
setError(errorText(e));
|
||||
setFeedback(failedFeedback(true, errorText(e), null, await gameGone(session, game)));
|
||||
} finally {
|
||||
stop.current = null;
|
||||
busy.current = false;
|
||||
setRunning(false);
|
||||
setCanceling(false);
|
||||
setProgress(null);
|
||||
}
|
||||
};
|
||||
@@ -80,96 +126,116 @@ export function TextSearch(props: { switcher: ReactNode; scope: ScopeControl; on
|
||||
<CardHeader>
|
||||
{props.switcher}
|
||||
<CardTitle>Find text</CardTitle>
|
||||
<CardDescription>
|
||||
Type a word or name you can see in the game, such as an item name or a menu label, to find where the game keeps it.
|
||||
</CardDescription>
|
||||
<CardDescription>Type a word or name you can see in the game, such as an item name or a menu label, to find where the game keeps it.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="text-needle">Text</Label>
|
||||
<Label htmlFor={`${uid}text-needle`}>Text</Label>
|
||||
<Input
|
||||
id="text-needle"
|
||||
id={`${uid}text-needle`}
|
||||
placeholder="New Game"
|
||||
autoCapitalize="off"
|
||||
autoCorrect="off"
|
||||
spellCheck={false}
|
||||
ref={textInput}
|
||||
aria-invalid={formError?.field === 'text' || undefined}
|
||||
aria-describedby={formError?.field === 'text' ? `${uid}text-needle-error` : undefined}
|
||||
value={text}
|
||||
disabled={running}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setText(e.target.value);
|
||||
if (formError?.field === 'text') setFormError(null);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !running) void run();
|
||||
}}
|
||||
/>
|
||||
{formError?.field === 'text' && (
|
||||
<p id={`${uid}text-needle-error`} role="alert" className="text-sm text-destructive">
|
||||
{formError.text}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{check('text-plain', <>Plain text <span className="text-muted-foreground">(one byte per letter)</span></>, plain, setPlain)}
|
||||
{check('text-wide', <>Wide text <span className="text-muted-foreground">(two bytes per letter; Unreal Engine games)</span></>, wide, setWide)}
|
||||
{check('text-case', 'Ignore capitals', ignoreCase, setIgnoreCase)}
|
||||
{check(
|
||||
'text-fixed',
|
||||
<>Also look in the game's code and fixed data <span className="text-muted-foreground">(built-in names and menu text are usually there)</span></>,
|
||||
`${uid}text-plain`,
|
||||
<>
|
||||
Plain text <span className="text-muted-foreground">(one byte per letter)</span>
|
||||
</>,
|
||||
plain,
|
||||
setPlain,
|
||||
)}
|
||||
{check(
|
||||
`${uid}text-wide`,
|
||||
<>
|
||||
Wide text <span className="text-muted-foreground">(two bytes per letter; Unreal Engine games)</span>
|
||||
</>,
|
||||
wide,
|
||||
setWide,
|
||||
)}
|
||||
{check(`${uid}text-case`, 'Ignore capitals', ignoreCase, setIgnoreCase)}
|
||||
{check(
|
||||
`${uid}text-fixed`,
|
||||
<>
|
||||
Also look in the game's code and fixed data <span className="text-muted-foreground">(built-in names and menu text are usually there)</span>
|
||||
</>,
|
||||
fixed,
|
||||
setFixed,
|
||||
)}
|
||||
</div>
|
||||
|
||||
<WhereToSearch scope={props.scope} fixed={fixed} disabled={running} />
|
||||
{formError?.field === 'where' && <Notice kind="error">{formError.text}</Notice>}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button disabled={running} onClick={() => void run()}>
|
||||
{running ? 'Searching…' : 'Search'}
|
||||
</Button>
|
||||
{running && (
|
||||
<Button variant="outline" onClick={() => stop.current?.abort()}>
|
||||
Cancel
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={canceling}
|
||||
onClick={() => {
|
||||
setCanceling(true);
|
||||
stop.current?.abort();
|
||||
}}
|
||||
>
|
||||
{canceling ? 'Canceling…' : 'Cancel'}
|
||||
</Button>
|
||||
)}
|
||||
{result && !running && (
|
||||
<span className="text-sm text-muted-foreground max-sm:basis-full" aria-live="polite">
|
||||
<strong className="text-foreground">{result.hits.length.toLocaleString()}</strong> {result.hits.length === 1 ? 'place' : 'places'} found
|
||||
<strong className="text-foreground">{result.hits.length.toLocaleString('en-US')}</strong> {result.hits.length === 1 ? 'place' : 'places'} found
|
||||
{result.capped ? ' (stopped there)' : ''}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{running && (
|
||||
<div>
|
||||
<PelletTrack fraction={progress ? progress.fraction : null} label="Text search progress" />
|
||||
<p className="text-sm text-muted-foreground" aria-live="polite">
|
||||
{scopeText || 'Reading the memory map…'}
|
||||
{progress ? ` · ${progress.hits.toLocaleString()} found so far` : ''}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <Notice kind="error">{error}</Notice>}
|
||||
{result && !running && result.canceled && (
|
||||
<Notice kind="warn">Canceled after {fmtBytes(result.bytesSearched)}. What was found up to then is listed below.</Notice>
|
||||
)}
|
||||
{result && !running && result.capped && (
|
||||
<Notice kind="warn">
|
||||
Stopped at the first {MAX_HITS} places; there may be more. Type a longer piece of the text, or search less memory, to
|
||||
get a shorter list.
|
||||
</Notice>
|
||||
)}
|
||||
{result && !running && !result.allCases && (
|
||||
<Notice>
|
||||
Looked for these spellings: {result.spellings.map((s) => `"${s}"`).join(', ')}. Other mixes of capitals are not found. If
|
||||
you know the exact spelling, type it that way.
|
||||
</Notice>
|
||||
)}
|
||||
{result && !running && result.hits.length === 0 && !result.canceled && (
|
||||
<Notice kind="warn">
|
||||
Not found. The game may spell it differently or build it from pieces, so try one distinctive word.
|
||||
{!fixed && " Text that never changes is often in the game's fixed data: check that box and search again."}
|
||||
</Notice>
|
||||
<SearchProgress
|
||||
label="Text search progress"
|
||||
startedAt={runInfo.startedAt}
|
||||
what={runInfo.what}
|
||||
canceling={canceling}
|
||||
progress={
|
||||
progress && {
|
||||
fraction: progress.fraction,
|
||||
done: progress.done,
|
||||
total: progress.total,
|
||||
unit: 'bytes',
|
||||
found: `${progress.hits.toLocaleString('en-US')} found so far`,
|
||||
}
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{result && result.hits.length > 0 && (
|
||||
<Card>
|
||||
{feedback && !running && <FeedbackNotice feedback={feedback} />}
|
||||
|
||||
{result && result.hits.length > 0 && !running && (
|
||||
<Card aria-label="Results">
|
||||
<CardContent>
|
||||
<ul className="divide-y">
|
||||
{result.hits.map((h) => (
|
||||
@@ -196,9 +262,7 @@ export function TextSearch(props: { switcher: ReactNode; scope: ScopeControl; on
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onSelect={() => wb.showMemory(h.address)}>Show in Memory</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => props.onPointsTo(h.address)}>Find what points to this</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => void navigator.clipboard?.writeText(fmtAddr(h.address)).catch(() => undefined)}>
|
||||
Copy address
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => copy(fmtAddr(h.address))}>Copy address</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</li>
|
||||
@@ -207,6 +271,7 @@ export function TextSearch(props: { switcher: ReactNode; scope: ScopeControl; on
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
{toast}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+14
-13
@@ -1,7 +1,7 @@
|
||||
// "Where to search": which parts of the game's memory a search covers. Shared
|
||||
// by the number, text and group searches, and remembered per game.
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react';
|
||||
import { ChevronDownIcon } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -94,6 +94,8 @@ export function WhereToSearch(props: { scope: ScopeControl; fixed?: boolean; dis
|
||||
const { scope, fixed, disabled } = props;
|
||||
const { choice, items } = scope;
|
||||
const [open, setOpen] = useState(false);
|
||||
// This tool can be on the page twice (a lesson's practice game in front of the real one), so its ids are per copy.
|
||||
const uid = useId();
|
||||
|
||||
const shown = useMemo(() => (items ?? []).filter((i) => i.writable || fixed), [items, fixed]);
|
||||
const summary = useMemo(() => {
|
||||
@@ -122,7 +124,7 @@ export function WhereToSearch(props: { scope: ScopeControl; fixed?: boolean; dis
|
||||
: choice.mode === 'all'
|
||||
? `Everything (${fmtBytes(summary.total)})`
|
||||
: summary.error
|
||||
? 'Address range (not set yet)'
|
||||
? 'Address range: not set'
|
||||
: `${fmtBytes(summary.bytes)} of ${fmtBytes(summary.total)}`;
|
||||
|
||||
return (
|
||||
@@ -161,8 +163,8 @@ export function WhereToSearch(props: { scope: ScopeControl; fixed?: boolean; dis
|
||||
|
||||
{choice.mode === 'all' && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
All the memory the game can change. This always works, but most of a big game's memory is pictures and sound. If
|
||||
searches are slow, choose parts and uncheck the biggest blocks.
|
||||
All the memory the game can change. This always works, but most of a big game's memory is pictures and sound. If searches are slow, choose parts and uncheck
|
||||
the biggest blocks.
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -178,11 +180,12 @@ export function WhereToSearch(props: { scope: ScopeControl; fixed?: boolean; dis
|
||||
Uncheck blocks over {fmtBytes(HUGE_BYTES)}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" disabled={choice.off.length === 0} onClick={() => scope.setChoice({ ...choice, off: [] })}>
|
||||
Tick everything
|
||||
Check everything
|
||||
</Button>
|
||||
</div>
|
||||
{!items && <p className="text-sm text-muted-foreground">{scope.loadError ?? 'Reading the memory map…'}</p>}
|
||||
<div className="-mx-1 max-h-72 overflow-y-auto px-1">
|
||||
{/* A box of its own, cut off mid-row when there is more, so it reads as a list that scrolls. */}
|
||||
<div className="max-h-72 overflow-y-auto rounded-md border px-2 py-1" role="group" aria-label="Parts of the game's memory">
|
||||
{GROUPS.map((group) => {
|
||||
const rows = shown.filter((i) => i.group === group);
|
||||
if (rows.length === 0) return null;
|
||||
@@ -214,9 +217,9 @@ export function WhereToSearch(props: { scope: ScopeControl; fixed?: boolean; dis
|
||||
{choice.mode === 'range' && (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="scope-from">From address</Label>
|
||||
<Label htmlFor={`${uid}scope-from`}>From address</Label>
|
||||
<Input
|
||||
id="scope-from"
|
||||
id={`${uid}scope-from`}
|
||||
className="font-mono"
|
||||
placeholder="0x880000000"
|
||||
autoCapitalize="off"
|
||||
@@ -226,9 +229,9 @@ export function WhereToSearch(props: { scope: ScopeControl; fixed?: boolean; dis
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="scope-to">To address</Label>
|
||||
<Label htmlFor={`${uid}scope-to`}>To address</Label>
|
||||
<Input
|
||||
id="scope-to"
|
||||
id={`${uid}scope-to`}
|
||||
className="font-mono"
|
||||
placeholder="0x880040000"
|
||||
autoCapitalize="off"
|
||||
@@ -237,9 +240,7 @@ export function WhereToSearch(props: { scope: ScopeControl; fixed?: boolean; dis
|
||||
onChange={(e) => scope.setChoice({ ...choice, to: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<p className="col-span-2 text-sm text-muted-foreground">
|
||||
Addresses as shown in the Memory tab. Only memory that exists between the two is searched.
|
||||
</p>
|
||||
<p className="col-span-2 text-sm text-muted-foreground">Addresses as shown in the Memory tab. Only memory that exists between the two is searched.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
+5
-2
@@ -122,7 +122,9 @@ function WizardBody(props: { onClose: () => void }) {
|
||||
aria-describedby={undefined}
|
||||
// A stray tap beside the sheet must not throw away a search that took minutes.
|
||||
onInteractOutside={(e) => e.preventDefault()}
|
||||
className="top-0 left-0 flex h-dvh max-w-none translate-x-0 translate-y-0 flex-col gap-0 rounded-none border-0 p-0 sm:top-[50%] sm:left-[50%] sm:h-[min(46rem,92dvh)] sm:max-w-md sm:translate-x-[-50%] sm:translate-y-[-50%] sm:rounded-lg sm:border"
|
||||
// The dialog's own height cap and scrolling are for ordinary dialogs; this sheet fills the screen and scrolls its middle part only.
|
||||
// It becomes a centered card only on a screen that is both wide and tall: a phone on its side is wide but far too short for one.
|
||||
className="top-0 left-0 flex h-dvh max-h-none max-w-none overflow-hidden sm:max-w-none translate-x-0 translate-y-0 flex-col gap-0 rounded-none border-0 p-0 [@media(min-width:40rem)_and_(min-height:34rem)]:top-[50%] [@media(min-width:40rem)_and_(min-height:34rem)]:left-[50%] [@media(min-width:40rem)_and_(min-height:34rem)]:h-[min(46rem,92dvh)] [@media(min-width:40rem)_and_(min-height:34rem)]:max-w-md [@media(min-width:40rem)_and_(min-height:34rem)]:translate-x-[-50%] [@media(min-width:40rem)_and_(min-height:34rem)]:translate-y-[-50%] [@media(min-width:40rem)_and_(min-height:34rem)]:rounded-lg [@media(min-width:40rem)_and_(min-height:34rem)]:border"
|
||||
>
|
||||
{!session.extended ? (
|
||||
<Unsupported onClose={props.onClose} />
|
||||
@@ -473,7 +475,8 @@ function Screens(props: { flow: FirstCheatFlow; state: WizardState; onClose: ()
|
||||
{state.sameAsBefore ? (
|
||||
<Notice kind="warn">
|
||||
That is the same number as last time, and it has to change for this to work. If the game is paused or in a menu, go back into
|
||||
it, {t.actOnce}, then type the new number.
|
||||
it, {t.actOnce}, then type the new number. If a cheat for your {t.noun} is already switched on, it will not change either: leave
|
||||
the guide, switch that cheat off, and start again.
|
||||
</Notice>
|
||||
) : (
|
||||
<Para className="text-sm text-muted-foreground">
|
||||
|
||||
+43
-2
@@ -1,6 +1,7 @@
|
||||
// Small shared pieces: the ghost, the pellet progress track, notices.
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
import { Component } from 'react';
|
||||
import type { ErrorInfo, ReactNode } from 'react';
|
||||
import { CircleAlertIcon, InfoIcon, TriangleAlertIcon } from 'lucide-react';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -30,7 +31,7 @@ export function Wordmark() {
|
||||
return (
|
||||
<div className="mr-auto flex min-w-0 items-center gap-2.5">
|
||||
<Ghost size={30} look />
|
||||
<h1 className="text-xl font-bold tracking-tight">
|
||||
<h1 className="truncate text-lg font-bold tracking-tight sm:text-xl">
|
||||
Cheat<span className="text-primary">Specter</span>
|
||||
</h1>
|
||||
</div>
|
||||
@@ -87,3 +88,43 @@ export function Notice(props: { kind?: 'error' | 'warn' | 'info'; children: Reac
|
||||
export function errorText(e: unknown): string {
|
||||
return e instanceof Error ? e.message : String(e);
|
||||
}
|
||||
|
||||
/**
|
||||
* Catches a mistake in one part of the page so the rest keeps working. Without
|
||||
* it, React takes the whole page down to a blank screen, and whatever is
|
||||
* switched on in the game is left with nothing to switch it off.
|
||||
*/
|
||||
export class Safe extends Component<{ what: string; children: ReactNode }, { error: unknown }> {
|
||||
state: { error: unknown } = { error: null };
|
||||
|
||||
static getDerivedStateFromError(error: unknown): { error: unknown } {
|
||||
return { error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: unknown, info: ErrorInfo): void {
|
||||
console.error(`CheatSpecter: ${this.props.what} stopped working`, error, info.componentStack);
|
||||
}
|
||||
|
||||
render(): ReactNode {
|
||||
if (this.state.error === null) return this.props.children;
|
||||
return (
|
||||
<Notice kind="error">
|
||||
<div className="flex flex-col items-start gap-2">
|
||||
<span>
|
||||
Something went wrong showing {this.props.what}. This is a fault in CheatSpecter, not something you did. Try again; if it keeps
|
||||
happening, reload the page. Cheats that are on stay on in the game either way.
|
||||
</span>
|
||||
<span className="font-mono text-xs break-all opacity-80">{errorText(this.state.error)}</span>
|
||||
<span className="flex flex-wrap gap-2">
|
||||
<button type="button" className="rounded-md border px-3 py-1.5 text-sm font-medium" onClick={() => this.setState({ error: null })}>
|
||||
Try again
|
||||
</button>
|
||||
<button type="button" className="rounded-md border px-3 py-1.5 text-sm font-medium" onClick={() => window.location.reload()}>
|
||||
Reload the page
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
</Notice>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
// Copying to the clipboard, and saying so.
|
||||
//
|
||||
// The page is usually served by the PS5 itself over plain http, where
|
||||
// browsers do not offer `navigator.clipboard` at all. A menu item that
|
||||
// quietly does nothing there looks broken, so this falls back to the old
|
||||
// select-and-copy way, and every copy ends with a short line on screen that
|
||||
// says what happened.
|
||||
|
||||
const SHOWN_MS = 2500;
|
||||
const SHOWN_FAILED_MS = 9000;
|
||||
|
||||
let line: HTMLDivElement | null = null;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
/** A short line at the bottom of the window that goes away by itself. One at a time; a new one replaces the old. */
|
||||
export function say(text: string, ms = SHOWN_MS): void {
|
||||
if (typeof document === 'undefined') return;
|
||||
if (!line) {
|
||||
line = document.createElement('div');
|
||||
line.setAttribute('role', 'status');
|
||||
line.setAttribute('aria-live', 'polite');
|
||||
// Above dialogs and menus, clear of the phone's home bar; text can be selected, for when copying failed.
|
||||
line.className =
|
||||
'fixed bottom-4 left-1/2 z-[80] w-max max-w-[calc(100%-2rem)] -translate-x-1/2 rounded-md border border-primary/50 bg-background px-3 py-2 text-sm break-all text-foreground shadow-lg select-text pointer-events-auto';
|
||||
}
|
||||
line.textContent = text;
|
||||
if (!line.isConnected) document.body.appendChild(line);
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => {
|
||||
line?.remove();
|
||||
}, ms);
|
||||
}
|
||||
|
||||
/** The way that works without `navigator.clipboard`: put the text in a hidden box, select it, and ask the browser to copy the selection. */
|
||||
function copyBySelection(text: string): boolean {
|
||||
const box = document.createElement('textarea');
|
||||
box.value = text;
|
||||
box.setAttribute('readonly', '');
|
||||
// Kept inside the window so no browser scrolls to it, and big enough type that phones do not zoom in.
|
||||
box.style.cssText = 'position:fixed;top:0;left:0;width:1px;height:1px;padding:0;border:0;opacity:0;font-size:16px;';
|
||||
// An open menu or dialog keeps focus to itself; the box has to be inside it to be selectable.
|
||||
const host = document.querySelector('[role="menu"][data-state="open"]') ?? document.querySelector('[role="dialog"][data-state="open"]') ?? document.body;
|
||||
const before = document.activeElement as HTMLElement | null;
|
||||
host.appendChild(box);
|
||||
let ok = false;
|
||||
try {
|
||||
box.focus();
|
||||
box.select();
|
||||
box.setSelectionRange(0, text.length);
|
||||
ok = document.execCommand('copy');
|
||||
} catch {
|
||||
ok = false;
|
||||
}
|
||||
box.remove();
|
||||
try {
|
||||
before?.focus();
|
||||
} catch {
|
||||
// Whatever had focus is gone; nothing to put back.
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
|
||||
/** Copies `text`. Resolves to whether it worked; never throws. */
|
||||
export async function copyText(text: string): Promise<boolean> {
|
||||
try {
|
||||
if (typeof navigator !== 'undefined' && navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
// Refused (no permission, page not focused): try the other way.
|
||||
}
|
||||
try {
|
||||
return copyBySelection(text);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Copies `text` and says so on screen. `what` names it for the person:
|
||||
* "address", "offset". When the browser will not copy at all, the text
|
||||
* itself is shown for long enough to select or write down.
|
||||
*/
|
||||
export async function copyAndSay(text: string, what = 'text'): Promise<boolean> {
|
||||
const ok = await copyText(text);
|
||||
const short = text.length > 60 ? `${text.slice(0, 57)}...` : text;
|
||||
if (ok) say(`Copied the ${what}: ${short}`);
|
||||
else say(`This browser would not copy. The ${what} is: ${text}`, SHOWN_FAILED_MS);
|
||||
return ok;
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
// Pieces the four Find modes share: honest progress for a running search, the
|
||||
// box that says how a search ended, and "copy address" with its confirmation.
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { CheckIcon, XIcon } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { copyText } from '../lib/clipboard.ts';
|
||||
import { clock, leftWords, ofBytes, timeLeft } from '../lib/findfeedback.ts';
|
||||
import type { Feedback, ProgressSample } from '../lib/findfeedback.ts';
|
||||
import type { Game, Session } from '../lib/session.ts';
|
||||
import { Notice, PelletTrack } from './bits.tsx';
|
||||
|
||||
/** Where a running search is. Every number is real work done, as reported by the search itself. */
|
||||
export interface RunProgress {
|
||||
/** 0..1 */
|
||||
fraction: number;
|
||||
done?: number;
|
||||
total?: number;
|
||||
unit?: 'bytes' | 'candidates' | 'places';
|
||||
/** "1,234 matches so far" */
|
||||
found?: string;
|
||||
/** "Round 2 of 3, as float": shown before the numbers. Time left is worked out afresh when it changes. */
|
||||
stage?: string;
|
||||
/** True when `fraction` covers only the current stage, so the time left does too. */
|
||||
stageOnly?: boolean;
|
||||
}
|
||||
|
||||
/** After a search failed: true when the reason is simply that the game it was searching is no longer running. */
|
||||
export async function gameGone(session: Session, game: Game): Promise<boolean> {
|
||||
const now = await session.currentGame().catch(() => undefined);
|
||||
return now === null || (now !== undefined && now.pid !== game.pid);
|
||||
}
|
||||
|
||||
/**
|
||||
* Progress of a running search as screen state. A search over a real game
|
||||
* reports thousands of times (once per block of memory), and redrawing for
|
||||
* each report would take longer than the searching, so reports that come
|
||||
* faster than the eye can follow are skipped. The last one is never older
|
||||
* than a tenth of a second.
|
||||
*/
|
||||
export function useProgress<T>(): [T | null, (p: T | null) => void] {
|
||||
const [shown, setShown] = useState<T | null>(null);
|
||||
const last = useRef(0);
|
||||
const report = useCallback((p: T | null) => {
|
||||
const now = Date.now();
|
||||
if (p !== null && now - last.current < 100) return;
|
||||
last.current = p === null ? 0 : now;
|
||||
setShown(p);
|
||||
}, []);
|
||||
return [shown, report];
|
||||
}
|
||||
|
||||
const STALL_MS = 6000;
|
||||
const STALL_LONG_MS = 45_000;
|
||||
|
||||
function amount(p: RunProgress): string | null {
|
||||
if (p.done === undefined || p.total === undefined) return null;
|
||||
if (p.unit === 'bytes') return ofBytes(p.done, p.total);
|
||||
return `${p.done.toLocaleString('en-US')} of ${p.total.toLocaleString('en-US')} ${p.unit === 'places' ? 'places' : 'matches'} checked`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Progress of a running search. The bar only ever moves when the search
|
||||
* reports work done; between reports only the clock ticks, and a long silence
|
||||
* is said out loud instead of being covered with an animation.
|
||||
*/
|
||||
export function SearchProgress(props: { label: string; startedAt: number; what: string; progress: RunProgress | null; canceling?: boolean }) {
|
||||
const { progress, startedAt } = props;
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
const samples = useRef<ProgressSample[]>([]);
|
||||
const stage = useRef<string | undefined>(undefined);
|
||||
const lastNews = useRef(startedAt);
|
||||
|
||||
const box = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// On a phone the progress would otherwise start below the bottom of the screen.
|
||||
box.current?.scrollIntoView({ block: 'nearest' });
|
||||
const timer = setInterval(() => setNow(Date.now()), 500);
|
||||
return () => {
|
||||
clearInterval(timer);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// One sample per report from the search.
|
||||
useEffect(() => {
|
||||
if (!progress) {
|
||||
samples.current = [];
|
||||
lastNews.current = startedAt;
|
||||
return;
|
||||
}
|
||||
const at = Date.now();
|
||||
const list = samples.current;
|
||||
const last = list[list.length - 1];
|
||||
if (stage.current !== progress.stage || (last && progress.fraction < last.fraction)) list.length = 0;
|
||||
stage.current = progress.stage;
|
||||
if (!last || progress.fraction !== last.fraction || list.length === 0) lastNews.current = at;
|
||||
list.push({ at, fraction: progress.fraction });
|
||||
if (list.length > 400) list.splice(0, list.length - 400);
|
||||
}, [progress, startedAt]);
|
||||
|
||||
const fraction = progress ? Math.max(0, Math.min(1, progress.fraction)) : 0;
|
||||
const percent = Math.floor(fraction * 100);
|
||||
const quiet = now - lastNews.current;
|
||||
const left = progress && fraction < 1 && !props.canceling ? timeLeft(samples.current, now) : null;
|
||||
const size = progress ? amount(progress) : null;
|
||||
const detail = progress ? [progress.stage, progress.found].filter(Boolean).join(' · ') : '';
|
||||
|
||||
// The block grows by a line when the first report arrives; keep all of it on screen.
|
||||
const tall = detail !== '';
|
||||
useEffect(() => {
|
||||
if (tall) box.current?.scrollIntoView({ block: 'nearest' });
|
||||
}, [tall]);
|
||||
|
||||
return (
|
||||
<div ref={box} className="flex scroll-mb-4 flex-col gap-1" data-testid="search-progress">
|
||||
<PelletTrack fraction={fraction} label={props.label} />
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-0.5 text-sm">
|
||||
<span className="min-w-0">
|
||||
<strong className="mr-2 inline-block min-w-[3ch] text-base tabular-nums text-primary">{percent}%</strong>
|
||||
{size && <span className="tabular-nums">{size}</span>}
|
||||
</span>
|
||||
<span className="text-muted-foreground tabular-nums">
|
||||
{clock(now - startedAt)} so far
|
||||
{left !== null && ` · ${leftWords(left)}${progress?.stageOnly ? ' in this step' : ''}`}
|
||||
</span>
|
||||
</div>
|
||||
{detail && <p className="text-sm tabular-nums">{detail}</p>}
|
||||
<p className="text-sm text-muted-foreground">{props.what}</p>
|
||||
{props.canceling ? (
|
||||
<p className="text-sm text-primary" role="status">
|
||||
Canceling… waiting for the console to finish what it is on.
|
||||
</p>
|
||||
) : (
|
||||
quiet > STALL_MS && (
|
||||
<p className="text-sm text-primary" role="status">
|
||||
{!progress || progress.unit === 'bytes' ? 'Working on a large block' : 'Waiting for the console'}: no news from the console for {Math.floor(quiet / 1000)} s.
|
||||
The bar moves again when this piece is done.
|
||||
{quiet > STALL_LONG_MS && ' If this goes on much longer, press Cancel and check that the game is still running.'}
|
||||
</p>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** How a search ended: one plain sentence, then what to try next. */
|
||||
export function FeedbackNotice(props: { feedback: Feedback; children?: ReactNode }) {
|
||||
const { feedback } = props;
|
||||
const box = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
// The answer to a search must not land below the bottom of the screen, where it looks like nothing happened.
|
||||
useEffect(() => {
|
||||
box.current?.scrollIntoView({ block: 'nearest' });
|
||||
}, [feedback]);
|
||||
|
||||
return (
|
||||
<Notice kind={feedback.tone === 'info' ? 'info' : feedback.tone} className="w-full scroll-my-4">
|
||||
<div ref={box} data-testid="search-outcome" className="flex w-full scroll-my-8 flex-col gap-1.5">
|
||||
<p className="font-medium text-foreground">{feedback.title}</p>
|
||||
{feedback.tips.length === 1 && <p>{feedback.tips[0]}</p>}
|
||||
{feedback.tips.length > 1 && (
|
||||
<ul className="list-disc space-y-1 pl-5">
|
||||
{feedback.tips.map((t) => (
|
||||
<li key={t}>{t}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{props.children}
|
||||
</div>
|
||||
</Notice>
|
||||
);
|
||||
}
|
||||
|
||||
interface CopyState {
|
||||
text: string;
|
||||
ok: boolean;
|
||||
nonce: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* "Copy address" that always ends in something the person can see: a short
|
||||
* confirmation, or, when the browser refuses, the text itself ready to be
|
||||
* selected by hand. Render `toast` once, anywhere in the component.
|
||||
*/
|
||||
export function useCopy(): { copy: (text: string) => void; toast: ReactNode } {
|
||||
const [state, setState] = useState<CopyState | null>(null);
|
||||
const field = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const copy = useCallback((text: string) => {
|
||||
void copyText(text).then((ok) => setState({ text, ok, nonce: Date.now() }));
|
||||
}, []);
|
||||
|
||||
// A confirmation goes away by itself; a failure stays until it is closed, because the text is needed from it.
|
||||
useEffect(() => {
|
||||
if (!state) return;
|
||||
if (!state.ok) {
|
||||
field.current?.select();
|
||||
return;
|
||||
}
|
||||
const timer = setTimeout(() => setState(null), 3500);
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [state]);
|
||||
|
||||
const toast = state ? (
|
||||
<div className="pointer-events-none fixed inset-x-0 bottom-4 z-50 flex justify-center px-4">
|
||||
<div
|
||||
role="status"
|
||||
data-testid="copy-toast"
|
||||
className="pointer-events-auto flex max-w-full items-center gap-2 rounded-lg border border-primary/50 bg-popover px-3 py-2 text-sm text-popover-foreground shadow-lg"
|
||||
>
|
||||
{state.ok ? (
|
||||
<>
|
||||
<CheckIcon className="size-4 shrink-0 text-primary" />
|
||||
<span className="min-w-0 break-all">
|
||||
Copied <span className="font-mono">{state.text}</span>
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="shrink-0">This browser would not copy. Select it here:</span>
|
||||
<input
|
||||
ref={field}
|
||||
readOnly
|
||||
aria-label="Text to copy"
|
||||
className="min-w-0 flex-1 rounded border bg-background px-2 py-1 font-mono text-sm"
|
||||
value={state.text}
|
||||
onFocus={(e) => e.target.select()}
|
||||
/>
|
||||
<Button variant="ghost" size="icon-sm" className="shrink-0" aria-label="Close" onClick={() => setState(null)}>
|
||||
<XIcon />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
return { copy, toast };
|
||||
}
|
||||
+27
-2
@@ -135,6 +135,8 @@ export interface Workbench extends EngineAccess {
|
||||
* table undo themselves.
|
||||
*/
|
||||
write: (address: bigint, bytes: Uint8Array) => Promise<void>;
|
||||
/** The same for many values at once ("Set all" in a result list): the screen is refreshed once at the end, not once per value. */
|
||||
writeMany: (list: { address: bigint; bytes: Uint8Array }[], onProgress?: (done: number) => void) => Promise<void>;
|
||||
/**
|
||||
* Registers a function that stops whatever the caller keeps rewriting in
|
||||
* the game (frozen values). Called by "Restore everything". Returns a
|
||||
@@ -536,12 +538,17 @@ export function WorkbenchProvider(props: {
|
||||
async (entry: Entry) => {
|
||||
// Undo the old version before replacing it, so nothing is left applied that can no longer be switched off.
|
||||
const old = tableRef.current.entries.find((e) => e.id === entry.id);
|
||||
if (old && runner.isOn(old.id)) await runner.disable(old).catch(() => undefined);
|
||||
const wasOn = !!old && runner.isOn(old.id);
|
||||
if (old && wasOn) await runner.disable(old).catch(() => undefined);
|
||||
setTable((t) => ({ ...t, entries: t.entries.map((e) => (e.id === entry.id ? entry : e)) }));
|
||||
bump();
|
||||
// It was on, so the edited version goes on in its place; left off, the
|
||||
// change would look like it silently broke the cheat. If the new
|
||||
// version does not fit, its row says why.
|
||||
if (wasOn && !isAction(entry) && entry.type !== 'shared' && !entry.unreviewed) await toggle(entry, true);
|
||||
proofLater(entry);
|
||||
},
|
||||
[runner, setTable, bump, proofLater],
|
||||
[runner, setTable, bump, proofLater, toggle],
|
||||
);
|
||||
|
||||
const removeEntry = useCallback(
|
||||
@@ -689,6 +696,23 @@ export function WorkbenchProvider(props: {
|
||||
},
|
||||
[journal, mem, bump],
|
||||
);
|
||||
const writeMany = useCallback(
|
||||
async (list: { address: bigint; bytes: Uint8Array }[], onProgress?: (done: number) => void) => {
|
||||
let done = 0;
|
||||
try {
|
||||
for (const w of list) {
|
||||
await journal.write(mem, w.address, w.bytes);
|
||||
onProgress?.(++done);
|
||||
}
|
||||
} finally {
|
||||
if (done > 0) {
|
||||
lastChange.current = { text: `Changed ${done} ${done === 1 ? 'value' : 'values'} by hand`, time: Date.now() };
|
||||
bump();
|
||||
}
|
||||
}
|
||||
},
|
||||
[journal, mem, bump],
|
||||
);
|
||||
|
||||
const freezers = useRef(new Set<() => void>());
|
||||
const onStopFreezing = useCallback((stop: () => void) => {
|
||||
@@ -868,6 +892,7 @@ export function WorkbenchProvider(props: {
|
||||
keptState,
|
||||
setKeep,
|
||||
write,
|
||||
writeMany,
|
||||
onStopFreezing,
|
||||
noteFrozen,
|
||||
registerRestorer,
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
// A React effect may return a cleanup function and nothing else. An effect written as a bare
|
||||
// expression (`useEffect(() => window.scrollTo(0, 0))`) returns whatever the call returns, and
|
||||
// newer browsers return a promise from scroll methods: React then tries to call that promise as
|
||||
// the cleanup and the page breaks. So effects are written with a block body, or return a function.
|
||||
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'src');
|
||||
|
||||
function* sources(dir) {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) yield* sources(full);
|
||||
else if (/\.tsx?$/.test(entry.name)) yield full;
|
||||
}
|
||||
}
|
||||
|
||||
test('no effect returns the value of an expression', () => {
|
||||
const bad = [];
|
||||
for (const file of sources(root)) {
|
||||
const text = fs.readFileSync(file, 'utf8');
|
||||
// `() => {` (a block) and `() => () =>` (a cleanup function) are the two allowed openings.
|
||||
for (const m of text.matchAll(/use(?:Layout|Insertion)?Effect\(\s*(?:async\s*)?\(\)\s*=>\s*(?=\S)(?!\{|\(\)\s*=>)/g)) {
|
||||
bad.push(`${path.relative(root, file)}:${text.slice(0, m.index).split('\n').length}`);
|
||||
}
|
||||
for (const m of text.matchAll(/use(?:Layout|Insertion)?Effect\(\s*async\b/g)) {
|
||||
bad.push(`${path.relative(root, file)}:${text.slice(0, m.index).split('\n').length} (async effect)`);
|
||||
}
|
||||
}
|
||||
assert.deepEqual(bad, [], 'write these effects with a block body');
|
||||
});
|
||||
@@ -0,0 +1,594 @@
|
||||
// The Find tab's honesty: progress that is real work done over work to do,
|
||||
// canceling at any point, input errors that say what to change, and the words
|
||||
// used for every way a search can end. Runs against the mock console with a
|
||||
// game enlarged to a few hundred megabytes and slowed down, so progress and
|
||||
// canceling can be seen.
|
||||
|
||||
import test, { after, before } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { startMock, blankRegion, GAME } from '../mock/mock-ps5.mjs';
|
||||
import { startBridge } from '../bridge/server.mjs';
|
||||
import { openWebSocketStream } from '../src/lib/transport.ts';
|
||||
import { Ps5Debug } from '../src/lib/ps5debug.ts';
|
||||
import {
|
||||
AUTO_KIND,
|
||||
AutoScan,
|
||||
SCAN_SLICE_BYTES,
|
||||
ScanCanceledError,
|
||||
ScanSession,
|
||||
SnapshotScan,
|
||||
compareKind,
|
||||
decodeValue,
|
||||
encodeCompare,
|
||||
encodeValue,
|
||||
liveDbg,
|
||||
valueKind,
|
||||
} from '../src/lib/scanner.ts';
|
||||
import { DEFAULT_SCOPE, hugeRegionIds, listRegions, rangesBytes, scopeRanges, sliceRanges, totalBytes } from '../src/lib/regions.ts';
|
||||
import { searchText } from '../src/lib/textsearch.ts';
|
||||
import { checkGroup, groupSearch } from '../src/lib/groupscan.ts';
|
||||
import { SnapshotCanceledError, takeSnapshot } from '../src/lib/memdiff.ts';
|
||||
import {
|
||||
canceledFeedback,
|
||||
checkWords,
|
||||
clock,
|
||||
failedFeedback,
|
||||
firstFeedback,
|
||||
groupFeedback,
|
||||
holdsWords,
|
||||
leftWords,
|
||||
narrowFeedback,
|
||||
ofBytes,
|
||||
textFeedback,
|
||||
timeLeft,
|
||||
tooManyFeedback,
|
||||
} from '../src/lib/findfeedback.ts';
|
||||
|
||||
const PORT_OFFSET = Number(process.env.CS_PORT_OFFSET ?? 0);
|
||||
const TCP = 17520 + PORT_OFFSET;
|
||||
const MB = 1024 * 1024;
|
||||
const BIG = 0x1000000000n;
|
||||
const BIG_BYTES = 100 * MB;
|
||||
let mock, bridge, bridgePort;
|
||||
|
||||
const connect = async () => {
|
||||
const stream = await openWebSocketStream(`ws://127.0.0.1:${bridgePort}/ws?host=127.0.0.1&port=${TCP}`);
|
||||
return new Ps5Debug(stream, { timeoutMs: 4000 });
|
||||
};
|
||||
const u32 = valueKind('u32');
|
||||
const exact = compareKind('exact');
|
||||
const enc = (text, kind = u32) => encodeValue(kind, text);
|
||||
const bigSlices = () => sliceRanges([{ start: BIG, end: BIG + BigInt(BIG_BYTES) }]);
|
||||
const heapSlices = () => sliceRanges([{ start: GAME.heapBase, end: GAME.heapBase + 0x40000n }]);
|
||||
/** Eight separate 256 KB pieces of the empty memory: eight slices of 65,536 zeros each. */
|
||||
const zeroSlices = () => sliceRanges(Array.from({ length: 8 }, (_, i) => ({ start: BIG + BigInt(i * 20 * MB), end: BIG + BigInt(i * 20 * MB + 0x40000) })));
|
||||
|
||||
before(async () => {
|
||||
mock = startMock({ tcpPort: TCP, udpPort: 0, tick: false });
|
||||
await mock.ready;
|
||||
// 100 MB of empty memory next to the real game: 7 slices to search.
|
||||
mock.game.regions.push(blankRegion('', BIG, BIG_BYTES));
|
||||
bridge = startBridge({ port: 0, host: '127.0.0.1', eventPort: null, demo: { host: '127.0.0.1', port: TCP, udpPort: 0, label: 'test' } });
|
||||
bridgePort = await bridge.ready;
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await bridge.close();
|
||||
await mock.close();
|
||||
});
|
||||
|
||||
const calm = () => {
|
||||
mock.state.scanDelayMs = 0;
|
||||
mock.state.countDelayMs = 0;
|
||||
mock.state.scanHang = false;
|
||||
};
|
||||
|
||||
// ---- progress ----------------------------------------------------------------------
|
||||
|
||||
test('a first search reports real progress: bytes done of bytes to do, slice by slice', async () => {
|
||||
calm();
|
||||
const dbg = await connect();
|
||||
const regions = bigSlices();
|
||||
assert.ok(regions.every((r) => r.length <= SCAN_SLICE_BYTES), 'no slice is bigger than 16 MB, so the bar moves steadily');
|
||||
assert.equal(regions.length, Math.ceil(BIG_BYTES / SCAN_SLICE_BYTES));
|
||||
const seen = [];
|
||||
const scan = new ScanSession(() => dbg, GAME.pid, u32);
|
||||
await scan.first(regions, exact, enc('123456'), (p) => seen.push(p));
|
||||
// One report before anything is searched (so the screen can say how much there is), then one per slice.
|
||||
assert.equal(seen.length, regions.length + 1);
|
||||
assert.deepEqual([seen[0].fraction, seen[0].done, seen[0].total, seen[0].unit], [0, 0, BIG_BYTES, 'bytes']);
|
||||
assert.deepEqual([seen.at(-1).fraction, seen.at(-1).done], [1, BIG_BYTES]);
|
||||
for (let i = 1; i < seen.length; i++) {
|
||||
assert.ok(seen[i].done > seen[i - 1].done, 'progress only moves forward');
|
||||
assert.equal(seen[i].fraction, seen[i].done / BIG_BYTES, 'the fraction is bytes done over bytes to do, nothing else');
|
||||
}
|
||||
assert.equal(scan.matches, 0);
|
||||
dbg.close();
|
||||
});
|
||||
|
||||
test('narrowing down reports candidates checked of candidates to check', async () => {
|
||||
calm();
|
||||
const dbg = await connect();
|
||||
const scan = new ScanSession(() => dbg, GAME.pid, u32);
|
||||
await scan.first(zeroSlices(), compareKind('smaller'), enc('1'), () => {});
|
||||
const before = scan.matches;
|
||||
assert.equal(before, 8 * 65536);
|
||||
const seen = [];
|
||||
await scan.next(compareKind('unchanged'), new Uint8Array(0), (p) => seen.push(p));
|
||||
assert.deepEqual([seen[0].done, seen[0].total, seen[0].unit, seen[0].fraction], [0, before, 'candidates', 0]);
|
||||
assert.deepEqual([seen.at(-1).done, seen.at(-1).fraction], [before, 1]);
|
||||
assert.ok(seen.length > 5, 'several steps, not one jump');
|
||||
for (let i = 1; i < seen.length; i++) assert.ok(seen[i].done > seen[i - 1].done);
|
||||
dbg.close();
|
||||
});
|
||||
|
||||
test('an Auto search says which round it is on and counts within that round', async () => {
|
||||
calm();
|
||||
const dbg = await connect();
|
||||
const scan = new AutoScan(() => dbg, GAME.pid);
|
||||
scan.input('100');
|
||||
const seen = [];
|
||||
await scan.first(heapSlices(), exact, new Uint8Array(0), (p) => seen.push(p));
|
||||
assert.deepEqual([...new Set(seen.map((p) => p.part.index))], [1, 2, 3]);
|
||||
assert.ok(seen.every((p) => p.part.count === 3 && p.total === 0x40000 && p.unit === 'bytes'));
|
||||
for (let i = 1; i < seen.length; i++) assert.ok(seen[i].fraction >= seen[i - 1].fraction, 'the bar never goes back between rounds');
|
||||
assert.equal(seen.at(-1).fraction, 1);
|
||||
assert.match(seen[0].detail, /as 4 bytes/);
|
||||
// A number no type can hold is refused with the reason, before anything is sent.
|
||||
scan.input('lots');
|
||||
await assert.rejects(scan.first(heapSlices(), exact, new Uint8Array(0), () => {}), /"lots" is not a number/);
|
||||
dbg.close();
|
||||
});
|
||||
|
||||
test('an unknown-value snapshot reports progress as the console goes, and can be canceled', async () => {
|
||||
calm();
|
||||
const dbg = await connect();
|
||||
const scan = new SnapshotScan(() => dbg, GAME.pid, u32);
|
||||
const seen = [];
|
||||
await scan.first([...heapSlices(), ...bigSlices()], compareKind('unknown'), new Uint8Array(0), (p) => seen.push(p));
|
||||
assert.ok(seen.length > 5);
|
||||
assert.deepEqual([seen[0].done, seen[0].unit], [0, 'bytes']);
|
||||
assert.equal(seen.at(-1).fraction, 1);
|
||||
for (let i = 1; i < seen.length; i++) assert.ok(seen[i].done >= seen[i - 1].done);
|
||||
await scan.end();
|
||||
|
||||
// Canceling one long request means closing the connection: it comes back as a cancel, not as an error.
|
||||
mock.state.scanDelayMs = 600;
|
||||
const dbg2 = await connect();
|
||||
await dbg2.auth();
|
||||
const second = new SnapshotScan(() => dbg2, GAME.pid, u32);
|
||||
const stop = new AbortController();
|
||||
const started = Date.now();
|
||||
const pending = second.first(bigSlices(), compareKind('unknown'), new Uint8Array(0), (p) => {
|
||||
if (p.done > 0 && !stop.signal.aborted) stop.abort();
|
||||
}, stop.signal);
|
||||
await assert.rejects(pending, (e) => e instanceof ScanCanceledError && e.progress.done > 0 && e.progress.done < BIG_BYTES);
|
||||
assert.ok(Date.now() - started < 5000, 'the cancel does not wait for the snapshot to finish');
|
||||
assert.equal(dbg2.closed, true);
|
||||
assert.equal(second.matches, 0);
|
||||
calm();
|
||||
|
||||
// Narrowing a list the console holds: a cancel that comes too late to stop it takes the result, because the
|
||||
// console's list is narrowed either way...
|
||||
const dbg3 = await connect();
|
||||
const third = new SnapshotScan(() => dbg3, GAME.pid, u32);
|
||||
await third.first(heapSlices(), compareKind('unknown'), new Uint8Array(0), () => {});
|
||||
const had = third.matches;
|
||||
const late = new AbortController();
|
||||
mock.state.countDelayMs = 300;
|
||||
setTimeout(() => late.abort(), 100);
|
||||
await third.next(compareKind('unchanged'), new Uint8Array(0), () => {}, late.signal);
|
||||
assert.equal(late.signal.aborted, true);
|
||||
calm();
|
||||
assert.equal(third.passes, 2);
|
||||
assert.ok(third.matches > 0 && third.matches <= had);
|
||||
assert.equal(third.lost, false);
|
||||
// ...and one that has to close the connection loses the list, and says so.
|
||||
mock.state.scanHang = true;
|
||||
const cut = new AbortController();
|
||||
const narrowing = third.next(compareKind('unchanged'), new Uint8Array(0), () => {}, cut.signal);
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
cut.abort();
|
||||
await assert.rejects(narrowing, ScanCanceledError);
|
||||
assert.deepEqual([third.lost, third.matches, dbg3.closed], [true, 0, true]);
|
||||
await assert.rejects(third.next(compareKind('unchanged'), new Uint8Array(0), () => {}), /Start a new search/);
|
||||
calm();
|
||||
});
|
||||
|
||||
// ---- canceling -----------------------------------------------------------------------
|
||||
|
||||
test('a first search can be canceled between slices, keeps nothing and leaves the connection usable', async () => {
|
||||
calm();
|
||||
mock.state.scanDelayMs = 30;
|
||||
const dbg = await connect();
|
||||
const scan = new ScanSession(() => dbg, GAME.pid, u32);
|
||||
const stop = new AbortController();
|
||||
const pending = scan.first(bigSlices(), exact, enc('123456'), (p) => {
|
||||
if (p.done >= 3 * SCAN_SLICE_BYTES) stop.abort();
|
||||
}, stop.signal);
|
||||
await assert.rejects(pending, (e) => {
|
||||
assert.ok(e instanceof ScanCanceledError);
|
||||
// It says how far it got, for "Canceled after searching 48 of 100 MB".
|
||||
assert.equal(e.progress.done, 3 * SCAN_SLICE_BYTES);
|
||||
assert.equal(e.progress.total, BIG_BYTES);
|
||||
assert.equal(e.progress.matches, 0);
|
||||
return true;
|
||||
});
|
||||
assert.deepEqual([scan.matches, scan.passes, scan.canUndo], [0, 0, false]);
|
||||
assert.equal(dbg.closed, false, 'a slice that finishes in time needs no closed connection');
|
||||
// The same connection searches again straight away.
|
||||
calm();
|
||||
const again = new ScanSession(() => dbg, GAME.pid, u32);
|
||||
await again.first(heapSlices(), exact, enc('100'), () => {});
|
||||
assert.ok(again.matches >= 1);
|
||||
// Already canceled before it starts: nothing is sent at all.
|
||||
const dead = new AbortController();
|
||||
dead.abort();
|
||||
await assert.rejects(new ScanSession(() => dbg, GAME.pid, u32).first(heapSlices(), exact, enc('100'), () => {}, dead.signal), ScanCanceledError);
|
||||
dbg.close();
|
||||
});
|
||||
|
||||
test('canceling a narrowing leaves the candidate list exactly as it was', async () => {
|
||||
calm();
|
||||
const dbg = await connect();
|
||||
const scan = new ScanSession(() => dbg, GAME.pid, u32);
|
||||
await scan.first(zeroSlices(), compareKind('smaller'), enc('1'), () => {});
|
||||
const before = scan.matches;
|
||||
const firstHits = await scan.hits(5);
|
||||
mock.state.countDelayMs = 30;
|
||||
const stop = new AbortController();
|
||||
const pending = scan.next(compareKind('changed'), new Uint8Array(0), (p) => {
|
||||
if (p.done > before / 4) stop.abort();
|
||||
}, stop.signal);
|
||||
await assert.rejects(pending, (e) => e instanceof ScanCanceledError && e.progress.unit === 'candidates' && e.progress.done < before);
|
||||
assert.deepEqual([scan.matches, scan.passes, scan.canUndo], [before, 1, false]);
|
||||
assert.deepEqual(await scan.hits(5), firstHits);
|
||||
// And it can be narrowed for real afterwards.
|
||||
calm();
|
||||
await scan.next(compareKind('changed'), new Uint8Array(0), () => {});
|
||||
assert.equal(scan.matches, 0);
|
||||
scan.undo();
|
||||
assert.equal(scan.matches, before);
|
||||
dbg.close();
|
||||
});
|
||||
|
||||
test('cancel gets through when the console has stopped answering, and the next search waits for the new connection', async () => {
|
||||
calm();
|
||||
let dbg = await connect();
|
||||
await dbg.auth();
|
||||
const getDbg = () => dbg;
|
||||
mock.state.scanHang = true;
|
||||
const scan = new ScanSession(getDbg, GAME.pid, u32);
|
||||
const stop = new AbortController();
|
||||
const pending = scan.first(heapSlices(), exact, enc('100'), () => {}, stop.signal);
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
const at = Date.now();
|
||||
stop.abort();
|
||||
await assert.rejects(pending, ScanCanceledError);
|
||||
const waited = Date.now() - at;
|
||||
assert.ok(waited >= 2000 && waited < 5000, `gives the console a moment, then closes the connection (waited ${waited} ms)`);
|
||||
assert.equal(dbg.closed, true);
|
||||
calm();
|
||||
|
||||
// The session replaces a closed connection a moment later; a search started in between waits for it.
|
||||
const dead = dbg;
|
||||
setTimeout(async () => {
|
||||
dbg = await connect();
|
||||
}, 300);
|
||||
assert.equal(getDbg(), dead);
|
||||
const again = new ScanSession(getDbg, GAME.pid, u32);
|
||||
await again.first(heapSlices(), exact, enc('100'), () => {});
|
||||
assert.ok(again.matches >= 1);
|
||||
assert.notEqual(getDbg(), dead);
|
||||
// If it never comes back, the wait ends with something a person can act on.
|
||||
await assert.rejects(liveDbg(() => dead, 300), /connection to the console was lost/);
|
||||
dbg.close();
|
||||
});
|
||||
|
||||
test('text and group searches: bytes done of bytes to do, and cancel when the console hangs', async () => {
|
||||
calm();
|
||||
const dbg = await connect();
|
||||
// 6 MB, in pieces of 1 MB so there are several steps to see.
|
||||
const SIZE = 6 * MB;
|
||||
const ranges = [{ start: BIG, end: BIG + BigInt(SIZE) }];
|
||||
const seen = [];
|
||||
const res = await searchText({ dbg: () => dbg, pid: GAME.pid, ranges, text: 'zzqqxx', encodings: ['utf8', 'utf16'], ignoreCase: true, chunkBytes: MB, onProgress: (p) => seen.push(p) });
|
||||
assert.deepEqual([res.hits.length, res.canceled, res.bytesSearched], [0, false, SIZE]);
|
||||
assert.deepEqual([seen[0].done, seen[0].total, seen[0].fraction], [0, SIZE, 0]);
|
||||
assert.deepEqual([seen.at(-1).done, seen.at(-1).fraction], [SIZE, 1]);
|
||||
assert.ok(seen.length >= 5);
|
||||
for (let i = 1; i < seen.length; i++) assert.ok(seen[i].done >= seen[i - 1].done && seen[i].fraction === seen[i].done / seen[i].total);
|
||||
|
||||
const steps = [];
|
||||
const group = await groupSearch({
|
||||
dbg: () => dbg,
|
||||
pid: GAME.pid,
|
||||
regions: sliceRanges([{ start: GAME.heapBase, end: GAME.heapBase + 0x40000n }, ...ranges], MB),
|
||||
members: [{ kind: u32, text: '3' }, { kind: u32, text: '12' }, { kind: u32, text: '7' }],
|
||||
distance: 64,
|
||||
onProgress: (p) => steps.push(p),
|
||||
});
|
||||
assert.equal(group.hits.length, 1);
|
||||
assert.deepEqual([group.bytesSearched, group.bytesTotal], [SIZE + 0x40000, SIZE + 0x40000]);
|
||||
const one = steps.filter((p) => p.step === 1);
|
||||
const two = steps.filter((p) => p.step === 2);
|
||||
assert.deepEqual([one[0].fraction, one[0].done, one.at(-1).fraction], [0, 0, 1]);
|
||||
assert.ok(one.every((p) => p.total === SIZE + 0x40000) && one.length >= 5, 'step 1 counts bytes of memory');
|
||||
assert.ok(two.length >= 1 && two.at(-1).fraction === 1 && two.at(-1).total === group.anchorMatches, 'step 2 counts the places to check');
|
||||
|
||||
// The console stops answering in the middle of each.
|
||||
for (const run of [
|
||||
(signal) => searchText({ dbg: () => dbg2, pid: GAME.pid, ranges, text: 'zzqqxx', encodings: ['utf8'], signal }),
|
||||
(signal) => groupSearch({ dbg: () => dbg2, pid: GAME.pid, regions: sliceRanges(ranges), members: [{ kind: u32, text: '424242' }, { kind: u32, text: '7' }], distance: 64, signal }),
|
||||
]) {
|
||||
calm();
|
||||
var dbg2 = await connect();
|
||||
await dbg2.auth();
|
||||
mock.state.scanHang = true;
|
||||
const stop = new AbortController();
|
||||
const pending = run(stop.signal);
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
stop.abort();
|
||||
const out = await pending;
|
||||
assert.equal(out.canceled, true, 'it ends as a cancel with whatever was found, not as an error and not as a hang');
|
||||
assert.deepEqual(out.hits, []);
|
||||
}
|
||||
calm();
|
||||
dbg.close();
|
||||
});
|
||||
|
||||
test('a before/after snapshot can be canceled and says so', async () => {
|
||||
calm();
|
||||
const dbg = await connect();
|
||||
const seen = [];
|
||||
const stop = new AbortController();
|
||||
const pending = takeSnapshot(() => dbg, GAME.pid, BIG, 8 * MB, (f) => {
|
||||
seen.push(f);
|
||||
if (f >= 0.25) stop.abort();
|
||||
}, stop.signal);
|
||||
await assert.rejects(pending, SnapshotCanceledError);
|
||||
assert.equal(seen[0], 0);
|
||||
assert.ok(seen.at(-1) < 1);
|
||||
dbg.close();
|
||||
});
|
||||
|
||||
// ---- what was typed ---------------------------------------------------------------------
|
||||
|
||||
test('a wrong number is refused with what to change', () => {
|
||||
const bad = (kindId, text, pattern) => assert.throws(() => encodeValue(valueKind(kindId), text), pattern, `${kindId} ${JSON.stringify(text)}`);
|
||||
bad('u32', '', /Enter a value first/);
|
||||
bad('u32', ' ', /Enter a value first/);
|
||||
bad('u32', 'abc', /"abc" is not a whole number\. Type digits only/);
|
||||
bad('float', 'abc', /"abc" is not a number\. Type digits only/);
|
||||
// A decimal typed for a whole-number type: say which type would take it.
|
||||
bad('u32', '1.5', /1\.5 is not a whole number.*only holds whole numbers.*Float \(or Auto\)/);
|
||||
bad('u16', '99.25', /Float/);
|
||||
// Negative for a type without a sign: name the signed one.
|
||||
bad('u32', '-5', /cannot hold a negative number\. Choose "4 bytes \(signed\)"/);
|
||||
bad('u8', '-1', /"1 byte \(signed\)"/);
|
||||
// Out of range: say the range.
|
||||
bad('u8', '256', /256 does not fit in the type "1 byte", which holds 0 to 255\. Choose a bigger type/);
|
||||
bad('i8', '-129', /holds -128 to 127/);
|
||||
bad('u32', '4294967296', /holds 0 to 4,294,967,295/);
|
||||
bad('u64', '18446744073709551616', /does not fit in the type "8 bytes"/);
|
||||
// Thousands separators and a decimal comma.
|
||||
bad('u32', '1,000', /without commas or spaces: 1000/);
|
||||
bad('u32', '12 000 000', /12000000/);
|
||||
bad('float', '99,5', /Use a point for decimals: 99\.5/);
|
||||
bad('float', 'Infinity', /is not a number/);
|
||||
// What is fine stays fine.
|
||||
assert.equal(decodeValue(u32, encodeValue(u32, ' 0x10 ')), '16');
|
||||
assert.equal(decodeValue(valueKind('i32'), encodeValue(valueKind('i32'), '-5')), '-5');
|
||||
assert.equal(decodeValue(valueKind('double'), encodeValue(valueKind('double'), '1e39')), '1e+39');
|
||||
assert.equal(decodeValue(valueKind('float'), encodeValue(valueKind('float'), '-0.5')), '-0.5');
|
||||
|
||||
// "Between" takes the two numbers in either order, and needs both.
|
||||
const between = compareKind('between');
|
||||
assert.deepEqual(encodeCompare(u32, between, '50', '10'), encodeCompare(u32, between, '10', '50'));
|
||||
assert.deepEqual([...encodeCompare(u32, between, '50', '10')], [10, 0, 0, 0, 50, 0, 0, 0]);
|
||||
assert.throws(() => encodeCompare(u32, between, '10', ''), /Type both numbers/);
|
||||
assert.throws(() => encodeCompare(u32, between, '10', 'x'), /"x" is not a whole number/);
|
||||
|
||||
// A group names the line that is wrong.
|
||||
const m = (text, id = 'u32') => ({ kind: valueKind(id), text });
|
||||
assert.throws(() => checkGroup([m('100'), m('')], 64), /Value 2 is empty/);
|
||||
assert.throws(() => checkGroup([m('100'), m('1.5')], 64), /Value 2: 1\.5 is not a whole number/);
|
||||
assert.throws(() => checkGroup([m('100'), m('50')], NaN), /whole number of bytes, such as 64/);
|
||||
assert.throws(() => checkGroup([m('100'), m('50')], 4), /has to be at least 8/);
|
||||
assert.throws(() => checkGroup([m('100'), m('50')], 5000), /at most 4,096/);
|
||||
assert.equal(checkGroup([m('100'), m('50', 'float')], 64).length, 2);
|
||||
});
|
||||
|
||||
// ---- the words for every ending -----------------------------------------------------------
|
||||
|
||||
const GB = 1024 ** 3;
|
||||
const all = { bytes: 9.6 * GB, total: 9.6 * GB };
|
||||
const part = { bytes: 1.2 * GB, total: 9.6 * GB };
|
||||
const q = (kindId, compareId, value = '', value2 = '') => ({ kind: kindId === 'auto' ? AUTO_KIND : valueKind(kindId), compare: compareKind(compareId), value, value2 });
|
||||
|
||||
test('a first search that finds nothing says what was searched, where, and what to try', () => {
|
||||
const f = firstFeedback(q('u32', 'exact', '123456'), all, 0, 85_000);
|
||||
assert.equal(f.tone, 'warn');
|
||||
assert.equal(f.title, "Nothing found. No place in all 9.60 GB of the game's memory holds 123456 as 4 bytes. Took 85 s.");
|
||||
assert.ok(f.tips.length >= 3);
|
||||
assert.match(f.tips[0], /exactly what the game shows right now/);
|
||||
assert.ok(f.tips.some((t) => /Set Type to Auto/.test(t)));
|
||||
assert.ok(f.tips.some((t) => /"Unknown"/.test(t)));
|
||||
assert.ok(!f.tips.some((t) => /Where to search/.test(t)), 'everything was searched, so searching more is not suggested');
|
||||
// Only part of the memory was searched: say so and say how to widen it.
|
||||
const narrow = firstFeedback(q('u32', 'exact', '123456'), part, 0, 4000);
|
||||
assert.match(narrow.title, /No place in 1\.20 GB of the game's 9\.60 GB holds/);
|
||||
assert.ok(narrow.tips.some((t) => /Only 1\.20 GB of 9\.60 GB was searched\. Open "Where to search" and choose "Everything"/.test(t)));
|
||||
// A decimal searched exactly: point at "About".
|
||||
assert.ok(firstFeedback(q('float', 'exact', '99.5'), all, 0, 1000).tips.some((t) => /About \(shown rounded\)/.test(t)));
|
||||
// Auto already tried the other types.
|
||||
const auto = firstFeedback(q('auto', 'exact', '100'), all, 0, 1000, ['Double (too many matches)']);
|
||||
assert.match(auto.title, /holds 100 as 4 bytes, Float or Double/);
|
||||
assert.ok(!auto.tips.some((t) => /Set Type to Auto/.test(t)));
|
||||
assert.ok(auto.tips.some((t) => /Not counted here: Double \(too many matches\)/.test(t)));
|
||||
// Every comparison reads as a sentence.
|
||||
assert.equal(holdsWords(q('u32', 'bigger', '5')), 'a value bigger than 5 as 4 bytes');
|
||||
assert.equal(holdsWords(q('i16', 'between', '-5', '5')), 'a value between -5 and 5 as 2 bytes (signed)');
|
||||
assert.equal(holdsWords(q('float', 'rounded', '99')), 'about 99 (98.5 to 99.5) as Float');
|
||||
});
|
||||
|
||||
test('a search that finds something says how many and what to do with them', () => {
|
||||
const many = firstFeedback(q('u32', 'exact', '100'), all, 48213, 85_000);
|
||||
assert.equal(many.tone, 'info');
|
||||
assert.equal(many.title, "Found 48,213 places holding 100 as 4 bytes, in all 9.60 GB of the game's memory. Took 85 s.");
|
||||
assert.match(many.tips[0], /Too many to try one by one.*press "Narrow down"/);
|
||||
assert.match(firstFeedback(q('u32', 'exact', '100'), all, 1, 500).title, /^Found 1 place holding .* Took under a second\.$/);
|
||||
assert.match(firstFeedback(q('u32', 'exact', '100'), all, 1, 500).tips[0], /very likely it/);
|
||||
assert.match(firstFeedback(q('u32', 'exact', '100'), all, 4, 200_000).title, /Took 3 min\.$/);
|
||||
const snap = firstFeedback(q('u32', 'unknown'), all, 2_400_000_000, 30_000);
|
||||
assert.match(snap.title, /^Took note of every value in all 9\.60 GB of the game's memory, read as 4 bytes: 2,400,000,000 matches/);
|
||||
assert.match(snap.tips[0], /"Went up", "Went down" or "Changed"/);
|
||||
});
|
||||
|
||||
test('narrowing down: what was kept, and an empty list is explained', () => {
|
||||
const kept = narrowFeedback(q('u32', 'exact', '95'), 48213, 12, true);
|
||||
assert.equal(kept.title, '12 of 48,213 matches left: the ones whose value is now 95.');
|
||||
const none = narrowFeedback(q('u32', 'exact', '95'), 48213, 0, true);
|
||||
assert.equal(none.tone, 'warn');
|
||||
assert.equal(none.title, 'Nothing left. None of the 48,213 matches passed the check "value is now 95".');
|
||||
assert.match(none.tips[0], /Press "Undo last step" to get the 48,213 matches back/);
|
||||
assert.ok(none.tips.some((t) => /"Went up", "Went down" or "Changed" instead of a number/.test(t)));
|
||||
// No undo (a search that started from "Unknown"): do not offer it.
|
||||
const snap = narrowFeedback(q('u32', 'decreased'), 1000, 0, false);
|
||||
assert.ok(!snap.tips.some((t) => /Undo/.test(t)));
|
||||
assert.match(snap.tips.at(-1), /^Press "New search"/);
|
||||
assert.match(snap.title, /"value went down"/);
|
||||
// Nothing ruled out is worth saying.
|
||||
assert.match(narrowFeedback(q('u32', 'unchanged'), 500, 500, true).tips[0], /Nothing was ruled out/);
|
||||
assert.equal(narrowFeedback(q('u32', 'unchanged'), 1, 1, true).title, '1 of 1 match left: the ones whose value stayed the same.');
|
||||
for (const [id, words] of [['increased', 'value went up'], ['increasedBy', 'value went up by exactly 5'], ['decreasedBy', 'value went down by exactly 5'], ['changed', 'value changed'], ['smaller', 'value is now smaller than 5']]) {
|
||||
assert.equal(checkWords(q('u32', id, '5')), words);
|
||||
}
|
||||
});
|
||||
|
||||
test('canceled, failed and too many: each says what was kept', () => {
|
||||
const progress = { fraction: 0.32, matches: 17, detail: '', done: 3.1 * GB, total: 9.6 * GB, unit: 'bytes' };
|
||||
const first = canceledFeedback(true, progress, all, null);
|
||||
assert.equal(first.title, 'Canceled after searching 3.10 of 9.60 GB.');
|
||||
assert.match(first.tips[0], /^Nothing was kept/);
|
||||
assert.ok(first.tips.some((t) => t === '17 matches had been found up to then.'));
|
||||
assert.match(canceledFeedback(true, { ...progress, part: { index: 2, count: 3 } }, all, null).title, /on round 2 of 3\.$/);
|
||||
assert.equal(canceledFeedback(true, null, null, null).title, 'Canceled.');
|
||||
assert.equal(canceledFeedback(false, null, null, 48213).title, 'Canceled. Nothing changed: your list still has 48,213 matches.');
|
||||
// A list kept on the console is lost by a cancel, and that has to be said.
|
||||
assert.match(canceledFeedback(false, null, null, null).title, /This search is gone/);
|
||||
|
||||
const gone = failedFeedback(true, 'Starting the scan failed (the console reported an error)', null, true);
|
||||
assert.equal(gone.tone, 'error');
|
||||
assert.equal(gone.title, 'The search stopped before it finished. Starting the scan failed (the console reported an error).');
|
||||
assert.deepEqual(gone.tips[0], 'Nothing was kept.');
|
||||
assert.match(gone.tips[1], /The game is not running any more/);
|
||||
const still = failedFeedback(false, 'The console closed the connection.', 120, false);
|
||||
assert.match(still.title, /^Narrowing down stopped before it finished\. The console closed the connection\.$/);
|
||||
assert.equal(still.tips[0], 'Your list is unchanged: it still has 120 matches.');
|
||||
assert.match(still.tips[1], /Check that the game is still running/);
|
||||
|
||||
const tooMany = tooManyFeedback(q('u32', 'exact', '0'), 4_000_000);
|
||||
assert.match(tooMany.title, /More than 4,000,000 places hold 0 as 4 bytes.*nothing was kept/);
|
||||
assert.equal(tooMany.tips.length, 3);
|
||||
});
|
||||
|
||||
test('text and group searches say what happened in the same way', () => {
|
||||
const t = { text: 'godmode', plain: true, wide: true, ignoreCase: true, fixed: false };
|
||||
const none = textFeedback(t, { hits: 0, capped: false, canceled: false, bytesSearched: all.bytes }, all, 40_000);
|
||||
assert.equal(none.title, 'Nothing found. No place in all 9.60 GB of the game\'s memory holds "godmode" as plain or wide text, in any mix of capitals. Took 40 s.');
|
||||
assert.ok(none.tips.some((x) => /Also look in the game's code and fixed data/.test(x)));
|
||||
const strict = textFeedback({ ...t, wide: false, ignoreCase: false, fixed: true }, { hits: 0, capped: false, canceled: false, bytesSearched: all.bytes }, all, 1000);
|
||||
assert.match(strict.title, /as plain text, with exactly those capitals/);
|
||||
assert.ok(strict.tips.some((x) => /Check "Ignore capitals"/.test(x)));
|
||||
assert.ok(strict.tips.some((x) => /Only plain text was searched/.test(x)));
|
||||
assert.ok(!strict.tips.some((x) => /fixed data/.test(x)));
|
||||
assert.match(textFeedback(t, { hits: 3, capped: false, canceled: false, bytesSearched: all.bytes }, all, 1000).title, /^Found 3 places holding "godmode"/);
|
||||
const capped = textFeedback(t, { hits: 200, capped: true, canceled: false, bytesSearched: 2 * GB }, all, 1000);
|
||||
assert.match(capped.title, /^Found at least 200 places/);
|
||||
assert.match(capped.tips[0], /stopped at the first 200, after 2\.00 of 9\.60 GB/);
|
||||
assert.equal(
|
||||
textFeedback(t, { hits: 2, capped: false, canceled: true, bytesSearched: 2 * GB }, all, 1000).title,
|
||||
'Canceled after searching 2.00 of 9.60 GB. 2 places found up to then are listed below.',
|
||||
);
|
||||
assert.match(textFeedback(t, { hits: 0, capped: false, canceled: true, bytesSearched: 0 }, all, 1000).title, /Nothing had been found up to then/);
|
||||
|
||||
const g = (over) => ({ groups: 0, anchorText: '2500', anchorMatches: 0, capped: false, canceled: false, bytesSearched: all.bytes, ...over });
|
||||
assert.match(groupFeedback(['100', '2500'], 64, g(), all, 9000).title, /^Nothing found\. 2500 is not anywhere in all 9\.60 GB/);
|
||||
const apart = groupFeedback(['100', '2500'], 64, g({ anchorMatches: 2341 }), all, 9000);
|
||||
assert.match(apart.title, /^No group found\. 2500 is in 2,341 places, but none of them has the other values \(100, 2500\) within 64 bytes/);
|
||||
assert.match(apart.tips[0], /bigger distance/);
|
||||
assert.match(groupFeedback(['100', '2500'], 64, g({ groups: 1, anchorMatches: 9 }), all, 9000).title, /^Found 1 group holding 100, 2500 within 64 bytes/);
|
||||
assert.match(groupFeedback(['100', '2500'], 64, g({ canceled: true, bytesSearched: 1 * GB }), all, 9000).title, /^Canceled after searching 1\.00 of 9\.60 GB\. No group had been found/);
|
||||
});
|
||||
|
||||
test('amounts and times read the way a person would say them', () => {
|
||||
assert.equal(ofBytes(3.1 * GB, 9.6 * GB), '3.10 of 9.60 GB');
|
||||
assert.equal(ofBytes(31 * GB, 96 * GB), '31.0 of 96.0 GB');
|
||||
assert.equal(ofBytes(120 * MB, 480 * MB), '120 of 480 MB');
|
||||
assert.equal(ofBytes(0, 4 * MB), '0.0 of 4.0 MB');
|
||||
// Never "9.60 of 9.60" while there is still something to do, and exactly that at the end.
|
||||
assert.equal(ofBytes(9.6 * GB - 1, 9.6 * GB), '9.59 of 9.60 GB');
|
||||
assert.equal(ofBytes(9.6 * GB, 9.6 * GB), '9.60 of 9.60 GB');
|
||||
assert.equal(clock(36_400), '0:36');
|
||||
assert.equal(clock(725_000), '12:05');
|
||||
assert.equal(leftWords(3000), 'a few seconds left');
|
||||
assert.equal(leftWords(42_000), 'about 40 s left');
|
||||
assert.equal(leftWords(100_000), 'about 1.5 min left');
|
||||
assert.equal(leftWords(26 * 60_000), 'about 26 min left');
|
||||
|
||||
// Time left comes from how fast the search has really gone, and only once there is enough to go on.
|
||||
const steady = Array.from({ length: 11 }, (_, i) => ({ at: i * 1000, fraction: i / 100 })); // 1% a second
|
||||
assert.equal(timeLeft(steady.slice(0, 3), 2000), null, 'too early to say');
|
||||
assert.equal(Math.round(timeLeft(steady, 10_000) / 1000), 90);
|
||||
assert.equal(Math.round(timeLeft(steady, 14_000) / 1000), 86, 'it keeps counting down between reports');
|
||||
// It follows a search that slows down: the last 20 seconds count, not the fast start.
|
||||
const slowing = [...Array.from({ length: 11 }, (_, i) => ({ at: i * 1000, fraction: i / 20 })), ...Array.from({ length: 30 }, (_, i) => ({ at: 11_000 + i * 1000, fraction: 0.5 + (i + 1) / 1000 }))];
|
||||
const left = timeLeft(slowing, 40_000);
|
||||
assert.ok(left > 400_000, `a slow stretch is not hidden by a fast start (${left})`);
|
||||
assert.equal(timeLeft([{ at: 0, fraction: 0.2 }, { at: 1000, fraction: 0.2 }, { at: 2000, fraction: 0.2 }, { at: 9000, fraction: 0.2 }], 9000), null, 'no movement, no promise');
|
||||
assert.equal(timeLeft([...steady.slice(0, 10), { at: 10_000, fraction: 1 }], 10_000), null);
|
||||
});
|
||||
|
||||
// ---- a real-sized game ------------------------------------------------------------------
|
||||
|
||||
test('"where to search" stays a short list for a game with thousands of blocks and 9.6 GB', () => {
|
||||
const maps = [];
|
||||
let at = 0x1000000000n;
|
||||
const add = (name, bytes, prot = 3) => {
|
||||
maps.push({ name, start: at, end: at + BigInt(bytes), offset: 0n, prot });
|
||||
at += BigInt(bytes) + 0x10000n;
|
||||
};
|
||||
maps.push({ name: 'executable', start: 0x400000n, end: 0x400000n + BigInt(113 * MB), offset: 0n, prot: 5 });
|
||||
maps.push({ name: 'executable', start: 0x400000n + BigInt(113 * MB), end: 0x400000n + BigInt(140 * MB), offset: 0n, prot: 3 });
|
||||
for (const n of [2048, 1536, 1024, 1024, 768, 512, 512, 256, 256, 128, 96, 80]) add('', n * MB);
|
||||
for (let i = 0; i < 40; i++) add('', (16 + (i % 30)) * MB);
|
||||
for (let i = 0; i < 300; i++) add('', (1 + (i % 12)) * MB);
|
||||
for (let i = 0; i < 2200; i++) add('', 0x4000 * (1 + (i % 20)));
|
||||
for (let i = 0; i < 90; i++) {
|
||||
add(`libSceModule${i}.sprx`, 0x40000 * (1 + (i % 5)), 5);
|
||||
add(`libSceModule${i}.sprx`, 0x8000 * (1 + (i % 20)));
|
||||
}
|
||||
for (let i = 0; i < 300; i++) add(`thread stack ${i}`, 0x10000 * (1 + (i % 4)));
|
||||
assert.ok(maps.length > 3000);
|
||||
|
||||
const items = listRegions(maps, { start: 0x400000n, end: 0x400000n + BigInt(140 * MB) });
|
||||
const writable = items.filter((i) => i.writable);
|
||||
assert.ok(writable.length <= 25, `a list a person can read on a phone (${writable.length} lines)`);
|
||||
assert.ok(new Set(items.map((i) => i.id)).size === items.length, 'every line has its own id');
|
||||
const total = totalBytes(items);
|
||||
assert.ok(total > 9 * GB && total < 12 * GB);
|
||||
// Everything, as slices: none over 16 MB, and together exactly the memory there is.
|
||||
const everything = scopeRanges(items, DEFAULT_SCOPE);
|
||||
const slices = sliceRanges(everything);
|
||||
assert.ok(slices.every((s) => s.length <= SCAN_SLICE_BYTES && s.length > 0));
|
||||
assert.equal(slices.reduce((n, s) => n + s.length, 0), rangesBytes(everything));
|
||||
assert.equal(rangesBytes(everything), total);
|
||||
// One button unchecks the huge blocks, and the summary follows.
|
||||
const huge = hugeRegionIds(writable, 64 * MB);
|
||||
assert.equal(huge.length, 12);
|
||||
const picked = rangesBytes(scopeRanges(items, { ...DEFAULT_SCOPE, mode: 'pick', off: huge }));
|
||||
assert.ok(picked < total - 8 * GB, 'most of a big game is in a dozen huge blocks');
|
||||
assert.equal(rangesBytes(scopeRanges(items, { ...DEFAULT_SCOPE, mode: 'pick', off: writable.map((i) => i.id) })), 0);
|
||||
});
|
||||
@@ -0,0 +1,152 @@
|
||||
// What the Memory, Code, Debug and Engine screens lean on to say plainly what
|
||||
// happened: address boxes that explain a typo, telling memory that is not
|
||||
// there from memory full of zeros, bytes as people type them, a pointer
|
||||
// search that can be given up, and an engine search that failed as opposed to
|
||||
// one that found nothing. Each of these was a silent or misleading screen
|
||||
// before; these are the checks that keep them from coming back.
|
||||
|
||||
import test, { after, before } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { startMock, GAME } from '../mock/mock-ps5.mjs';
|
||||
import { startBridge } from '../bridge/server.mjs';
|
||||
import { openWebSocketStream } from '../src/lib/transport.ts';
|
||||
import { Ps5Debug } from '../src/lib/ps5debug.ts';
|
||||
import { parseTypedBytes } from '../src/lib/bytes.ts';
|
||||
import { createGameContext, parseAddress, readableAt } from '../src/lib/locations.ts';
|
||||
import { findPointerPaths } from '../src/lib/pointerscan.ts';
|
||||
import { detectEngine } from '../src/lib/engines.ts';
|
||||
|
||||
const PORT_OFFSET = Number(process.env.CS_PORT_OFFSET ?? 0);
|
||||
const TCP = 17700 + PORT_OFFSET;
|
||||
let mock, bridge, bridgePort, dbg, ctx;
|
||||
|
||||
const P = (off) => GAME.heapBase + BigInt(off);
|
||||
|
||||
before(async () => {
|
||||
mock = startMock({ tcpPort: TCP, udpPort: 0, tick: false });
|
||||
await mock.ready;
|
||||
bridge = startBridge({ port: 0, host: '127.0.0.1', eventPort: null, demo: { host: '127.0.0.1', port: TCP, udpPort: 0, label: 'test' } });
|
||||
bridgePort = await bridge.ready;
|
||||
dbg = new Ps5Debug(await openWebSocketStream(`ws://127.0.0.1:${bridgePort}/ws?host=127.0.0.1&port=${TCP}`), { timeoutMs: 4000 });
|
||||
ctx = createGameContext({ pid: GAME.pid, dbg: () => dbg, x86: null });
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
if (dbg && !dbg.closed) dbg.close();
|
||||
await bridge.close();
|
||||
await mock.close();
|
||||
});
|
||||
|
||||
// ---- address boxes ---------------------------------------------------------------
|
||||
|
||||
test('parseAddress: a typo gets an explanation, not jargon', async () => {
|
||||
assert.equal(await parseAddress(ctx, ' 0x880012340 '), 0x880012340n);
|
||||
assert.equal(await parseAddress(ctx, 'game+1000'), GAME.imageBase + 0x1000n);
|
||||
// "game+" with nothing after it used to say: "" is not a hex number.
|
||||
await assert.rejects(parseAddress(ctx, 'game+'), /Type the offset after "game\+", for example game\+1A2B3C/);
|
||||
await assert.rejects(parseAddress(ctx, 'libgame.prx+'), /Type the offset after "libgame\.prx\+"/);
|
||||
// Says what an address looks like, and keeps the wording other callers match on.
|
||||
await assert.rejects(parseAddress(ctx, 'hello'), /"hello" is not a hex number\. Addresses look like/);
|
||||
await assert.rejects(parseAddress(ctx, '-5'), /not a hex number/);
|
||||
await assert.rejects(parseAddress(ctx, ''), /Enter an address/);
|
||||
});
|
||||
|
||||
test('parseAddress: nothing longer than 64 bits gets through', async () => {
|
||||
assert.equal(await parseAddress(ctx, 'FFFFFFFFFFFFFFFF'), 0xffff_ffff_ffff_ffffn);
|
||||
await assert.rejects(parseAddress(ctx, 'FFFFFFFFFFFFFFFFFFFF'), /too large/);
|
||||
await assert.rejects(parseAddress(ctx, 'game+FFFFFFFFFFFFFFFF'), /too large/);
|
||||
});
|
||||
|
||||
test('readableAt: tells real memory from the zeros the console answers with where there is none', async () => {
|
||||
assert.equal(await readableAt(ctx, P(GAME.vars.health), 4), 'all');
|
||||
assert.equal(await readableAt(ctx, 0x10n, 128), 'none');
|
||||
assert.equal(await readableAt(ctx, 0n, 8), 'none');
|
||||
// The heap is 0x40000 long: a page that starts inside it and runs off the end.
|
||||
assert.equal(await readableAt(ctx, P(0x40000 - 0x40), 0x80), 'part');
|
||||
// The executable's code runs straight into its data: two maps, one unbroken stretch.
|
||||
assert.equal(await readableAt(ctx, GAME.dataBase - 0x40n, 0x80), 'all');
|
||||
// And the read itself really does come back as zeros rather than an error, which is why screens must ask.
|
||||
assert.deepEqual([...(await dbg.read(GAME.pid, 0x10n, 8))], [0, 0, 0, 0, 0, 0, 0, 0]);
|
||||
});
|
||||
|
||||
// ---- bytes as typed ----------------------------------------------------------------
|
||||
|
||||
test('parseTypedBytes: the ways people write bytes, and plain words for the rest', () => {
|
||||
assert.deepEqual([...parseTypedBytes('90 90 90')], [0x90, 0x90, 0x90]);
|
||||
assert.deepEqual([...parseTypedBytes('90,90')], [0x90, 0x90]);
|
||||
assert.deepEqual([...parseTypedBytes('0x90 0X0f')], [0x90, 0x0f]);
|
||||
assert.deepEqual([...parseTypedBytes(' 64000000 ')], [0x64, 0, 0, 0]);
|
||||
assert.throws(() => parseTypedBytes(' '), /Enter some bytes/);
|
||||
assert.throws(() => parseTypedBytes('9'), /"9" is half a byte.*like 09/);
|
||||
assert.throws(() => parseTypedBytes('90 9'), /half a byte/);
|
||||
// The shared hexToBytes would have dropped the "zz" and written one byte.
|
||||
assert.throws(() => parseTypedBytes('90 zz'), /"zz" is not a byte/);
|
||||
assert.throws(() => parseTypedBytes('nop'), /not a byte/);
|
||||
});
|
||||
|
||||
// ---- pointer search ----------------------------------------------------------------
|
||||
|
||||
test('findPointerPaths: a search that is given up ends, with what it had', async () => {
|
||||
const target = P(GAME.inventory + 12);
|
||||
// Not given up: the path through the static player pointer is found.
|
||||
const all = await findPointerPaths(ctx, target, { maxDepth: 2 }, () => {});
|
||||
assert.ok(all.length > 0);
|
||||
|
||||
// Given up before it starts: no pass over memory is made at all.
|
||||
const before = new AbortController();
|
||||
before.abort();
|
||||
const progress = [];
|
||||
assert.deepEqual(await findPointerPaths(ctx, target, { maxDepth: 3, signal: before.signal }, (t) => progress.push(t)), []);
|
||||
assert.deepEqual(progress, []);
|
||||
|
||||
// Given up during the first level: it does not go on to the next one.
|
||||
const during = new AbortController();
|
||||
const seen = [];
|
||||
const partial = await findPointerPaths(ctx, P(GAME.vars.gold), { maxDepth: 4, signal: during.signal }, (t) => {
|
||||
seen.push(t);
|
||||
during.abort();
|
||||
});
|
||||
assert.ok(Array.isArray(partial));
|
||||
assert.equal(seen.length, 1, 'only the pass in hand was started');
|
||||
});
|
||||
|
||||
// ---- engine search -----------------------------------------------------------------
|
||||
|
||||
const fakeCtx = () => createGameContext({ pid: 1, dbg: () => ({}), x86: null });
|
||||
const none = async () => null;
|
||||
const boom = async () => {
|
||||
throw new Error('The console closed the connection');
|
||||
};
|
||||
|
||||
test('detectEngine: "could not finish" is told apart from "this game names nothing"', async () => {
|
||||
const clean = await detectEngine(fakeCtx(), {
|
||||
identify: async () => ({ engine: 'unknown', evidence: ['nothing seen'] }),
|
||||
detectors: { unreal: none, unity: none, godot: none, gamemaker: none, cpp: none },
|
||||
});
|
||||
assert.equal(clean.helper, null);
|
||||
assert.ok(!clean.failed, 'every look finished and found nothing: a verdict');
|
||||
|
||||
const dropped = await detectEngine(fakeCtx(), {
|
||||
identify: async () => ({ engine: 'unknown', evidence: ['nothing seen'] }),
|
||||
detectors: { unreal: boom, unity: none, godot: none, gamemaker: none, cpp: none },
|
||||
});
|
||||
assert.equal(dropped.helper, null);
|
||||
assert.equal(dropped.failed, true);
|
||||
assert.ok(dropped.steps.some((s) => /stopped with an error/.test(s)));
|
||||
|
||||
const firstLook = await detectEngine(fakeCtx(), {
|
||||
identify: boom,
|
||||
detectors: { unreal: none, unity: none, godot: none, gamemaker: none, cpp: none },
|
||||
});
|
||||
assert.equal(firstLook.failed, true);
|
||||
|
||||
// An error on the way does not matter once something was found.
|
||||
const helper = { engine: 'cpp', label: 'C++ class names', describeObject: none, classInfo: none, findClasses: async () => [], findObjects: async () => [] };
|
||||
const found = await detectEngine(fakeCtx(), {
|
||||
identify: async () => ({ engine: 'unknown', evidence: [] }),
|
||||
detectors: { unreal: boom, unity: none, godot: none, gamemaker: none, cpp: async () => helper },
|
||||
});
|
||||
assert.equal(found.helper, helper);
|
||||
assert.ok(!found.failed);
|
||||
});
|
||||
+181
-3
@@ -29,7 +29,7 @@ import {
|
||||
parseText,
|
||||
readProgress,
|
||||
} from '../src/lib/learn.ts';
|
||||
import { PracticeGame } from '../src/lib/practice/game.ts';
|
||||
import { PracticeGame, practiceTitle } from '../src/lib/practice/game.ts';
|
||||
import { PracticeConsole, connectPractice } from '../src/lib/practice/console.ts';
|
||||
import { GoalTracker, patience, showMe } from '../src/lib/practice/coach.ts';
|
||||
import { AutoScan, ScanSession, SnapshotScan, compareKind, encodeValue, scannableRegions, valueKind } from '../src/lib/scanner.ts';
|
||||
@@ -146,6 +146,33 @@ test('every lesson in learn/ passes the checker, and the index agrees with the f
|
||||
for (const l of lessons) for (const s of l.steps) if (s.kind === 'practice') assert.ok(s.hints.length >= 1 && s.done, `${l.id}: ${s.title}`);
|
||||
});
|
||||
|
||||
test('the checker reads the words too: app names, places on screen, spelling', () => {
|
||||
const dir = fs.mkdtempSync(new URL('../node_modules/.learn-check-', import.meta.url).pathname);
|
||||
try {
|
||||
fs.cpSync(new URL('../learn/', import.meta.url), dir, { recursive: true });
|
||||
const file = `${dir}/lessons/find-a-number.json`;
|
||||
const doc = JSON.parse(fs.readFileSync(file, 'utf8'));
|
||||
const said = (change) => {
|
||||
const copy = clone(doc);
|
||||
change(copy);
|
||||
fs.writeFileSync(file, JSON.stringify(copy));
|
||||
return checkLessons(dir).problems.join('\n');
|
||||
};
|
||||
assert.equal(said(() => {}), '');
|
||||
// A button that was renamed in the app and not in the lesson.
|
||||
assert.match(said((d) => (d.steps[2].text = d.steps[2].text.replace('**Narrow down**', '**Refine**'))), /steps\[2\]: "Refine" is in bold like a button/);
|
||||
// The game's own buttons and readouts count as names.
|
||||
assert.equal(said((d) => (d.steps[1].text += ' Press **Fire** and watch **Ammo**.')), '');
|
||||
// Numbers to type are not names.
|
||||
assert.equal(said((d) => (d.steps[3].text += ' Or **123**.')), '');
|
||||
assert.match(said((d) => (d.steps[1].hints[0] += ' It is at the bottom of the window.')), /hints\[0\]: "at the bottom of the window": do not say where/);
|
||||
assert.match(said((d) => (d.steps[0].text += ' Its colour changes.')), /steps\[0\].text: "colour": use US spelling/);
|
||||
assert.match(said((d) => (d.steps[4].explain += ' Not for online games.')), /steps\[4\].explain: "online"/);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('the checker refuses what does not fit, and says where', () => {
|
||||
const good = read('lessons/find-a-number.json');
|
||||
assert.equal(checkLesson(good).id, 'find-a-number');
|
||||
@@ -266,7 +293,7 @@ test('downloading: GitHub by default, a local copy when asked, and each way it c
|
||||
return mode === 'missing' ? response(404, 'Not Found') : mode === 'down' ? response(503, '') : response(200, index);
|
||||
},
|
||||
});
|
||||
await assert.rejects(lib.loadIndex(), (e) => e instanceof LearnError && e.problem === 'unpublished' && /not published yet/.test(e.message));
|
||||
await assert.rejects(lib.loadIndex(), (e) => e instanceof LearnError && e.problem === 'unpublished' && /not on CheatSpecter’s GitHub page yet/.test(e.message));
|
||||
mode = 'offline';
|
||||
await assert.rejects(lib.loadIndex(), (e) => e.problem === 'offline' && /connected to the internet/.test(e.message));
|
||||
mode = 'down';
|
||||
@@ -274,6 +301,33 @@ test('downloading: GitHub by default, a local copy when asked, and each way it c
|
||||
mode = 'ok';
|
||||
assert.equal((await lib.loadIndex()).index.courses.length, 3, 'trying again works once it can');
|
||||
|
||||
// One lesson failing after the list arrived is said to be about that lesson, not about all of them.
|
||||
let lessonMode = 'missing';
|
||||
lib = new LessonLibrary({
|
||||
fetch: async (url) => {
|
||||
if (url.endsWith('index.json')) return response(200, index);
|
||||
if (lessonMode === 'offline') throw new TypeError('Failed to fetch');
|
||||
return lessonMode === 'missing' ? response(404, 'Not Found') : lessonMode === 'down' ? response(500, '') : response(200, lesson);
|
||||
},
|
||||
});
|
||||
await assert.rejects(lib.loadLesson('hold-it'), (e) => e.problem === 'unpublished' && /^This lesson is in the list/.test(e.message) && !/GitHub page yet/.test(e.message));
|
||||
lessonMode = 'offline';
|
||||
await assert.rejects(lib.loadLesson('hold-it'), (e) => e.problem === 'offline' && /^This lesson did not download/.test(e.message));
|
||||
lessonMode = 'down';
|
||||
await assert.rejects(lib.loadLesson('hold-it'), (e) => e.problem === 'offline' && /500/.test(e.message) && /instead of the lesson\./.test(e.message));
|
||||
lessonMode = 'ok';
|
||||
assert.equal((await lib.loadLesson('hold-it')).id, 'hold-it', 'and it opens once it can be fetched');
|
||||
// A download that breaks off partway is a connection problem, not a broken lesson.
|
||||
lib = new LessonLibrary({ fetch: async () => ({ ok: true, status: 200, text: () => Promise.reject(new TypeError('network error')) }) });
|
||||
await assert.rejects(lib.loadIndex(), (e) => e.problem === 'offline' && /partway/.test(e.message));
|
||||
|
||||
// A connection that stalls: "Downloading…" ends, with something to do next, and trying again works.
|
||||
let stalled = true;
|
||||
lib = new LessonLibrary({ patienceMs: 40, fetch: (url) => (stalled ? new Promise(() => {}) : Promise.resolve(response(200, url.endsWith('index.json') ? index : lesson))) });
|
||||
await assert.rejects(lib.loadIndex(), (e) => e.problem === 'offline' && /taking too long/.test(e.message));
|
||||
stalled = false;
|
||||
assert.equal((await lib.loadLesson('hold-it')).id, 'hold-it');
|
||||
|
||||
// Things that are not lessons.
|
||||
for (const body of ['<html>captive portal</html>', '{"format":"cheatspecter-lessons"}', JSON.stringify({ ...index, extra: 1 }), 'x'.repeat(300 * 1024)]) {
|
||||
lib = new LessonLibrary({ fetch: async () => response(200, body) });
|
||||
@@ -335,9 +389,25 @@ test('progress: kept by the server when it can, in the browser when it cannot, a
|
||||
store.save(a);
|
||||
await wait(500);
|
||||
assert.deepEqual(calls, ['GET', 'PUT']);
|
||||
// Served by something known not to keep settings: not asked at all, so nothing shows up as a failed request.
|
||||
calls.length = 0;
|
||||
store = new ProgressStore({ fetch: async (url, init = {}) => (calls.push(init.method ?? 'GET'), response(400, '')) });
|
||||
assert.deepEqual(await store.load(false), emptyProgress());
|
||||
store.save(a);
|
||||
await wait(500);
|
||||
assert.deepEqual(calls, []);
|
||||
// No server at all.
|
||||
store = new ProgressStore({ fetch: async () => { throw new TypeError('Failed to fetch'); } });
|
||||
assert.deepEqual(await store.load(), emptyProgress());
|
||||
// A server that never answers: the Learn section still opens, and the server's copy is left alone for the visit.
|
||||
calls.length = 0;
|
||||
store = new ProgressStore({ patienceMs: 40, fetch: (url, init = {}) => (calls.push(init.method ?? 'GET'), new Promise(() => {})) });
|
||||
const began = Date.now();
|
||||
assert.deepEqual(await store.load(), emptyProgress());
|
||||
assert.ok(Date.now() - began < 1000, 'it does not wait for good');
|
||||
store.save(a);
|
||||
await wait(500);
|
||||
assert.deepEqual(calls, ['GET'], 'nothing is written over a copy that could not be read');
|
||||
});
|
||||
|
||||
// ---- the practice game under the real tools -------------------------------------------
|
||||
@@ -610,6 +680,66 @@ test('goals: counted in order, from the moment each part comes up, and done stay
|
||||
b.close();
|
||||
});
|
||||
|
||||
test('goals say how far along they are, and when the search has lost the value', async () => {
|
||||
const b = await bench(RANGE);
|
||||
const { game, con } = b;
|
||||
const regions = await regionsOf(b);
|
||||
const scan = new ScanSession(() => b.session.scan, game.pid, U32);
|
||||
|
||||
// A list that never had the value in it: the learner typed a number the game was not showing.
|
||||
let tracker = new GoalTracker({ check: 'narrowed', label: 'x', field: 'player.ammo', atMost: 1 }, b, { settleMs: 320 });
|
||||
assert.equal(tracker.advice(), null, 'nothing to say before any search');
|
||||
await scan.first(regions, compareKind('exact'), encodeValue(U32, '31'), quiet);
|
||||
if (con.lastScan.size > 0) assert.match(tracker.advice(), /^Your last search left \d+ match(es)?, and the ammo is not among them\./);
|
||||
// The right number: the value is in the list, which is merely long. That needs no telling.
|
||||
await scan.end();
|
||||
const good = new ScanSession(() => b.session.scan, game.pid, U32);
|
||||
await good.first(regions, compareKind('exact'), encodeValue(U32, '30'), quiet);
|
||||
assert.ok(con.lastScan.size > 1);
|
||||
assert.equal(tracker.advice(), null);
|
||||
// A wrong answer while narrowing throws the value out, with others still listed (the decoys hold 30).
|
||||
con.press('fire');
|
||||
await good.next(compareKind('exact'), encodeValue(U32, '30'), quiet);
|
||||
assert.ok(con.lastScan.size > 0);
|
||||
assert.match(tracker.advice(), /the ammo is not among them.*Press New search and start over\.$/);
|
||||
assert.doesNotMatch(tracker.advice(), /0x|undefined/);
|
||||
await tracker.check();
|
||||
assert.equal(tracker.done, false);
|
||||
tracker.stop();
|
||||
|
||||
// A goal about a value the learner gets at through another one is left alone.
|
||||
tracker = new GoalTracker({ check: 'value', label: 'x', field: 'player.ammo', is: 'atLeast', value: 999 }, b);
|
||||
assert.equal(tracker.advice(), null);
|
||||
tracker.stop();
|
||||
// After a restart the old list means nothing.
|
||||
tracker = new GoalTracker({ check: 'held', label: 'x', field: 'player.ammo', action: 'fire', times: 3 }, b, { settleMs: 320 });
|
||||
assert.match(tracker.advice(), /not among them/);
|
||||
con.restart();
|
||||
assert.equal(tracker.advice(), null);
|
||||
|
||||
// Held: counted shot by shot, and back to nothing when one gets through.
|
||||
assert.equal(tracker.progress, null);
|
||||
const entry = await valueEntry(b.ctx, game.address('player.ammo'), U32, '30');
|
||||
await b.add(entry);
|
||||
con.press('fire');
|
||||
await until(() => tracker.progress === '1 of 3', 'the first held shot');
|
||||
con.press('fire');
|
||||
await until(() => tracker.progress === '2 of 3', 'the second held shot');
|
||||
await b.runner.disable(entry);
|
||||
con.press('fire');
|
||||
await until(() => tracker.progress === null, 'the streak to break');
|
||||
tracker.stop();
|
||||
|
||||
tracker = new GoalTracker({ check: 'pressed', label: 'x', action: 'fire', times: 3 }, b);
|
||||
assert.equal(tracker.progress, null);
|
||||
con.press('fire');
|
||||
con.press('fire');
|
||||
assert.equal(tracker.progress, '2 of 3');
|
||||
tracker.stop();
|
||||
await good.end();
|
||||
b.close();
|
||||
});
|
||||
|
||||
// ---- every lesson can be completed ---------------------------------------------------
|
||||
|
||||
const { lessons } = checkLessons();
|
||||
@@ -627,7 +757,8 @@ for (const lesson of lessons) {
|
||||
benches.delete(step.game);
|
||||
}
|
||||
if (!benches.has(step.game)) {
|
||||
const made = await bench(lesson.games[step.game], 'CSPR' + String(benches.size + 1).padStart(5, '0'));
|
||||
// Under the title the lesson screen uses, so the game is laid out exactly as a learner gets it.
|
||||
const made = await bench(lesson.games[step.game], practiceTitle(lesson.id, step.game));
|
||||
made.con.start();
|
||||
benches.set(step.game, made);
|
||||
}
|
||||
@@ -648,6 +779,53 @@ for (const lesson of lessons) {
|
||||
});
|
||||
}
|
||||
|
||||
test('the pointer challenge as a learner meets it: the shortest path is a coincidence, and nothing pretends otherwise', async () => {
|
||||
const lesson = lessons.find((l) => l.id === 'pointers');
|
||||
const index = lesson.steps.findIndex((s) => s.kind === 'practice' && s.challenge);
|
||||
const step = lesson.steps[index];
|
||||
const field = goalParts(step.goal)[0].field;
|
||||
// The lesson's text and hints warn about this, so it has to stay true of the game as the screen starts it.
|
||||
let b = await bench(lesson.games[step.game], practiceTitle(lesson.id, step.game));
|
||||
const kind = valueKind('u32');
|
||||
const paths = await findPointerPaths(b.ctx, b.game.address(field), { maxDepth: 3 }, quiet);
|
||||
assert.ok(paths.length >= 2 && paths[0].depth === 1, 'the search offers a one-pointer path first');
|
||||
assert.ok(step.text.includes('more than one path') && step.hints.some((h) => /coincidence/.test(h) && /2 pointers/.test(h)), 'and the lesson says so');
|
||||
// Taking the first, as an earlier wording of the hint told the learner to: fine today, wrong after a restart.
|
||||
const first = await valueEntry(b.ctx, b.game.address(field), kind, '99', paths[0].location);
|
||||
assert.equal(await resolveLocation(b.ctx, first.at), b.game.address(field));
|
||||
b.con.restart();
|
||||
assert.notEqual(await resolveLocation(b.ctx, first.at).catch(() => 0n), b.game.address(field));
|
||||
b.close();
|
||||
|
||||
// "Show me" takes the path the game itself follows, says why, and then uses the cheat rather than writing behind its back.
|
||||
b = await bench(lesson.games[step.game], practiceTitle(lesson.id, step.game));
|
||||
const tracker = new GoalTracker(step.goal, b, { settleMs: SETTLE });
|
||||
for (let guard = 0; !tracker.done; guard++) {
|
||||
assert.ok(guard < 4, b.lines.join('\n'));
|
||||
const item = tracker.current;
|
||||
await showMe(item.goal, b, { settleMs: SETTLE });
|
||||
await until(async () => (await tracker.check(), item.done), item.goal.label, patience(item.goal, SETTLE));
|
||||
}
|
||||
tracker.stop();
|
||||
const said = b.lines.join('\n');
|
||||
assert.match(said, /Took the one with 2 pointers, because the shorter one only works by coincidence/);
|
||||
assert.match(said, /Set ".+" in Your cheats to hold 99, and switched it on\. It followed its pointer path to the lives/);
|
||||
assert.doesNotMatch(said, /wrote 99 over it/);
|
||||
const cheat = b.entries.find((e) => e.type === 'value');
|
||||
assert.deepEqual([cheat.at.kind, cheat.at.offsets.length, cheat.value, b.runner.isOn(cheat.id)], ['pointer', 2, '99', true]);
|
||||
assert.equal(b.game.get(field), 99);
|
||||
b.close();
|
||||
});
|
||||
|
||||
test('a challenge about surviving an update only counts a cheat that was made before it', () => {
|
||||
const lesson = lessons.find((l) => l.id === 'game-updates');
|
||||
const step = lesson.steps.find((s) => s.kind === 'practice' && s.challenge);
|
||||
assert.deepEqual(
|
||||
goalParts(step.goal).map((g) => g.check),
|
||||
['off', 'updated', 'off', 'held'],
|
||||
);
|
||||
});
|
||||
|
||||
test('what Show me says is plain: no addresses, no jargon it did not explain', async () => {
|
||||
const b = await bench(RANGE);
|
||||
for (const goal of [
|
||||
|
||||
@@ -351,3 +351,35 @@ test('keeps cheat files on the console, one per game version', { skip }, async (
|
||||
}
|
||||
assert.equal(fs.readdirSync(path.join(FAKE_ROOT, 'data/cheatspecter/cheats')).length, 2);
|
||||
});
|
||||
|
||||
test('closing a debugger connection gets one goodbye, not two', { skip }, async () => {
|
||||
// Both ends of the relay used to send a close frame when the browser closed
|
||||
// the socket (the echo, then "the console closed the connection"). Browsers
|
||||
// report the second one as an error in their console on every normal close.
|
||||
const frames = await new Promise((resolve, reject) => {
|
||||
const s = net.connect(PORT, '127.0.0.1');
|
||||
let data = Buffer.alloc(0);
|
||||
let upgraded = false;
|
||||
s.on('connect', () =>
|
||||
s.write(`GET /ws HTTP/1.1\r\nHost: 127.0.0.1:${PORT}\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\nSec-WebSocket-Version: 13\r\n\r\n`),
|
||||
);
|
||||
s.on('data', (d) => {
|
||||
data = Buffer.concat([data, d]);
|
||||
const end = data.indexOf('\r\n\r\n');
|
||||
if (!upgraded && end >= 0) {
|
||||
upgraded = true;
|
||||
data = data.subarray(end + 4);
|
||||
// A masked close frame with status 1000, as a browser sends.
|
||||
s.write(Buffer.from([0x88, 0x82, 0, 0, 0, 0, 0x03, 0xe8]));
|
||||
}
|
||||
});
|
||||
s.on('error', reject);
|
||||
s.on('close', () => {
|
||||
// Server frames are unmasked and these are all short: opcode byte, length byte, payload.
|
||||
const seen = [];
|
||||
for (let at = 0; at + 2 <= data.length; at += 2 + (data[at + 1] & 0x7f)) seen.push(data[at] & 0x0f);
|
||||
resolve(seen);
|
||||
});
|
||||
});
|
||||
assert.deepEqual(frames, [0x8]);
|
||||
});
|
||||
@@ -0,0 +1,181 @@
|
||||
// Regression tests from the quality pass on connecting, the Cheats tab and the cheat editors.
|
||||
|
||||
import test, { after, before } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import net from 'node:net';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { execFileSync, spawn } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { startMock } from '../mock/mock-ps5.mjs';
|
||||
import { parseOwnTable } from '../src/lib/table.ts';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const src = (file) => fs.readFileSync(path.join(ROOT, file), 'utf8');
|
||||
|
||||
// ---- a table file with damaged cheats ---------------------------------------------------------
|
||||
|
||||
const file = (cheats) => JSON.stringify({ format: 'cheatspecter', formatVersion: 1, game: { titleId: 'PPSA99999', version: '01.000.000' }, cheats });
|
||||
|
||||
test('a cheat with its main parts missing is left out instead of breaking the page', () => {
|
||||
const table = parseOwnTable(
|
||||
file([
|
||||
{ type: 'patch', name: 'No location' },
|
||||
{ type: 'patch', name: 'Location is text', at: 'hello', on: '90' },
|
||||
{ type: 'patch', name: 'Bytes are a number', at: { kind: 'module', offset: '1000' }, on: 5 },
|
||||
{ type: 'value', name: 'No place' },
|
||||
{ type: 'value', name: 'Unknown type', at: { kind: 'address', address: '880012340' }, valueType: 'nope', value: '1', freeze: true },
|
||||
{ type: 'call', name: 'No function' },
|
||||
{ type: 'inject', name: 'No line' },
|
||||
{ type: 'script', name: 'No code', code: 7 },
|
||||
{ type: 'patch', name: 'Pointer without offsets', at: { kind: 'pointer', base: '10100' }, on: '90' },
|
||||
{ type: 'patch', name: 'Good', at: { kind: 'module', offset: '1000' }, on: '90', off: '89' },
|
||||
]),
|
||||
);
|
||||
assert.deepEqual(table.entries.map((e) => e.name), ['Good']);
|
||||
});
|
||||
|
||||
test('small slips in a hand-written file are put right rather than refused', () => {
|
||||
const table = parseOwnTable(
|
||||
file([
|
||||
// A number where text belongs, a pattern with no distance, and extra places of which one is damaged.
|
||||
{ type: 'value', name: 'V', at: { kind: 'pointer', base: '10100', offsets: ['0x340'] }, valueType: 'u32', value: 7 },
|
||||
{ type: 'call', name: 'C', fn: { kind: 'module', offset: '4000' }, args: [2, '0x3'] },
|
||||
{ type: 'patch', name: 'P', at: { kind: 'signature', pattern: '89 81' }, on: '9090', more: [null, 5, { at: { kind: 'module', offset: '20' }, on: '90' }] },
|
||||
{ type: 'patch', name: 'Q', at: { kind: 'module', offset: '10' }, on: '90', more: 'nonsense' },
|
||||
]),
|
||||
);
|
||||
const [v, c, p, q] = table.entries;
|
||||
assert.equal(v.value, '7');
|
||||
assert.equal(v.freeze, false);
|
||||
assert.deepEqual(c.args, ['2', '0x3']);
|
||||
assert.equal(p.at.offset, 0);
|
||||
assert.deepEqual(p.more, [{ at: { kind: 'module', offset: '20' }, on: '90' }]);
|
||||
assert.equal(q.more, undefined);
|
||||
});
|
||||
|
||||
// ---- things that were wrong on screen, pinned in the source ------------------------------------
|
||||
|
||||
test('every dialog keeps its buttons in reach and hands focus back', () => {
|
||||
const dialog = src('src/components/ui/dialog.tsx');
|
||||
assert.match(dialog, /data-slot="dialog-footer"[\s\S]*?sticky/, 'the footer stays at the bottom of a scrolling dialog');
|
||||
assert.match(dialog, /max-h-\[calc\(100dvh-2rem\)\]/, 'a dialog is never taller than the screen');
|
||||
assert.match(dialog, /onCloseAutoFocus/, 'focus returns to what opened the dialog');
|
||||
});
|
||||
|
||||
test('the guide is not held to the ordinary dialog height', () => {
|
||||
assert.match(src('src/ui/Wizard.tsx'), /h-dvh max-h-none/);
|
||||
});
|
||||
|
||||
test('editors are forms that Enter submits, and are not closed by a stray click', () => {
|
||||
const editor = src('src/ui/EntryEditor.tsx');
|
||||
assert.match(editor, /<form\s+id="entry-form"/);
|
||||
assert.match(editor, /type="submit" form="entry-form"/);
|
||||
assert.match(editor, /onInteractOutside: \(e: Event\) => e\.preventDefault\(\)/);
|
||||
assert.match(src('src/ui/CodeCheatEditor.tsx'), /useCloseGuard\(props\.dirty/);
|
||||
});
|
||||
|
||||
test('the page has something to show when a part of it fails', () => {
|
||||
assert.match(src('src/ui/bits.tsx'), /getDerivedStateFromError/);
|
||||
assert.match(src('src/ui/App.tsx'), /<Safe what="this page">/);
|
||||
const tabs = src('src/ui/ConsoleView.tsx').match(/<Safe what="the \w+ tab">/g) ?? [];
|
||||
assert.equal(tabs.length, 6, 'each tab is fenced off from the others');
|
||||
});
|
||||
|
||||
// ---- the payload's WebSocket ------------------------------------------------------------------
|
||||
|
||||
const BIN = path.join(ROOT, 'payload/build/cheatspecter-host');
|
||||
const PORT_OFFSET = Number(process.env.CS_PORT_OFFSET ?? 0);
|
||||
const PORT = 18790 + PORT_OFFSET;
|
||||
const MOCK = 17490 + PORT_OFFSET;
|
||||
const EVENTS = 17790 + PORT_OFFSET;
|
||||
|
||||
let skip = false;
|
||||
try {
|
||||
if (!fs.existsSync(path.join(ROOT, 'dist/index.html'))) throw new Error('dist missing');
|
||||
execFileSync(process.execPath, [path.join(ROOT, 'scripts/build-payload.mjs'), 'host'], { stdio: 'pipe' });
|
||||
} catch {
|
||||
skip = 'needs a C compiler and a built web app (npm run build)';
|
||||
}
|
||||
|
||||
let mock, proc;
|
||||
const FAKE_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), 'cs-qa-root-'));
|
||||
|
||||
before(async () => {
|
||||
if (skip) return;
|
||||
mock = startMock({ tcpPort: MOCK, udpPort: 0, tick: false, eventPort: EVENTS });
|
||||
await mock.ready;
|
||||
const env = { ...process.env, CS_PORT: String(PORT), CS_DEBUGGER_PORT: String(MOCK), CS_EVENT_PORT: String(EVENTS), CS_ROOT: FAKE_ROOT };
|
||||
proc = spawn(BIN, [], { env, stdio: 'ignore' });
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const ok = await new Promise((resolve) => {
|
||||
const s = net.connect(PORT, '127.0.0.1');
|
||||
s.once('connect', () => (s.destroy(), resolve(true)));
|
||||
s.once('error', () => resolve(false));
|
||||
});
|
||||
if (ok) return;
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
}
|
||||
throw new Error('payload did not start');
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
proc?.kill('SIGKILL');
|
||||
await mock?.close?.();
|
||||
fs.rmSync(FAKE_ROOT, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
/** Opens /ws by hand, closes it the way a browser does, and returns every frame the server sent, as [opcode, payload]. */
|
||||
function closeAndCollect() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const sock = net.connect(PORT, '127.0.0.1');
|
||||
let buf = Buffer.alloc(0);
|
||||
let upgraded = false;
|
||||
const timer = setTimeout(() => (sock.destroy(), reject(new Error('the server never closed the socket'))), 5000);
|
||||
sock.on('connect', () => {
|
||||
sock.write(
|
||||
`GET /ws HTTP/1.1\r\nHost: 127.0.0.1:${PORT}\r\nOrigin: http://127.0.0.1:${PORT}\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n` +
|
||||
`Sec-WebSocket-Key: ${crypto.randomBytes(16).toString('base64')}\r\nSec-WebSocket-Version: 13\r\n\r\n`,
|
||||
);
|
||||
});
|
||||
sock.on('data', (d) => {
|
||||
buf = Buffer.concat([buf, d]);
|
||||
if (!upgraded) {
|
||||
const end = buf.indexOf('\r\n\r\n');
|
||||
if (end < 0) return;
|
||||
assert.match(buf.subarray(0, end).toString(), /^HTTP\/1\.1 101/);
|
||||
buf = buf.subarray(end + 4);
|
||||
upgraded = true;
|
||||
// A masked close frame, code 1000: what a browser sends for ws.close(1000).
|
||||
const mask = Buffer.from([1, 2, 3, 4]);
|
||||
const body = Buffer.from([0x03, 0xe8]).map((b, i) => b ^ mask[i]);
|
||||
sock.write(Buffer.concat([Buffer.from([0x88, 0x82]), mask, body]));
|
||||
}
|
||||
});
|
||||
sock.on('error', reject);
|
||||
sock.on('close', () => {
|
||||
clearTimeout(timer);
|
||||
const frames = [];
|
||||
for (let at = 0; at + 2 <= buf.length; ) {
|
||||
const len = buf[at + 1] & 0x7f; // control frames are always short
|
||||
frames.push([buf[at] & 0x0f, buf.subarray(at + 2, at + 2 + len)]);
|
||||
at += 2 + len;
|
||||
}
|
||||
resolve(frames);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test('closing a debugger connection gets exactly one close frame back', { skip }, async () => {
|
||||
// Two used to come back (the echo, then "the console closed the connection"),
|
||||
// which current browsers report in the console as a failed WebSocket on every page load.
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const frames = await closeAndCollect();
|
||||
const closes = frames.filter(([op]) => op === 0x8);
|
||||
assert.equal(closes.length, 1, `got ${closes.length} close frames`);
|
||||
assert.equal(closes[0][1].readUInt16BE(0), 1000);
|
||||
}
|
||||
});
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
valueKind,
|
||||
} from '../src/lib/scanner.ts';
|
||||
import { DEFAULT_SCOPE, clipRanges, hugeRegionIds, listRegions, mergeRanges, rangesBytes, scopeRanges, sliceRanges, totalBytes } from '../src/lib/regions.ts';
|
||||
import { caseSpellings, decodeUtf16Around, decodeUtf8Around, encodeText, searchText, textNeedles } from '../src/lib/textsearch.ts';
|
||||
import { caseSpellings, decodeUtf16Around, decodeUtf8Around, encodeText, looseMatch, searchText, textNeedles } from '../src/lib/textsearch.ts';
|
||||
import { GroupTooBroadError, groupSearch, rarity } from '../src/lib/groupscan.ts';
|
||||
import { SNAPSHOT_MAX_BYTES, diffHits, diffSnapshots, takeSnapshot } from '../src/lib/memdiff.ts';
|
||||
|
||||
@@ -567,7 +567,7 @@ test('text search: plain and wide text, with what surrounds each hit', async ()
|
||||
dbg.close();
|
||||
});
|
||||
|
||||
test('text search: ignoring case, and where that stops being exact', async () => {
|
||||
test('text search: ignoring case finds every mix of capitals, whatever the length', async () => {
|
||||
resetHeap();
|
||||
const dbg = await connect();
|
||||
const ranges = await writableRanges(dbg);
|
||||
@@ -576,21 +576,49 @@ test('text search: ignoring case, and where that stops being exact', async () =>
|
||||
utf16('AMMo').copy(mock.game.heap, 0x6100);
|
||||
const short = await searchText({ dbg: () => dbg, pid: GAME.pid, ranges, text: 'ammo', encodings: ['utf8', 'utf16'], ignoreCase: true });
|
||||
assert.equal(short.allCases, true);
|
||||
assert.equal(short.spellings.length, 16);
|
||||
assert.deepEqual(short.hits.map((h) => [h.address, h.encoding, h.match]), [[at(0x6000), 'utf8', 'aMmO'], [at(0x6100), 'utf16', 'AMMo']]);
|
||||
// Without the option only the spelling typed is found.
|
||||
assert.deepEqual((await searchText({ dbg: () => dbg, pid: GAME.pid, ranges, text: 'ammo', encodings: ['utf8', 'utf16'] })).hits, []);
|
||||
|
||||
// More letters: as typed, lower, UPPER and Title Case. Odd mixes are missed, and the result says so.
|
||||
// More letters: the first three in every mix, the rest left open and checked here. Odd mixes
|
||||
// are found too (version 0.4.0 missed them and said "nothing found").
|
||||
mock.game.heap.write('specter health', 0x7000, 'utf8');
|
||||
mock.game.heap.write('SPECTER HEALTH', 0x7100, 'utf8');
|
||||
utf16('Specter Health').copy(mock.game.heap, 0x7200);
|
||||
mock.game.heap.write('sPeCtEr hEaLtH', 0x7300, 'utf8');
|
||||
mock.game.heap.write('specter HEALTH', 0x7400, 'utf8');
|
||||
utf16('sPECTER heaLTH').copy(mock.game.heap, 0x7400);
|
||||
// Same shape, other words: the console reports these for the open letters, and they must be dropped.
|
||||
mock.game.heap.write('specter wealth', 0x7500, 'utf8');
|
||||
mock.game.heap.write('spectre health', 0x7600, 'utf8');
|
||||
mock.game.heap.write('specter_health', 0x7700, 'utf8');
|
||||
utf16('Specter Hearth').copy(mock.game.heap, 0x7800);
|
||||
const long = await searchText({ dbg: () => dbg, pid: GAME.pid, ranges, text: 'specter HEALTH', encodings: ['utf8', 'utf16'], ignoreCase: true });
|
||||
assert.equal(long.allCases, false);
|
||||
assert.deepEqual(long.spellings, ['specter HEALTH', 'specter health', 'SPECTER HEALTH', 'Specter Health']);
|
||||
assert.deepEqual(long.hits.map((h) => h.address), [at(0x7000), at(0x7100), at(0x7200), at(0x7400)]);
|
||||
assert.equal(long.allCases, true);
|
||||
assert.deepEqual(long.hits.map((h) => [h.address, h.match]), [
|
||||
[at(0x7000), 'specter health'],
|
||||
[at(0x7100), 'SPECTER HEALTH'],
|
||||
[at(0x7200), 'Specter Health'],
|
||||
[at(0x7300), 'sPeCtEr hEaLtH'],
|
||||
[at(0x7400), 'sPECTER heaLTH'],
|
||||
]);
|
||||
// Many near misses packed together do not hide a real one after them, and do not count toward the cap.
|
||||
resetHeap();
|
||||
for (let i = 0; i < 40; i++) mock.game.heap.write('GodMove', 0x9000 + i * 16, 'utf8');
|
||||
mock.game.heap.write('GodMode', 0x9000 + 40 * 16, 'utf8');
|
||||
const dense = await searchText({ dbg: () => dbg, pid: GAME.pid, ranges, text: 'godmode', encodings: ['utf8', 'utf16'], ignoreCase: true, maxHits: 5 });
|
||||
assert.deepEqual(dense.hits.map((h) => [h.address, h.match]), [[at(0x9000 + 40 * 16), 'GodMode']]);
|
||||
assert.equal(dense.capped, false);
|
||||
|
||||
// The patterns: every one starts with fixed bytes, and only letters past the third are open.
|
||||
const { needles } = textNeedles('godmode', { encodings: ['utf8', 'utf16'], ignoreCase: true });
|
||||
assert.equal(needles.length, 16);
|
||||
assert.ok(needles.every((n) => n.loose && n.mask[0] === 1));
|
||||
assert.deepEqual([...needles[0].mask], [1, 1, 1, 0, 0, 0, 0]);
|
||||
assert.deepEqual([...needles[8].mask], [1, 1, 1, 1, 1, 1, 0, 1, 0, 1, 0, 1, 0, 1]);
|
||||
assert.ok(looseMatch(needles[0], Buffer.from('godMODE')));
|
||||
assert.ok(!looseMatch(needles[0], Buffer.from('godmod'))); // too short
|
||||
assert.ok(!looseMatch(needles[0], Buffer.from('god-ode')));
|
||||
assert.ok(textNeedles('Exact Text', { encodings: ['utf8'] }).needles.every((n) => !n.loose));
|
||||
|
||||
assert.deepEqual(caseSpellings('ab'), { spellings: ['ab', 'Ab', 'aB', 'AB'], allCases: true });
|
||||
assert.deepEqual(caseSpellings('12-34'), { spellings: ['12-34'], allCases: true });
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
// A short look at the Cheats tab in a real browser, for the things only a
|
||||
// browser shows: what fits on a phone, what a dialog covers, where focus goes.
|
||||
// Skipped when Playwright or the built web app is not there.
|
||||
//
|
||||
// CS_PORT_OFFSET=<n> node --test test/ui/cheats.ui.test.mjs
|
||||
|
||||
import test, { after, before } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { createRequire } from 'node:module';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
const PORT = 18796 + Number(process.env.CS_PORT_OFFSET ?? 0);
|
||||
const URL = `http://127.0.0.1:${PORT}/`;
|
||||
|
||||
let chromium = null;
|
||||
for (const from of [import.meta.url, '/opt/npm-tools/node_modules/']) {
|
||||
try {
|
||||
chromium = createRequire(from)('playwright').chromium;
|
||||
break;
|
||||
} catch {
|
||||
// try the next place
|
||||
}
|
||||
}
|
||||
const skip = !chromium ? 'needs Playwright' : !fs.existsSync(path.join(ROOT, 'dist/index.html')) ? 'needs a built web app (npm run build)' : false;
|
||||
|
||||
let server, browser;
|
||||
const problems = [];
|
||||
|
||||
before(async () => {
|
||||
if (skip) return;
|
||||
const env = { ...process.env };
|
||||
for (const k of ['HTTP_PROXY', 'HTTPS_PROXY', 'http_proxy', 'https_proxy']) delete env[k];
|
||||
server = spawn(process.execPath, [path.join(ROOT, 'bridge/server.mjs'), '--demo', '--port', String(PORT)], { env, stdio: ['ignore', 'pipe', 'inherit'] });
|
||||
await new Promise((resolve, reject) => {
|
||||
server.stdout.on('data', (d) => String(d).includes('On this computer') && resolve());
|
||||
server.on('exit', () => reject(new Error('the demo bridge did not start')));
|
||||
});
|
||||
browser = await chromium.launch({ args: ['--no-proxy-server'] });
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await browser?.close();
|
||||
server?.kill('SIGKILL');
|
||||
});
|
||||
|
||||
/** The demo console's Cheats tab at a given screen size. */
|
||||
async function openCheats(width, height) {
|
||||
const context = await browser.newContext({ viewport: { width, height } });
|
||||
const page = await context.newPage();
|
||||
page.on('pageerror', (e) => problems.push(`error: ${e.message}`));
|
||||
page.on('console', (m) => {
|
||||
if ((m.type() === 'error' || m.type() === 'warning') && !/Failed to load resource/.test(m.text())) problems.push(`${m.type()}: ${m.text()}`);
|
||||
});
|
||||
// No waiting on the network for cheat tables.
|
||||
await page.route('https://raw.githubusercontent.com/**', (route) => route.fulfill({ status: 404, body: '' }));
|
||||
await page.goto(URL);
|
||||
await page.getByRole('button', { name: /Demo console/ }).click();
|
||||
await page.getByText('Your cheats').waitFor();
|
||||
return page;
|
||||
}
|
||||
|
||||
test('on a phone the header is one line and nothing sticks out sideways', { skip }, async () => {
|
||||
const page = await openCheats(390, 844);
|
||||
const header = await page.locator('header').first().boundingBox();
|
||||
assert.ok(header.height <= 48, `the header is ${header.height}px tall, so it has wrapped`);
|
||||
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth), 390);
|
||||
await page.context().close();
|
||||
});
|
||||
|
||||
test('on a phone held sideways an editor keeps Save in reach, and closing it is deliberate', { skip }, async () => {
|
||||
const page = await openCheats(844, 390);
|
||||
const open = page.getByRole('button', { name: 'New' });
|
||||
await open.click();
|
||||
await page.getByRole('menuitem', { name: 'Patch' }).click();
|
||||
const dialog = page.getByRole('dialog');
|
||||
const box = await dialog.boundingBox();
|
||||
assert.ok(box.y >= 0 && box.y + box.height <= 390, 'the dialog fits on the screen');
|
||||
const save = await dialog.getByRole('button', { name: 'Save' }).boundingBox();
|
||||
assert.ok(save.y + save.height <= 390, 'Save is on screen without scrolling');
|
||||
|
||||
// Enter in the name box tries to save, and says what is missing.
|
||||
await dialog.getByLabel('Name', { exact: true }).fill('Test');
|
||||
await page.keyboard.press('Enter');
|
||||
await dialog.getByRole('alert').waitFor();
|
||||
|
||||
// A click outside does not close it; Escape asks once when there are changes.
|
||||
await page.mouse.click(5, 200);
|
||||
assert.equal(await dialog.count(), 1);
|
||||
await page.keyboard.press('Escape');
|
||||
await dialog.getByText('changes that are not saved').waitFor();
|
||||
await page.keyboard.press('Escape');
|
||||
await dialog.waitFor({ state: 'detached' });
|
||||
assert.equal(await page.evaluate(() => document.activeElement?.textContent?.trim()), 'New', 'focus goes back to the button that opened it');
|
||||
await page.context().close();
|
||||
});
|
||||
|
||||
test('the guide fills a phone screen, upright or sideways', { skip }, async () => {
|
||||
for (const [width, height] of [[390, 844], [844, 390]]) {
|
||||
const page = await openCheats(width, height);
|
||||
await page.getByRole('button', { name: /Guide me/ }).first().click();
|
||||
// It opens with a short zoom; measure once that has finished.
|
||||
await page.waitForTimeout(500);
|
||||
const box = await page.getByRole('dialog').boundingBox();
|
||||
assert.deepEqual([Math.round(box.width), Math.round(box.height)], [width, height]);
|
||||
await page.context().close();
|
||||
}
|
||||
});
|
||||
|
||||
test('switching a cheat on does not move the page', { skip }, async () => {
|
||||
const page = await openCheats(390, 844);
|
||||
const cheat = page.getByRole('switch', { name: 'Infinite Health' });
|
||||
await cheat.waitFor();
|
||||
await page.waitForTimeout(500);
|
||||
// Where it is on the page, not on the screen: clicking may scroll it into view.
|
||||
const top = async () => Math.round((await cheat.boundingBox()).y + (await page.evaluate(() => window.scrollY)));
|
||||
const before = await top();
|
||||
await cheat.click();
|
||||
await page.locator('[role=switch][aria-label="Infinite Health"][aria-checked=true]').waitFor();
|
||||
assert.equal(await top(), before);
|
||||
await cheat.click();
|
||||
await page.locator('[role=switch][aria-label="Infinite Health"][aria-checked=false]').waitFor();
|
||||
await page.context().close();
|
||||
});
|
||||
|
||||
test('none of that put an error or a warning in the browser console', { skip }, () => {
|
||||
assert.deepEqual(problems, []);
|
||||
});
|
||||
@@ -0,0 +1,179 @@
|
||||
// The Find tab in a real browser, for what version 0.4.0 got wrong and only a
|
||||
// browser shows: a first search that finds nothing has to say so (what was
|
||||
// searched, where, what to try), progress has to be real and cancelable, and
|
||||
// "Copy address" has to work on a plain http page, which is how the app is
|
||||
// served from a PS5. Skipped when Playwright or the built web app is not there.
|
||||
//
|
||||
// CS_PORT_OFFSET=<n> node --test test/ui/find.ui.test.mjs
|
||||
|
||||
import test, { after, before } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { createRequire } from 'node:module';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { startMock, blankRegion } from '../../mock/mock-ps5.mjs';
|
||||
import { startBridge } from '../../bridge/server.mjs';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
const TCP = 17560 + Number(process.env.CS_PORT_OFFSET ?? 0);
|
||||
const UDP = 10160 + Number(process.env.CS_PORT_OFFSET ?? 0);
|
||||
const MB = 1024 * 1024;
|
||||
|
||||
let chromium = null;
|
||||
for (const from of [import.meta.url, '/opt/npm-tools/node_modules/']) {
|
||||
try {
|
||||
chromium = createRequire(from)('playwright').chromium;
|
||||
break;
|
||||
} catch {
|
||||
// try the next place
|
||||
}
|
||||
}
|
||||
const skip = !chromium ? 'needs Playwright' : !fs.existsSync(path.join(ROOT, 'dist/index.html')) ? 'needs a built web app (npm run build)' : false;
|
||||
|
||||
let mock, bridge, browser, url;
|
||||
const problems = [];
|
||||
|
||||
before(async () => {
|
||||
if (skip) return;
|
||||
// The demo game plus 96 MB of empty memory, searched slowly enough to watch: seven slices.
|
||||
mock = startMock({ tcpPort: TCP, udpPort: UDP, tick: false });
|
||||
await mock.ready;
|
||||
mock.game.regions.push(blankRegion('', 0x1000000000n, 96 * MB));
|
||||
bridge = startBridge({ port: 0, host: '127.0.0.1', eventPort: null, demo: { host: '127.0.0.1', port: TCP, udpPort: UDP, label: 'Demo console' } });
|
||||
const port = await bridge.ready;
|
||||
// A made-up name for this computer: unlike 127.0.0.1 it is not a "secure" page, so the browser
|
||||
// gives it no clipboard, exactly as when the app is opened from the PS5's address.
|
||||
url = `http://ps5.test:${port}/`;
|
||||
browser = await chromium.launch({ args: ['--no-proxy-server', '--host-resolver-rules=MAP ps5.test 127.0.0.1'] });
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await browser?.close();
|
||||
await bridge?.close();
|
||||
await mock?.close();
|
||||
});
|
||||
|
||||
async function openFind(width, height) {
|
||||
const context = await browser.newContext({ viewport: { width, height } });
|
||||
const page = await context.newPage();
|
||||
page.on('pageerror', (e) => problems.push(`error: ${e.message}`));
|
||||
page.on('console', (m) => {
|
||||
if ((m.type() === 'error' || m.type() === 'warning') && !/Failed to load resource/.test(m.text())) problems.push(`${m.type()}: ${m.text()}`);
|
||||
});
|
||||
await page.route('https://raw.githubusercontent.com/**', (route) => route.fulfill({ status: 404, body: '' }));
|
||||
await page.goto(url);
|
||||
await page.getByRole('button', { name: /Demo console/ }).click();
|
||||
await page.getByRole('tab', { name: 'Find', exact: true }).click();
|
||||
const panel = page.locator('[role=tabpanel][data-state=active]');
|
||||
return {
|
||||
page,
|
||||
panel,
|
||||
value: panel.getByRole('textbox', { name: 'Value', exact: true }),
|
||||
search: panel.getByRole('button', { name: 'Search', exact: true }),
|
||||
outcome: panel.getByTestId('search-outcome').locator('visible=true'),
|
||||
progress: panel.getByTestId('search-progress').locator('visible=true'),
|
||||
};
|
||||
}
|
||||
|
||||
const inView = (locator) =>
|
||||
locator.evaluate((el) => {
|
||||
const r = el.getBoundingClientRect();
|
||||
return r.top >= 0 && r.bottom <= window.innerHeight;
|
||||
});
|
||||
|
||||
test('a first search that finds nothing says so, where the person is looking (phone)', { skip }, async () => {
|
||||
const f = await openFind(390, 844);
|
||||
mock.state.scanDelayMs = 150;
|
||||
await f.value.fill('123456');
|
||||
await f.value.press('Enter');
|
||||
|
||||
// While it runs: a percentage, how much memory of how much, matches so far, a clock, and a way out.
|
||||
await f.progress.getByText(/of 96 MB/).waitFor();
|
||||
const running = await f.progress.innerText();
|
||||
assert.match(running, /\d+%/);
|
||||
assert.match(running, /\d+ of 96 MB/);
|
||||
assert.match(running, /0 matches so far/);
|
||||
assert.match(running, /0:0\d so far/);
|
||||
assert.match(running, /Looking for 123456 as 4 bytes in all 96 MB of the game's memory/);
|
||||
assert.equal(await f.panel.getByRole('button', { name: 'Cancel' }).isVisible(), true);
|
||||
assert.equal(await inView(f.progress), true, 'the progress is on screen, not below it');
|
||||
const bar = f.progress.getByRole('progressbar');
|
||||
const before = Number(await bar.getAttribute('aria-valuenow'));
|
||||
await f.page.waitForFunction((b) => Number(document.querySelector('[role=progressbar]')?.getAttribute('aria-valuenow')) > b, before);
|
||||
|
||||
await f.outcome.waitFor();
|
||||
const said = await f.outcome.innerText();
|
||||
assert.match(said, /Nothing found\. No place in all 96 MB of the game's memory holds 123456 as 4 bytes\./);
|
||||
assert.match(said, /Set Type to Auto/);
|
||||
assert.equal(await inView(f.outcome), true, 'the answer is scrolled into view');
|
||||
// Nothing that looks like a result list, and the form is ready for another try: type and all.
|
||||
assert.equal(await f.panel.locator('tbody tr').count(), 0);
|
||||
assert.equal(await f.search.isEnabled(), true);
|
||||
assert.equal(await f.panel.getByRole('combobox', { name: 'Type' }).isEnabled(), true);
|
||||
assert.equal(await f.panel.getByRole('button', { name: 'Narrow down' }).count(), 0);
|
||||
|
||||
// What is wrong with a number is said next to it, and nothing is searched.
|
||||
await f.value.fill('1.5');
|
||||
await f.search.click();
|
||||
assert.match(await f.panel.getByRole('alert').locator('visible=true').innerText(), /1\.5 is not a whole number.*Float \(or Auto\)/);
|
||||
assert.equal(await f.progress.count(), 0);
|
||||
await f.page.context().close();
|
||||
});
|
||||
|
||||
test('cancel works mid-search and says what was kept; narrowing to nothing offers the way back', { skip }, async () => {
|
||||
const f = await openFind(1280, 860);
|
||||
mock.state.scanDelayMs = 400;
|
||||
await f.value.fill('100');
|
||||
await f.search.click();
|
||||
await f.progress.getByText(/of 96 MB/).waitFor();
|
||||
await f.panel.getByRole('button', { name: 'Cancel' }).click();
|
||||
await f.outcome.waitFor();
|
||||
const canceled = await f.outcome.innerText();
|
||||
assert.match(canceled, /Canceled after searching \d+ of 96 MB\./);
|
||||
assert.match(canceled, /Nothing was kept/);
|
||||
assert.equal(await f.panel.locator('tbody tr').count(), 0);
|
||||
|
||||
// The same search again, straight away, to the end.
|
||||
mock.state.scanDelayMs = 0;
|
||||
await f.search.click();
|
||||
await f.outcome.getByText(/^Found \d+ places? holding 100 as 4 bytes/).waitFor();
|
||||
const rows = await f.panel.locator('tbody tr').count();
|
||||
assert.ok(rows >= 1);
|
||||
|
||||
await f.value.fill('987654');
|
||||
await f.panel.getByRole('button', { name: 'Narrow down' }).click();
|
||||
await f.outcome.getByText(/^Nothing left\./).waitFor();
|
||||
assert.match(await f.outcome.innerText(), new RegExp(`None of the ${rows} matches? passed the check "value is now 987654"`));
|
||||
assert.equal(await f.panel.locator('tbody tr').count(), 0, 'the old list is not left on screen looking current');
|
||||
await f.panel.getByRole('button', { name: 'Undo last step' }).click();
|
||||
await f.outcome.getByText(/^Back to the \d+ matches?/).waitFor();
|
||||
assert.equal(await f.panel.locator('tbody tr').count(), rows);
|
||||
await f.page.context().close();
|
||||
});
|
||||
|
||||
test('"Copy address" works on a plain http page, and says it did', { skip }, async () => {
|
||||
const f = await openFind(390, 844);
|
||||
mock.state.scanDelayMs = 0;
|
||||
assert.deepEqual(await f.page.evaluate(() => [window.isSecureContext, typeof navigator.clipboard]), [false, 'undefined'], 'this page has no clipboard API, like one served by the PS5');
|
||||
await f.value.fill('100');
|
||||
await f.search.click();
|
||||
await f.outcome.getByText(/^Found/).waitFor();
|
||||
const address = (await f.panel.locator('tbody tr').first().locator('td').nth(1).innerText()).trim();
|
||||
await f.panel.locator('tbody tr').first().getByRole('button', { name: /^More for/ }).click();
|
||||
await f.page.getByRole('menuitem', { name: 'Copy address' }).click();
|
||||
const toast = f.page.getByTestId('copy-toast');
|
||||
await toast.waitFor();
|
||||
assert.equal((await toast.innerText()).trim(), `Copied ${address}`);
|
||||
// It really is on the clipboard: paste it somewhere.
|
||||
await f.value.fill('');
|
||||
await f.value.focus();
|
||||
await f.page.keyboard.press('Control+V');
|
||||
assert.equal(await f.value.inputValue(), address);
|
||||
await f.page.context().close();
|
||||
});
|
||||
|
||||
test('none of that put an error or a warning in the browser console', { skip }, () => {
|
||||
assert.deepEqual(problems, []);
|
||||
});
|
||||
@@ -0,0 +1,172 @@
|
||||
// A short look at the Memory, Code and Debug tabs in a real browser, for
|
||||
// things only a browser shows: a message that is gone again a second later,
|
||||
// a banner lying over a dialog, a Copy that does nothing on plain http.
|
||||
// Skipped when Playwright or the built web app is not there.
|
||||
//
|
||||
// CS_PORT_OFFSET=<n> node --test test/ui/inspect.ui.test.mjs
|
||||
|
||||
import test, { after, before } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { createRequire } from 'node:module';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
const PORT = 18836 + Number(process.env.CS_PORT_OFFSET ?? 0);
|
||||
const URL = `http://127.0.0.1:${PORT}/`;
|
||||
|
||||
let chromium = null;
|
||||
for (const from of [import.meta.url, '/opt/npm-tools/node_modules/']) {
|
||||
try {
|
||||
chromium = createRequire(from)('playwright').chromium;
|
||||
break;
|
||||
} catch {
|
||||
// try the next place
|
||||
}
|
||||
}
|
||||
const skip = !chromium ? 'needs Playwright' : !fs.existsSync(path.join(ROOT, 'dist/index.html')) ? 'needs a built web app (npm run build)' : false;
|
||||
|
||||
let server, browser;
|
||||
const problems = [];
|
||||
|
||||
before(async () => {
|
||||
if (skip) return;
|
||||
const env = { ...process.env };
|
||||
for (const k of ['HTTP_PROXY', 'HTTPS_PROXY', 'http_proxy', 'https_proxy']) delete env[k];
|
||||
server = spawn(process.execPath, [path.join(ROOT, 'bridge/server.mjs'), '--demo', '--port', String(PORT)], { env, stdio: ['ignore', 'pipe', 'inherit'] });
|
||||
await new Promise((resolve, reject) => {
|
||||
server.stdout.on('data', (d) => String(d).includes('On this computer') && resolve());
|
||||
server.on('exit', () => reject(new Error('the demo bridge did not start')));
|
||||
});
|
||||
browser = await chromium.launch({ args: ['--no-proxy-server'] });
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await browser?.close();
|
||||
server?.kill('SIGKILL');
|
||||
});
|
||||
|
||||
/** The demo console on one of its tabs, at a given screen size. */
|
||||
async function openTab(name, width, height, init) {
|
||||
const context = await browser.newContext({ viewport: { width, height } });
|
||||
const page = await context.newPage();
|
||||
page.on('pageerror', (e) => problems.push(`error: ${e.message}`));
|
||||
page.on('console', (m) => {
|
||||
if ((m.type() === 'error' || m.type() === 'warning') && !/Failed to load resource/.test(m.text())) problems.push(`${m.type()}: ${m.text()}`);
|
||||
});
|
||||
await page.route('https://raw.githubusercontent.com/**', (route) => route.fulfill({ status: 404, body: '' }));
|
||||
if (init) await page.addInitScript(init);
|
||||
await page.goto(URL);
|
||||
await page.getByRole('button', { name: /Demo console/ }).click();
|
||||
await page.getByRole('tab', { name, exact: true }).click();
|
||||
return { page, panel: page.locator('[role=tabpanel][data-state=active]') };
|
||||
}
|
||||
|
||||
const notices = (panel) => panel.locator('[data-slot=alert]:visible').allInnerTexts();
|
||||
|
||||
test('Memory: what was wrong with an address stays on screen, and memory that is not there is called that', { skip }, async () => {
|
||||
const { page, panel } = await openTab('Memory', 390, 844);
|
||||
const box = panel.getByPlaceholder('Address, or game+1A2B3C');
|
||||
await panel.getByRole('table', { name: 'Memory bytes' }).waitFor();
|
||||
|
||||
await box.fill('game+');
|
||||
await box.press('Enter');
|
||||
// The view refreshes every second; that used to wipe the message.
|
||||
await page.waitForTimeout(2300);
|
||||
assert.match((await notices(panel)).join(' '), /Type the offset after "game\+"/);
|
||||
|
||||
await box.fill('10');
|
||||
await box.press('Enter');
|
||||
await panel.getByText('The game has no memory at this address').waitFor();
|
||||
await panel.getByLabel('Or as raw bytes').fill('90');
|
||||
await panel.getByLabel('Or as raw bytes').press('Enter');
|
||||
await panel.getByText('so there is nothing to change').waitFor();
|
||||
|
||||
await box.fill('0');
|
||||
await box.press('Enter');
|
||||
await panel.getByRole('table', { name: 'Memory bytes' }).waitFor();
|
||||
assert.equal(await panel.getByLabel('Previous page').isDisabled(), true, 'there is nothing before address 0');
|
||||
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth), 390, 'the byte grid fits a phone');
|
||||
await page.context().close();
|
||||
});
|
||||
|
||||
test('Code: a pattern search says what it found, and Copy works without the clipboard API', { skip }, async () => {
|
||||
// Plain http (how the PS5 serves the page) has no navigator.clipboard.
|
||||
const { page, panel } = await openTab('Code', 1280, 860, () => Object.defineProperty(Navigator.prototype, 'clipboard', { get: () => undefined }));
|
||||
const pattern = panel.locator('#code-pattern');
|
||||
await panel.getByRole('table', { name: 'Disassembly' }).waitFor();
|
||||
|
||||
await pattern.fill('DE AD BE EF 12 34');
|
||||
await pattern.press('Enter');
|
||||
await panel.getByText('No match: that pattern is not anywhere').waitFor();
|
||||
await pattern.fill('FF 4B 28');
|
||||
await pattern.press('Enter');
|
||||
await panel.getByText('It is the only place this pattern matches').waitFor();
|
||||
|
||||
await panel.locator('table[aria-label="Disassembly"] button').first().click();
|
||||
await page.getByRole('menuitem', { name: 'Copy a signature for this spot' }).click();
|
||||
await panel.getByText(/^Copied: FF 4B 28/).waitFor();
|
||||
await pattern.fill('');
|
||||
await pattern.focus();
|
||||
await page.keyboard.press('Control+V');
|
||||
assert.match(await pattern.inputValue(), /^FF 4B 28/, 'the signature really is on the clipboard');
|
||||
await page.context().close();
|
||||
});
|
||||
|
||||
test('Debug: on a phone held sideways the paused banner leaves a dialog whole and stays in reach', { skip }, async () => {
|
||||
const { page, panel } = await openTab('Debug', 844, 390);
|
||||
await panel.getByRole('button', { name: 'Pause the game' }).click();
|
||||
const banner = page.locator('body > [role=alert]');
|
||||
await banner.waitFor();
|
||||
|
||||
await page.getByRole('tab', { name: 'Memory', exact: true }).click();
|
||||
await panel.getByRole('button', { name: 'Save to your cheats' }).click();
|
||||
const dialog = page.locator('[role=dialog]');
|
||||
await dialog.waitFor();
|
||||
await page.waitForTimeout(300); // the opening animation moves it
|
||||
const bar = await banner.boundingBox();
|
||||
const box = await dialog.boundingBox();
|
||||
assert.ok(box.y >= bar.height, `the dialog starts at ${box.y}px, under a ${bar.height}px banner`);
|
||||
assert.ok(box.y + box.height <= 390, 'and ends inside the screen');
|
||||
|
||||
// An open dialog switches off clicks outside itself; Resume must work all the same, and not close the dialog.
|
||||
await banner.locator('button', { hasText: 'Resume' }).click({ timeout: 3000 });
|
||||
await banner.waitFor({ state: 'detached' });
|
||||
assert.equal(await dialog.count(), 1, 'the dialog and what was typed in it are still there');
|
||||
await page.context().close();
|
||||
});
|
||||
|
||||
test('Debug: a mistake in "Inside an object" stays on screen, and adding a breakpoint twice says so', { skip }, async () => {
|
||||
const { page, panel } = await openTab('Debug', 390, 844);
|
||||
const add = panel.getByLabel('Address of the instruction');
|
||||
await add.fill('game+3600');
|
||||
await add.press('Enter');
|
||||
await panel.getByRole('list', { name: 'Breakpoints' }).waitFor();
|
||||
await add.fill('game+3600');
|
||||
await add.press('Enter');
|
||||
await panel.getByText('There is already a breakpoint on that instruction').waitFor();
|
||||
// The game stops there at once; let it go, and take the breakpoint out.
|
||||
await panel.getByRole('button', { name: /^Actions for the breakpoint/ }).click();
|
||||
await page.getByRole('menuitem', { name: 'Remove' }).click();
|
||||
const banner = page.locator('body > [role=alert]');
|
||||
if (await banner.count()) await banner.locator('button', { hasText: 'Resume' }).click();
|
||||
|
||||
await panel.getByRole('tab').nth(2).click();
|
||||
const object = panel.locator('#obj-addr');
|
||||
await object.fill('880014000');
|
||||
await object.press('Enter');
|
||||
await panel.getByRole('table', { name: 'Fields of the object' }).waitFor();
|
||||
await object.fill('880014000');
|
||||
await object.press('Enter');
|
||||
// The fields refresh every second and a half; that used to wipe the message.
|
||||
await page.waitForTimeout(2300);
|
||||
assert.match((await notices(panel)).join(' '), /That object is already shown/);
|
||||
assert.ok((await panel.locator('table[aria-label="Fields of the object"]').evaluate((t) => t.parentElement.scrollWidth - t.parentElement.clientWidth)) <= 0, 'the field list fits a phone without sideways scrolling');
|
||||
await page.context().close();
|
||||
});
|
||||
|
||||
test('nothing was reported in the browser console along the way', { skip }, () => {
|
||||
assert.deepEqual([...new Set(problems)], []);
|
||||
});
|
||||
@@ -0,0 +1,163 @@
|
||||
// The Learn section in a real browser: the things that only show on a screen.
|
||||
// Each check is here because it was broken once: the practice game's panel
|
||||
// lying over the box being typed in, the game starting over between two
|
||||
// practice steps, a label that focused the hidden real tool instead of the
|
||||
// lesson's, and warnings in the browser's console.
|
||||
//
|
||||
// Needs a built app (npm run build) and Playwright; without either it is
|
||||
// skipped. Runs against the bridge's demo mode, which serves learn/ itself.
|
||||
//
|
||||
// CS_PORT_OFFSET=<n> node --test test/ui/learn.test.mjs
|
||||
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { createRequire } from 'node:module';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const ROOT = fileURLToPath(new URL('../../', import.meta.url));
|
||||
const OFFSET = Number(process.env.CS_PORT_OFFSET ?? 0);
|
||||
const PORT = 18744 + OFFSET;
|
||||
const URL_ = `http://127.0.0.1:${PORT}/`;
|
||||
|
||||
function findPlaywright() {
|
||||
for (const from of [import.meta.url, 'file:///opt/npm-tools/node_modules/']) {
|
||||
try {
|
||||
return createRequire(from)('playwright');
|
||||
} catch {
|
||||
// try the next place
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const playwright = findPlaywright();
|
||||
const built = fs.existsSync(ROOT + 'dist/index.html');
|
||||
const skip = !playwright ? 'Playwright is not installed' : !built ? 'the app is not built (npm run build)' : false;
|
||||
|
||||
test('the Learn section on a screen', { skip, timeout: 60000 }, async (t) => {
|
||||
const env = { ...process.env, CS_PORT_OFFSET: String(OFFSET + 3) };
|
||||
for (const k of ['HTTP_PROXY', 'HTTPS_PROXY', 'http_proxy', 'https_proxy']) delete env[k];
|
||||
const bridge = spawn(process.execPath, ['bridge/server.mjs', '--demo', '--port', String(PORT)], { cwd: ROOT, env, stdio: 'ignore' });
|
||||
t.after(() => bridge.kill('SIGKILL'));
|
||||
for (let i = 0; ; i++) {
|
||||
const up = await fetch(URL_).then(
|
||||
(r) => r.ok,
|
||||
() => false,
|
||||
);
|
||||
if (up) break;
|
||||
assert.ok(i < 100, 'the bridge did not start');
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
}
|
||||
|
||||
const browser = await playwright.chromium.launch({ args: ['--no-proxy-server'] });
|
||||
t.after(() => browser.close());
|
||||
|
||||
/** A page with everything the browser complains about collected. Failed downloads of cheat tables are not ours. */
|
||||
const visit = async (viewport) => {
|
||||
const page = await browser.newPage({ viewport });
|
||||
const said = [];
|
||||
page.on('pageerror', (e) => said.push(`error: ${e.message}`));
|
||||
page.on('console', (m) => {
|
||||
if ((m.type() === 'error' || m.type() === 'warning') && !/Failed to load resource/.test(m.text())) said.push(`${m.type()}: ${m.text()}`);
|
||||
});
|
||||
await page.route('https://raw.githubusercontent.com/**', (route) => route.fulfill({ status: 404, body: '' }));
|
||||
await page.goto(URL_);
|
||||
return { page, said };
|
||||
};
|
||||
const tools = (page) => page.locator('[role=tablist][aria-label="Tools"]').locator('xpath=..');
|
||||
const dock = (page) => page.locator('section[aria-label^="Practice game"]');
|
||||
const openLesson = async (page, number) => {
|
||||
const load = page.getByRole('button', { name: 'Load lessons' });
|
||||
await load.or(page.getByRole('heading', { name: 'Learn', exact: true })).first().waitFor();
|
||||
if (await load.isVisible()) await load.click();
|
||||
await page.locator('ol li button').nth(number - 1).click();
|
||||
await page.getByText(/^Step 1 of/).waitFor();
|
||||
};
|
||||
const stepIs = (page, n) => page.getByText(new RegExp(`^Step ${n} of`)).waitFor();
|
||||
|
||||
await t.test('a phone: the panel is docked, and never over the box that has the focus', async () => {
|
||||
const { page, said } = await visit({ width: 390, height: 844 });
|
||||
await page.getByRole('button', { name: /Learn how cheats are made/ }).click();
|
||||
await openLesson(page, 2);
|
||||
await page.getByRole('button', { name: /^Next$/ }).click();
|
||||
await stepIs(page, 2);
|
||||
await tools(page).getByRole('button', { name: 'Search', exact: true }).waitFor();
|
||||
assert.equal(await dock(page).evaluate((el) => getComputedStyle(el).position), 'sticky');
|
||||
// Scroll so that the Value box sits where the panel is, then give it the focus without a click.
|
||||
const value = tools(page).getByLabel('Value', { exact: true });
|
||||
await value.evaluate((el) => {
|
||||
const panel = document.querySelector('section[aria-label^="Practice game"]').getBoundingClientRect();
|
||||
window.scrollBy(0, el.getBoundingClientRect().top - panel.top - 4);
|
||||
});
|
||||
assert.ok(await value.evaluate((el) => el.getBoundingClientRect().bottom > document.querySelector('section[aria-label^="Practice game"]').getBoundingClientRect().top), 'the box starts out under the panel');
|
||||
await value.focus();
|
||||
await page.waitForTimeout(150);
|
||||
const clear = await value.evaluate((el) => document.querySelector('section[aria-label^="Practice game"]').getBoundingClientRect().top - el.getBoundingClientRect().bottom);
|
||||
assert.ok(clear >= 0, `the focused box is ${-clear}px under the panel`);
|
||||
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true, 'nothing makes the page scroll sideways');
|
||||
|
||||
// Skipping the first search: the next step says what is missing instead of pointing at a button that is not there.
|
||||
await page.getByRole('button', { name: 'Skip this step' }).click();
|
||||
await stepIs(page, 3);
|
||||
await page.getByText(/This step carries on from step 2/).waitFor();
|
||||
await page.getByRole('button', { name: 'Go to step 2' }).click();
|
||||
await stepIs(page, 2);
|
||||
assert.deepEqual(said, []);
|
||||
await page.close();
|
||||
});
|
||||
|
||||
await t.test('a phone on its side: the panel shrinks to one line', async () => {
|
||||
const { page, said } = await visit({ width: 844, height: 390 });
|
||||
await page.getByRole('button', { name: /Learn how cheats are made/ }).click();
|
||||
await openLesson(page, 2);
|
||||
await page.getByRole('button', { name: /^Next$/ }).click();
|
||||
await tools(page).getByRole('button', { name: 'Search', exact: true }).waitFor();
|
||||
const height = await dock(page).evaluate((el) => el.getBoundingClientRect().height);
|
||||
assert.ok(height <= 70, `the panel is ${height}px of a 390px window`);
|
||||
await dock(page).getByRole('button', { name: 'Fire' }).click();
|
||||
await dock(page).getByRole('button', { name: 'Restart game' }).waitFor();
|
||||
assert.deepEqual(said, []);
|
||||
await page.close();
|
||||
});
|
||||
|
||||
await t.test('a wide window, with a console connected: the panel is beside the tools, the game lasts through a question, labels are the lesson\'s own', async () => {
|
||||
const { page, said } = await visit({ width: 1280, height: 860 });
|
||||
await page.getByText('Demo console').click();
|
||||
await page.getByRole('button', { name: 'Learn', exact: true }).click();
|
||||
await openLesson(page, 2);
|
||||
await page.getByRole('button', { name: /^Next$/ }).click();
|
||||
await stepIs(page, 2);
|
||||
await tools(page).getByRole('button', { name: 'Search', exact: true }).waitFor();
|
||||
const [panel, column] = await Promise.all([dock(page).boundingBox(), tools(page).boundingBox()]);
|
||||
assert.equal(await dock(page).evaluate((el) => getComputedStyle(el).position), 'fixed');
|
||||
assert.ok(panel.x >= column.x + column.width, 'the panel is to the right of the tools, not over them');
|
||||
assert.ok(panel.x + panel.width <= 1280, 'and inside the window');
|
||||
|
||||
// The real console's Find tab is still on the page, hidden. Clicking the lesson's "Value" label must reach the lesson's box.
|
||||
await tools(page).getByText('Value', { exact: true }).click();
|
||||
assert.equal(await page.evaluate(() => !!document.activeElement.closest('[role=tabpanel]') && document.activeElement.offsetParent !== null && document.activeElement.tagName), 'INPUT');
|
||||
await page.keyboard.type('30');
|
||||
await page.keyboard.press('Enter');
|
||||
await tools(page).getByText(/\d+ matches/).waitFor();
|
||||
const count = await tools(page).getByText(/\d+ matches/).innerText();
|
||||
|
||||
// On through the next two steps to the question, answer it, and come back: the search is as it was left.
|
||||
await page.getByRole('button', { name: 'Next step' }).first().click();
|
||||
await stepIs(page, 3);
|
||||
await page.getByRole('button', { name: 'Skip this step' }).click();
|
||||
await page.getByRole('button', { name: 'Skip this step' }).click();
|
||||
await stepIs(page, 5);
|
||||
assert.equal(await dock(page).isVisible(), false, 'no game on a question');
|
||||
await page.getByRole('radio').nth(1).click();
|
||||
await page.getByText('That is right.').waitFor();
|
||||
await page.getByRole('button', { name: 'Back', exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Back', exact: true }).click();
|
||||
await stepIs(page, 3);
|
||||
assert.equal(await tools(page).getByText(/\d+ matches/).innerText(), count);
|
||||
assert.equal(await page.evaluate(() => document.activeElement.tagName), 'H2', 'a new step starts from its heading');
|
||||
assert.deepEqual(said, []);
|
||||
await page.close();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user