├── .editorconfig
├── .env
├── .eslintrc.js
├── .gitattributes
├── .github
├── ISSUE_TEMPLATE
│ ├── Bug_report.md
│ ├── New_feature.md
│ └── Question.md
└── workflows
│ └── deploy.yml
├── .gitignore
├── .travis.yml
├── README.md
├── package.json
├── public
├── favicon.ico
├── index.html
└── manifest.json
├── src
├── assets
│ └── logo.svg
├── components
│ ├── App.css
│ ├── App.js
│ ├── App.test.js
│ ├── CPU.css
│ ├── CPU.js
│ ├── CPU
│ │ ├── BreakpointIcon.js
│ │ ├── BreakpointModal.css
│ │ ├── BreakpointModal.js
│ │ ├── BreakpointPanel.js
│ │ ├── BreakpointPanelContextMenu.js
│ │ ├── BreakpointPanelItem.css
│ │ ├── BreakpointPanelItem.js
│ │ ├── BreakpointPanelList.css
│ │ ├── BreakpointPanelList.js
│ │ ├── Disasm.css
│ │ ├── Disasm.js
│ │ ├── DisasmBranchGuide.js
│ │ ├── DisasmButtons.css
│ │ ├── DisasmButtons.js
│ │ ├── DisasmContextMenu.js
│ │ ├── DisasmLine.js
│ │ ├── DisasmList.js
│ │ ├── DisasmSearch.css
│ │ ├── DisasmSearch.js
│ │ ├── FuncList.css
│ │ ├── FuncList.js
│ │ ├── LeftPanel.css
│ │ ├── LeftPanel.js
│ │ ├── RegList.js
│ │ ├── RegPanel.css
│ │ ├── RegPanel.js
│ │ ├── SaveBreakpoints.css
│ │ ├── SaveBreakpoints.js
│ │ ├── StatusBar.css
│ │ ├── StatusBar.js
│ │ ├── VisualizeVFPU.css
│ │ └── VisualizeVFPU.js
│ ├── DebuggerContext.js
│ ├── GPU.css
│ ├── GPU.js
│ ├── Log.css
│ ├── Log.js
│ ├── LogItem.js
│ ├── NotConnected.css
│ ├── NotConnected.js
│ ├── common
│ │ ├── Field.js
│ │ ├── FitModal.js
│ │ ├── Form.css
│ │ ├── Form.js
│ │ ├── GotoBox.css
│ │ └── GotoBox.js
│ └── ext
│ │ ├── react-contextmenu.css
│ │ ├── react-modal.css
│ │ └── react-tabs.css
├── index.css
├── index.js
├── registerServiceWorker.js
├── sdk
│ └── ppsspp.js
└── utils
│ ├── clipboard.js
│ ├── dom.js
│ ├── format.js
│ ├── game.js
│ ├── listeners.js
│ ├── logger.js
│ ├── persist.js
│ └── timeouts.js
└── yarn.lock
/.editorconfig:
--------------------------------------------------------------------------------
1 | # http://editorconfig.org/
2 | root = true
3 |
4 | [*]
5 | charset = utf-8
6 | indent_style = tab
7 | insert_final_newline = true
8 | trim_trailing_whitespace = true
9 |
10 | [*.py]
11 | indent_style = space
12 | indent_size = 2
13 |
14 | [*.sh]
15 | end_of_line = lf
16 |
--------------------------------------------------------------------------------
/.env:
--------------------------------------------------------------------------------
1 | REACT_APP_NAME=$npm_package_name
2 | REACT_APP_VERSION=$npm_package_version
3 |
--------------------------------------------------------------------------------
/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | extends: ['react-app', 'plugin:jsdoc/recommended'],
3 | env: {
4 | browser: true,
5 | commonjs: true,
6 | node: true,
7 | es6: true,
8 | },
9 | parserOptions: {
10 | ecmaVersion: 6,
11 | sourceType: 'module',
12 | },
13 | plugins: [
14 | 'jsdoc',
15 | ],
16 | rules: {
17 | 'block-scoped-var': 'error',
18 | 'consistent-return': 'warn',
19 | 'comma-dangle': ['error', 'always-multiline'],
20 | 'curly': 'warn',
21 | 'indent': ['error', 'tab'],
22 | 'jsdoc/require-jsdoc': 0,
23 | 'jsx-quotes': ['error', 'prefer-double'],
24 | 'keyword-spacing': 'error',
25 | 'no-extra-semi': 'error',
26 | 'no-sequences': 'error',
27 | 'quotes': ['error', 'single'],
28 | 'react/button-has-type': 'error',
29 | 'react/function-component-definition': 'error',
30 | 'react/jsx-curly-newline': 'error',
31 | 'react/jsx-indent': ['error', 'tab'],
32 | 'react/jsx-no-duplicate-props': 'error',
33 | 'react/jsx-no-useless-fragment': 'error',
34 | 'react/jsx-props-no-multi-spaces': 'error',
35 | 'react/jsx-tag-spacing': ['error', { beforeClosing: 'never' }],
36 | 'react/no-access-state-in-setstate': 'error',
37 | 'react/no-children-prop': 'error',
38 | 'react/no-deprecated': 'warn',
39 | 'react/no-direct-mutation-state': 'error',
40 | 'react/no-unescaped-entities': 'warn',
41 | 'react/style-prop-object': 'error',
42 | 'no-unexpected-multiline': 'warn',
43 | 'no-extra-boolean-cast': 'warn',
44 | 'no-unsafe-finally': 'warn',
45 | 'no-irregular-whitespace': 'error',
46 | 'object-curly-spacing': ['error', 'always'],
47 | 'semi': ['error', 'always'],
48 | 'yoda': 'error',
49 | },
50 | };
51 |
--------------------------------------------------------------------------------
/.gitattributes:
--------------------------------------------------------------------------------
1 | *.html text diff=html
2 | *.js text diff=javascript
3 | *.css text
4 | *.md text
5 | *.json text
6 | *.yml text
7 | *.svg text
8 | *.sh text eol=lf
9 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/Bug_report.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Bug report
3 | about: Report something broken in the debugger
4 | custom_fields: []
5 |
6 | ---
7 |
8 | ### What happens?
9 |
10 | (include screenshots if applicable.)
11 |
12 |
13 | ### Reproduction steps?
14 |
15 | (don't forget steps required in PPSSPP, if any...)
16 |
17 |
18 | ### With what versions?
19 |
20 | Debugger
21 | * Browser version: [e.g. Chrome 68]
22 | * OS: [e.g. Windows 10]
23 |
24 | PPSSPP
25 | * Version: [e.g. 1.6.0]
26 | * Device: [e.g. PC or NVIDIA SHIELD TV]
27 | * OS: [e.g. Android 8.1]
28 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/New_feature.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: New feature
3 | about: New ideas for the debugger
4 | custom_fields: []
5 |
6 | ---
7 |
8 | ### What do you want to accomplish?
9 |
10 |
11 | ### What do you think would solve it?
12 |
13 |
14 | ### Any other debuggers or apps with this feature?
15 |
16 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/Question.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Question
3 | about: Usage questions and help
4 | custom_fields: []
5 |
6 | ---
7 |
8 | You can ask your question here but:
9 |
10 | * Consider the forum: http://forums.ppsspp.org/forumdisplay.php?fid=3
11 | * Make sure it's about the debugger, not the API or PPSSPP
12 | * If the answer helps, consider watching the repo to help others
13 |
14 | Make sure to describe what you've tried so far and what you're trying to accomplish.
15 |
--------------------------------------------------------------------------------
/.github/workflows/deploy.yml:
--------------------------------------------------------------------------------
1 | name: Deployment
2 |
3 | on:
4 | push:
5 | branches:
6 | - master
7 |
8 | jobs:
9 | deploy:
10 | runs-on: ubuntu-latest
11 |
12 | steps:
13 | - uses: actions/checkout@v2
14 |
15 | - name: Setup Node.js
16 | uses: actions/setup-node@v2
17 | with:
18 | node-version: '14'
19 |
20 | - name: Install packages
21 | run: yarn install --frozen-lockfile
22 |
23 | - name: Build
24 | run: yarn build
25 |
26 | - name: Update 404 HTML
27 | run: cp build/index.html build/404.html
28 |
29 | - name: Deploy to gh-pages
30 | uses: peaceiris/actions-gh-pages@v3
31 | with:
32 | github_token: ${{ secrets.GITHUB_TOKEN }}
33 | publish_dir: ./build
34 | cname: ppsspp-debugger.unknownbrackets.org
35 |
36 | bundle:
37 | runs-on: ubuntu-latest
38 |
39 | steps:
40 | - uses: actions/checkout@v2
41 |
42 | - name: Setup Node.js
43 | uses: actions/setup-node@v2
44 | with:
45 | node-version: '14'
46 |
47 | - name: Install packages
48 | run: yarn install --frozen-lockfile
49 |
50 | - name: Build
51 | run: yarn build
52 | env:
53 | PUBLIC_URL: '.'
54 |
55 | - name: Deploy to bundled
56 | uses: peaceiris/actions-gh-pages@v3
57 | with:
58 | github_token: ${{ secrets.GITHUB_TOKEN }}
59 | publish_dir: ./build
60 | publish_branch: bundled
61 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/ignore-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 |
6 | # testing
7 | /coverage
8 |
9 | # production
10 | /build
11 |
12 | # misc
13 | .DS_Store
14 | .env.local
15 | .env.development.local
16 | .env.test.local
17 | .env.production.local
18 |
19 | npm-debug.log*
20 | yarn-debug.log*
21 | yarn-error.log*
22 |
23 | # IDEs
24 | .idea/
25 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: node_js
2 | sudo: false
3 | node_js:
4 | - node
5 | - lts/*
6 | cache:
7 | yarn: true
8 | directories:
9 | - node_modules
10 | script:
11 | - yarn build
12 | - yarn eslint src
13 | - yarn test
14 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | ppsspp-debugger
2 | ===============
3 |
4 | A debugger UI for PPSSPP written in React.
5 |
6 |
7 | Release
8 | -------
9 |
10 | Recent builds are published here:
11 |
12 | http://ppsspp-debugger.unknownbrackets.org/
13 |
14 |
15 | Contributing
16 | ------------
17 |
18 | Contributions welcome. New features, bug fixes, anything good for debugging.
19 |
20 | ### Getting started
21 |
22 | First make sure to install [Node.js](https://nodejs.org/) (LTS is fine) and [Yarn](https://yarnpkg.com/).
23 |
24 | ```sh
25 | git clone https://github.com/unknownbrackets/ppsspp-debugger.git
26 | yarn # or npm install
27 | yarn start
28 | ```
29 |
30 | This will automatically open a browser with the development version of the app.
31 |
32 | ### Debugging and editing
33 |
34 | Change files in src/, and the app will automatically reload.
35 |
36 | Many IDEs work well, including Visual Studio 2017 and vim. The app runs in
37 | browser, so use the browser's debugger (IDE debuggers are for server-side.)
38 |
39 | Note that components aren't re-rendered unless their props or state change.
40 | These are shallow compares, so use `{ ...old, new: 1 }` to update objects.
41 |
42 | ### Building
43 |
44 | ```sh
45 | yarn build
46 | ```
47 |
48 | This will create a `build` directory with files ready for everyday use.
49 |
50 | ### What's this crazy syntax?
51 |
52 | This app uses modern JavaScript syntax + JSX extensions.
53 |
54 | * [Simple React tutorial](https://reactjs.org/tutorial/tutorial.html)
55 | * [Intro to JSX](https://reactjs.org/docs/introducing-jsx.html)
56 | * [JavaScript versions](https://developer.mozilla.org/en-US/docs/Web/JavaScript/New_in_JavaScript)
57 | * [Equality in JavaScript](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Equality_comparisons_and_sameness)
58 |
59 |
60 | Features
61 | --------
62 |
63 | * Connects to local or remote PPSSPPs (as long as not on HTTPS)
64 | * Supports PPSSPP on desktop, mobile, and consoles
65 | * CPU debugger with breakpoints
66 | * Keyboard shortcuts
67 |
68 |
69 | Credits and Licensing
70 | ---------------------
71 |
72 | ppsspp-debugger is based upon the old Windows debugger in PPSSPP, especially
73 | thanks to:
74 |
75 | * @hrydgard
76 | * @Kingcom
77 | * All contributors to PPSSPP
78 |
79 | This code is licensed under GPL 3.0 or later.
80 |
81 |
82 | Interested in PPSSPP's API?
83 | ---------------------------
84 |
85 | This debugger uses PPSSPP's WebSocket API, but it can be used by other apps
86 | and tools - complex or simple. For API issues and questions, check the
87 | [PPSSPP repo](https://github.com/hrydgard/ppsspp).
88 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "ppsspp-debugger",
3 | "version": "1.0.0",
4 | "homepage": "http://ppsspp-debugger.unknownbrackets.org",
5 | "license": "GPL-3.0-or-later",
6 | "dependencies": {
7 | "clsx": "^1.1.1",
8 | "react": "^18.1.0",
9 | "react-app-polyfill": "^3.0.0",
10 | "react-contextmenu": "^2.14.0",
11 | "react-dom": "^18.1.0",
12 | "react-modal": "^3.15.1",
13 | "react-router-dom": "^5.3.3",
14 | "react-scripts": "5.0.1",
15 | "react-tabs": "^5.1.0",
16 | "react-virtualized": "^9.22.3"
17 | },
18 | "devDependencies": {
19 | "eslint-plugin-jsdoc": "^39.3.0"
20 | },
21 | "scripts": {
22 | "start": "react-scripts start",
23 | "build": "react-scripts build",
24 | "test": "react-scripts test",
25 | "eject": "react-scripts eject"
26 | },
27 | "browserslist": [
28 | ">0.2%",
29 | "not dead",
30 | "not ie < 11",
31 | "not op_mini all"
32 | ]
33 | }
34 |
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/unknownbrackets/ppsspp-debugger/433d04520238b27b4e0614e40e2658ecf4d6a157/public/favicon.ico
--------------------------------------------------------------------------------
/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
11 |
12 |
13 |
22 | PPSSPP Debugger
23 |
24 |
25 |
26 | You need to enable JavaScript to run this app.
27 |
28 |
29 |
39 |
40 |
41 |
--------------------------------------------------------------------------------
/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "short_name": "Debugger",
3 | "name": "PPSSPP Debugger",
4 | "icons": [
5 | {
6 | "src": "favicon.ico",
7 | "sizes": "256x256 128x128 48x48 32x32 16x16",
8 | "type": "image/x-icon"
9 | }
10 | ],
11 | "start_url": "./index.html",
12 | "display": "standalone",
13 | "theme_color": "#000000",
14 | "background_color": "#ffffff"
15 | }
16 |
--------------------------------------------------------------------------------
/src/assets/logo.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
--------------------------------------------------------------------------------
/src/components/App.css:
--------------------------------------------------------------------------------
1 | .App {
2 | display: flex;
3 | flex-direction: column;
4 | flex-grow: 1;
5 | height: 100%;
6 | z-index: 0;
7 | }
8 |
9 | .App-logo {
10 | display: inline-block;
11 | height: 24px;
12 | margin-right: 5px;
13 | vertical-align: top;
14 | }
15 |
16 | .App-header {
17 | /* Roughly from PPSSPP's background, maybe replace with something better. */
18 | background: linear-gradient(135deg, #0d2d3b, #163951 25%, #1c4261 53%, #27517c 89%, #234b72);
19 | border-bottom: 2px solid #3999bd;
20 | color: white;
21 | height: 24px;
22 | padding: 8px;
23 | margin-bottom: 3px;
24 | text-align: right;
25 | }
26 |
27 | .App-title {
28 | display: inline-block;
29 | font-family: Roboto, sans-serif;
30 | font-size: 20px;
31 | font-weight: normal;
32 | margin: 0;
33 | }
34 |
35 | .App-nav {
36 | align-items: center;
37 | display: flex;
38 | float: left;
39 | height: 100%;
40 | margin: 0;
41 | padding: 0;
42 | }
43 |
44 | .App-nav a {
45 | color: #fff;
46 | font-size: 16px;
47 | margin-right: 2px;
48 | padding: 10px 1ex;
49 | text-decoration: none;
50 | }
51 |
52 | .App-nav a.active {
53 | background: #3999bd;
54 | }
55 |
56 | .App-nav a:hover,
57 | .App-nav a:active,
58 | .App-nav a:focus {
59 | background-color: #4cc2ed;
60 | }
61 |
62 | .App-utilityPanel {
63 | display: flex;
64 | flex: 1;
65 | max-height: calc(25vh - 24px);
66 | min-height: 5em;
67 | }
68 |
69 | .App-utilityPanel .react-tabs {
70 | display: flex;
71 | flex: 1;
72 | flex-direction: column;
73 | }
74 |
75 | .App-utilityPanel .react-tabs__tab-panel--selected {
76 | display: flex;
77 | flex: 1 1 auto;
78 | flex-direction: column;
79 | min-height: 0;
80 | }
81 |
--------------------------------------------------------------------------------
/src/components/App.js:
--------------------------------------------------------------------------------
1 | import { Component } from 'react';
2 | import { BrowserRouter as Router, NavLink, Route, Switch } from 'react-router-dom';
3 | import CPU from './CPU';
4 | import GPU from './GPU';
5 | import { DebuggerProvider } from './DebuggerContext';
6 | import NotConnected from './NotConnected';
7 | import PPSSPP from '../sdk/ppsspp.js';
8 | import listeners from '../utils/listeners.js';
9 | import logger from '../utils/logger.js';
10 | import GameStatus from '../utils/game.js';
11 | import { breakpointPersister } from '../utils/persist.js';
12 | import logo from '../assets/logo.svg';
13 | import './App.css';
14 |
15 | const versionInfo = {
16 | name: process.env.REACT_APP_NAME,
17 | version: process.env.REACT_APP_VERSION,
18 | };
19 |
20 | class App extends Component {
21 | state = {
22 | connected: false,
23 | connecting: false,
24 | gameStatus: null,
25 | };
26 | gameStatus;
27 | ppsspp;
28 | originalTitle;
29 |
30 | constructor(props) {
31 | super(props);
32 |
33 | this.ppsspp = new PPSSPP();
34 | this.gameStatus = new GameStatus();
35 | this.originalTitle = document.title;
36 |
37 | listeners.init(this.ppsspp);
38 | listeners.listen({
39 | 'connection.change': this.onConnectionChange,
40 | 'game.start': this.updateTitle,
41 | 'game.quit': this.updateTitle,
42 | });
43 | logger.init(this.ppsspp);
44 | this.gameStatus.init(this.ppsspp);
45 | this.gameStatus.listenState(gameStatus => {
46 | this.setState({ gameStatus });
47 | });
48 | breakpointPersister.init(this.ppsspp);
49 | }
50 |
51 | render() {
52 | return (
53 |
54 |
55 |
56 |
57 |
58 | m || l.pathname === '/'}>CPU
59 | GPU
60 |
61 |
62 | Debugger
63 |
64 | {this.renderContent()}
65 |
66 |
67 |
68 | );
69 | }
70 |
71 | renderContent() {
72 | if (!this.state.connected) {
73 | return ;
74 | }
75 |
76 | return (
77 |
78 |
79 |
80 |
81 |
82 |
83 |
84 |
85 | );
86 | }
87 |
88 | componentDidMount() {
89 | // Connect automatically on start.
90 | if (!this.props.testing) {
91 | this.handleAutoConnect();
92 | }
93 | }
94 |
95 | handleAutoConnect = () => {
96 | this.connect(null);
97 | };
98 |
99 | connect = (uri) => {
100 | this.setState({ connecting: true });
101 |
102 | this.ppsspp.onClose = () => {
103 | this.log('Debugger disconnected');
104 | listeners.change(false);
105 | this.setState({ connected: false, connecting: false });
106 | };
107 |
108 | let connection;
109 | if (uri === null) {
110 | connection = this.ppsspp.autoConnect();
111 | } else {
112 | connection = this.ppsspp.connect(uri);
113 | }
114 |
115 | connection.then(() => {
116 | this.log('Debugger connected');
117 | listeners.change(true);
118 | this.setState({ connected: true, connecting: false });
119 | }, err => {
120 | this.log('Debugger could not connect');
121 | listeners.change(false);
122 | this.setState({ connected: false, connecting: false });
123 | });
124 | };
125 |
126 | handleDisconnect = () => {
127 | // Should trigger the appropriate events automatically.
128 | this.ppsspp.disconnect();
129 | };
130 |
131 | log = (message) => {
132 | // Would rather keep Logger managing its state, and pass this callback around.
133 | logger.addLogItem({ message: message + '\n' });
134 | };
135 |
136 | updateTitle = (data) => {
137 | if (!document) {
138 | return;
139 | }
140 |
141 | if (data.game) {
142 | document.title = this.originalTitle + ' - ' + data.game.id + ': ' + data.game.title;
143 | } else {
144 | document.title = this.originalTitle;
145 | }
146 | };
147 |
148 | onConnectionChange = (status) => {
149 | if (status) {
150 | this.ppsspp.send({ event: 'version', ...versionInfo }).catch((err) => {
151 | window.alert('PPSSPP seems to think this debugger is out of date. Try refreshing?\n\nDetails: ' + err);
152 | });
153 | this.ppsspp.send({ event: 'game.status' }).then(this.updateTitle, (err) => this.updateTitle({}));
154 | } else {
155 | this.updateTitle({});
156 | }
157 | };
158 | }
159 |
160 | export default App;
161 |
--------------------------------------------------------------------------------
/src/components/App.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { createRoot } from 'react-dom/client';
3 | import App from './App';
4 |
5 | it('renders without crashing', () => {
6 | const div = document.createElement('div');
7 | const root = createRoot(div);
8 | root.render( );
9 | root.unmount();
10 | });
11 |
--------------------------------------------------------------------------------
/src/components/CPU.css:
--------------------------------------------------------------------------------
1 | #CPU {
2 | display: flex;
3 | flex: 1;
4 | flex-direction: column;
5 | }
6 |
7 | .CPU__main {
8 | display: flex;
9 | flex: 1;
10 | max-height: calc(75vh - 24px);
11 | min-height: 20em;
12 | }
13 |
14 | .CPU__pane {
15 | display: flex;
16 | flex: 0 0 auto;
17 | flex-direction: column;
18 | }
19 |
20 | .CPU__paneClose {
21 | display: none;
22 | }
23 |
24 | @media only screen and (max-width: 700px) {
25 | .CPU__pane {
26 | background: #fff;
27 | display: none;
28 | position: absolute;
29 | z-index: 1;
30 | }
31 |
32 | .CPU__pane--open {
33 | display: flex;
34 | height: 75vh;
35 | }
36 |
37 | .CPU__paneClose {
38 | display: block;
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/src/components/CPU.js:
--------------------------------------------------------------------------------
1 | import { PureComponent } from 'react';
2 | import { Tab, TabList, TabPanel, Tabs } from 'react-tabs';
3 | import BreakpointPanel from './CPU/BreakpointPanel';
4 | import DebuggerContext, { DebuggerContextValues } from './DebuggerContext';
5 | import Disasm from './CPU/Disasm';
6 | import DisasmButtons from './CPU/DisasmButtons';
7 | import GotoBox from './common/GotoBox';
8 | import LeftPanel from './CPU/LeftPanel';
9 | import listeners from '../utils/listeners.js';
10 | import Log from './Log';
11 | import './CPU.css';
12 |
13 | class CPU extends PureComponent {
14 | state = {
15 | setInitialPC: false,
16 | // Note: these are inclusive.
17 | selectionTop: null,
18 | selectionBottom: null,
19 | jumpMarker: null,
20 | promptGotoMarker: null,
21 | lastTicks: 0,
22 | ticks: 0,
23 | navTray: false,
24 | };
25 | /**
26 | * @type {DebuggerContextValues}
27 | */
28 | context;
29 | listeners_;
30 |
31 | render() {
32 | const { pc, currentThread } = this.context.gameStatus;
33 | const { selectionTop, selectionBottom, jumpMarker, setInitialPC, navTray } = this.state;
34 | const disasmProps = { currentThread, selectionTop, selectionBottom, jumpMarker, pc, setInitialPC };
35 |
36 | return (
37 |
38 | {this.renderMain(navTray, disasmProps)}
39 | {this.renderUtilityPanel()}
40 |
41 | );
42 | }
43 |
44 | renderMain(navTray, disasmProps) {
45 | const { stepping, paused, started, currentThread } = this.context.gameStatus;
46 |
47 | return (
48 |
49 |
50 | Close
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 | );
60 | }
61 |
62 | renderUtilityPanel() {
63 | return (
64 |
65 |
66 |
67 | Log
68 | Breakpoints
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 | );
79 | }
80 |
81 | componentDidMount() {
82 | this.listeners_ = listeners.listen({
83 | 'connection': () => this.onConnection(),
84 | 'cpu.stepping': (data) => this.onStepping(data),
85 | 'game.start': () => {
86 | // This may often not happen if the register list is visible.
87 | if (!this.state.setInitialPC) {
88 | this.updateInitialPC();
89 | }
90 | },
91 | 'game.quit': () => {
92 | this.setState({
93 | setInitialPC: false,
94 | });
95 | },
96 | 'cpu.setReg': (result) => {
97 | if (result.category === 0 && result.register === 32) {
98 | const pc = result.uintValue;
99 | if (!this.state.setInitialPC) {
100 | this.gotoDisasm(pc);
101 | }
102 | this.setState({ setInitialPC: pc !== 0 });
103 | }
104 | },
105 | 'cpu.getAllRegs': (result) => {
106 | const pc = result.categories[0].uintValues[32];
107 | if (!this.state.setInitialPC) {
108 | this.gotoDisasm(pc);
109 | }
110 | this.setState({ setInitialPC: pc !== 0 });
111 | },
112 | });
113 | }
114 |
115 | componentWillUnmount() {
116 | listeners.forget(this.listeners_);
117 | }
118 |
119 | onConnection() {
120 | // Update the status of this connection immediately too.
121 | this.context.ppsspp.send({ event: 'cpu.status' }).then((result) => {
122 | const { pc, ticks } = result;
123 |
124 | if (!this.state.setInitialPC) {
125 | this.gotoDisasm(pc);
126 | }
127 | this.setState({ setInitialPC: pc !== 0, ticks, lastTicks: ticks });
128 | });
129 | }
130 |
131 | onStepping(data) {
132 | this.setState(prevState => ({
133 | selectionTop: data.pc,
134 | selectionBottom: data.pc,
135 | lastTicks: prevState.ticks,
136 | ticks: data.ticks,
137 | }));
138 | }
139 |
140 | updateSelection = (data) => {
141 | this.setState(data);
142 | };
143 |
144 | gotoDisasm = (pc) => {
145 | this.setState({
146 | selectionTop: pc,
147 | selectionBottom: pc,
148 | // It just matters that this is a new object.
149 | jumpMarker: {},
150 | });
151 | };
152 |
153 | promptGoto = () => {
154 | this.setState({
155 | promptGotoMarker: {},
156 | });
157 | };
158 |
159 | updateCurrentThread = (currentThread, pc) => {
160 | this.setState({ setInitialPC: pc !== 0 && pc !== undefined });
161 | this.context.gameStatus.setState({ currentThread });
162 | if (pc !== 0 && pc !== undefined) {
163 | this.context.gameStatus.setState({ pc });
164 | this.gotoDisasm(pc);
165 | }
166 | };
167 |
168 | showNavTray = () => {
169 | this.setState({ navTray: true });
170 | };
171 |
172 | hideNavTray = () => {
173 | this.setState({ navTray: false });
174 | };
175 |
176 | updateInitialPC = () => {
177 | this.context.ppsspp.send({ event: 'cpu.getReg', name: 'pc' }).then(result => {
178 | const pc = result.uintValue;
179 | if (!this.state.setInitialPC) {
180 | this.gotoDisasm(pc);
181 | }
182 | this.setState({ setInitialPC: pc !== 0 });
183 | this.context.gameStatus.setState({ pc });
184 | });
185 | };
186 | }
187 |
188 | CPU.contextType = DebuggerContext;
189 |
190 | export default CPU;
191 |
--------------------------------------------------------------------------------
/src/components/CPU/BreakpointIcon.js:
--------------------------------------------------------------------------------
1 | import PropTypes from 'prop-types';
2 |
3 | export default function BreakpointIcon(props) {
4 | return (
5 |
6 |
7 |
8 | );
9 | }
10 |
11 | BreakpointIcon.propTypes = {
12 | className: PropTypes.string.isRequired,
13 | };
14 |
--------------------------------------------------------------------------------
/src/components/CPU/BreakpointModal.css:
--------------------------------------------------------------------------------
1 | .BreakpointModal {
2 | min-width: 40ex;
3 | }
4 |
--------------------------------------------------------------------------------
/src/components/CPU/BreakpointModal.js:
--------------------------------------------------------------------------------
1 | import { PureComponent } from 'react';
2 | import DebuggerContext, { DebuggerContextValues } from '../DebuggerContext';
3 | import PropTypes from 'prop-types';
4 | import FitModal from '../common/FitModal';
5 | import Field from '../common/Field';
6 | import Form from '../common/Form';
7 | import { Timeout } from '../../utils/timeouts';
8 | import { toString08X } from '../../utils/format';
9 | import '../ext/react-modal.css';
10 | import './BreakpointModal.css';
11 |
12 | const typeOptions = [
13 | { label: 'Memory', value: 'memory' },
14 | { label: 'Execute', value: 'execute' },
15 | ];
16 |
17 | const operationOptions = [
18 | { label: 'Read', value: 'read' },
19 | { label: 'Write', value: 'write' },
20 | { label: 'On change', value: 'change' },
21 | ];
22 |
23 | const actionOptions = [
24 | { label: 'Break', value: 'enabled' },
25 | { label: 'Log', value: 'log' },
26 | ];
27 |
28 | class BreakpointModal extends PureComponent {
29 | state = {};
30 | /**
31 | * @type {DebuggerContextValues}
32 | */
33 | context;
34 | cleanState = {
35 | isOpen: false,
36 | editing: false,
37 | derivedBreakpoint: null,
38 | };
39 | cleanBreakpoint = {
40 | type: 'memory',
41 | address: '',
42 | size: '0x00000001',
43 | condition: '',
44 | logFormat: '',
45 | read: true,
46 | write: true,
47 | change: false,
48 | enabled: true,
49 | log: true,
50 | };
51 | cleanTimeout;
52 |
53 | constructor(props) {
54 | super(props);
55 | Object.assign(this.state, { ...this.cleanState, ...this.cleanBreakpoint, ...props.breakpoint, ...props.initialOverrides });
56 |
57 | this.cleanTimeout = new Timeout(() => {
58 | this.setState({ ...this.cleanState, ...this.cleanBreakpoint, ...this.props.breakpoint, ...this.props.initialOverrides });
59 | }, 200);
60 | }
61 |
62 | render() {
63 | const editing = this.state.editing;
64 |
65 | return (
66 |
72 |
81 |
82 |
83 | );
84 | }
85 |
86 | renderMemory() {
87 | if (this.state.type !== 'memory') {
88 | return null;
89 | }
90 | return (
91 | <>
92 |
93 |
94 | >
95 | );
96 | }
97 |
98 | renderExecute() {
99 | if (this.state.type !== 'execute') {
100 | return null;
101 | }
102 | return (
103 |
104 | );
105 | }
106 |
107 | hasChanges() {
108 | const compareTo = this.state.derivedBreakpoint || this.cleanBreakpoint;
109 | return Object.keys(compareTo).find(key => this.state[key] !== compareTo[key]) !== undefined;
110 | }
111 |
112 | componentDidUpdate(prevProps, prevState) {
113 | if (this.props.isOpen && this.cleanTimeout != null) {
114 | this.cleanTimeout.runEarly();
115 | }
116 | }
117 |
118 | onSave = (e) => {
119 | let operation = this.context.ppsspp.send({
120 | event: 'cpu.evaluate',
121 | thread: this.context.gameStatus.currentThread,
122 | expression: this.state.address,
123 | });
124 |
125 | if (this.props.breakpoint) {
126 | const { derivedBreakpoint, address, size, type } = this.state;
127 | if (type !== derivedBreakpoint.type || derivedBreakpoint.address !== address) {
128 | operation = operation.then(this.deleteOld).then(this.saveNew);
129 | } else if (type === 'memory' && derivedBreakpoint.size !== size) {
130 | operation = operation.then(this.deleteOld).then(this.saveNew);
131 | } else {
132 | operation = operation.then(this.updateExisting);
133 | }
134 | } else {
135 | operation = operation.then(this.saveNew);
136 | }
137 |
138 | operation.then(this.onClose, err => {
139 | window.alert(err.message);
140 | });
141 |
142 | e.preventDefault();
143 | };
144 |
145 | saveNew = ({ uintValue }) => {
146 | return this.context.ppsspp.send({
147 | event: this.getEvent(this.state.type, 'add'),
148 | ...this.state,
149 | address: uintValue,
150 | });
151 | };
152 |
153 | deleteOld = ({ uintValue }) => {
154 | // This is used when changing the breakpoint type.
155 | return this.context.ppsspp.send({
156 | event: this.getEvent(this.props.breakpoint.type, 'remove'),
157 | address: this.props.breakpoint.address,
158 | size: this.props.breakpoint.size,
159 | }).then(() => {
160 | // Return the original new address for easy sequencing.
161 | return { uintValue };
162 | });
163 | };
164 |
165 | updateExisting = ({ uintValue }) => {
166 | return this.context.ppsspp.send({
167 | event: this.getEvent(this.state.type, 'update'),
168 | ...this.state,
169 | address: uintValue,
170 | });
171 | };
172 |
173 | getEvent(type, event) {
174 | if (type === 'execute') {
175 | return 'cpu.breakpoint.' + event;
176 | } else if (type === 'memory') {
177 | return 'memory.breakpoint.' + event;
178 | } else {
179 | throw new Error('Unexpected type: ' + type);
180 | }
181 | }
182 |
183 | onClose = () => {
184 | this.cleanTimeout.start();
185 | this.props.onClose();
186 | };
187 |
188 | static getDerivedStateFromProps(nextProps, prevState) {
189 | if (nextProps.isOpen && !prevState.isOpen) {
190 | const derivedBreakpoint = nextProps.breakpoint;
191 | if (derivedBreakpoint) {
192 | // This is the "derived" unchanged state for the "Discard changes?" prompt, and initial state.
193 | derivedBreakpoint.address = '0x' + toString08X(derivedBreakpoint.address);
194 | if (derivedBreakpoint.size) {
195 | derivedBreakpoint.size = '0x' + toString08X(derivedBreakpoint.size);
196 | }
197 | derivedBreakpoint.condition = derivedBreakpoint.condition || '';
198 | derivedBreakpoint.logFormat = derivedBreakpoint.logFormat || '';
199 | }
200 | const initialOverrides = nextProps.initialOverrides;
201 | if (initialOverrides) {
202 | initialOverrides.address = '0x' + toString08X(initialOverrides.address);
203 | if (initialOverrides.size) {
204 | initialOverrides.size = '0x' + toString08X(initialOverrides.size);
205 | }
206 | }
207 | return {
208 | isOpen: true,
209 | editing: !!derivedBreakpoint,
210 | ...derivedBreakpoint,
211 | derivedBreakpoint,
212 | ...initialOverrides,
213 | };
214 | }
215 | if (!nextProps.isOpen && prevState.derivedBreakpoint) {
216 | return { derivedBreakpoint: null };
217 | }
218 | return null;
219 | }
220 | }
221 |
222 | BreakpointModal.propTypes = {
223 | isOpen: PropTypes.bool.isRequired,
224 | breakpoint: PropTypes.shape({
225 | type: PropTypes.oneOf(['execute', 'memory']),
226 | address: PropTypes.number.isRequired,
227 | }),
228 | initialOverrides: PropTypes.shape({
229 | type: PropTypes.oneOf(['execute', 'memory']),
230 | address: PropTypes.number.isRequired,
231 | }),
232 |
233 | onClose: PropTypes.func.isRequired,
234 | };
235 |
236 | BreakpointModal.contextType = DebuggerContext;
237 |
238 | export default BreakpointModal;
239 |
--------------------------------------------------------------------------------
/src/components/CPU/BreakpointPanel.js:
--------------------------------------------------------------------------------
1 | import { useCallback, useEffect, useState } from 'react';
2 | import PropTypes from 'prop-types';
3 | import { useDebuggerContext } from '../DebuggerContext';
4 | import BreakpointPanelList from './BreakpointPanelList';
5 | import listeners from '../../utils/listeners';
6 |
7 | export default function BreakpointPanel(props) {
8 | const context = useDebuggerContext();
9 |
10 | const [cpuBreakpoints, setCpuBreakpoints] = useState([]);
11 | const [memoryBreakpoints, setMemoryBreakpoints] = useState([]);
12 | const [selectedRow, setSelectedRow] = useState(-1);
13 |
14 | const breakpoints = [...memoryBreakpoints, ...cpuBreakpoints];
15 |
16 | const fetchCpuBreakpoints = useCallback(() => {
17 | if (!context.gameStatus.started) {
18 | return;
19 | }
20 | context.ppsspp.send({
21 | event: 'cpu.breakpoint.list',
22 | }).then(({ breakpoints }) => {
23 | setCpuBreakpoints(breakpoints.map(b => ({ ...b, type: 'execute' })));
24 | }, () => {
25 | setCpuBreakpoints([]);
26 | });
27 | }, [context.ppsspp, context.gameStatus.started]);
28 |
29 | const fetchMemoryBreakpoints = useCallback(() => {
30 | if (!context.gameStatus.started) {
31 | return;
32 | }
33 | context.ppsspp.send({
34 | event: 'memory.breakpoint.list',
35 | }).then(({ breakpoints }) => {
36 | setMemoryBreakpoints(breakpoints.map(b => ({ ...b, type: 'memory' })));
37 | }, () => {
38 | setMemoryBreakpoints([]);
39 | });
40 | }, [context.ppsspp, context.gameStatus.started]);
41 |
42 | const fetchBreakpoints = useCallback(() => {
43 | fetchCpuBreakpoints();
44 | fetchMemoryBreakpoints();
45 | }, [fetchCpuBreakpoints, fetchMemoryBreakpoints]);
46 |
47 | const clearBreakpoints = useCallback(() => {
48 | setCpuBreakpoints([]);
49 | setMemoryBreakpoints([]);
50 | setSelectedRow(-1);
51 | }, []);
52 |
53 | useEffect(() => {
54 | const listeners_ = listeners.listen({
55 | 'game.start': () => fetchBreakpoints(),
56 | 'game.quit': () => clearBreakpoints(),
57 | 'connection': () => fetchBreakpoints(),
58 | 'cpu.stepping': () => fetchBreakpoints(),
59 | 'cpu.breakpoint.add': () => fetchCpuBreakpoints(),
60 | 'cpu.breakpoint.update': () => fetchCpuBreakpoints(),
61 | 'cpu.breakpoint.remove': () => fetchCpuBreakpoints(),
62 | 'memory.breakpoint.add': () => fetchMemoryBreakpoints(),
63 | 'memory.breakpoint.update': () => fetchMemoryBreakpoints(),
64 | 'memory.breakpoint.remove': () => fetchMemoryBreakpoints(),
65 | });
66 | return () => listeners.forget(listeners_);
67 | }, [fetchBreakpoints, fetchCpuBreakpoints, fetchMemoryBreakpoints, clearBreakpoints]);
68 |
69 | return (
70 |
71 | );
72 | }
73 |
74 | BreakpointPanel.propTypes = {
75 | gotoDisasm: PropTypes.func.isRequired,
76 | };
77 |
--------------------------------------------------------------------------------
/src/components/CPU/BreakpointPanelContextMenu.js:
--------------------------------------------------------------------------------
1 | import { connectMenu, ContextMenu, MenuItem } from 'react-contextmenu';
2 | import PropTypes from 'prop-types';
3 |
4 | function BreakpointPanelContextMenu(props) {
5 | const { id, trigger, hasBreakpoints, hasEnabledBreakpoints } = props;
6 |
7 | return (
8 |
9 | {trigger?.breakpoint ? (<>
10 | props.toggleBreakpoint(trigger.breakpoint)}>
11 | Toggle Break
12 |
13 | props.editBreakpoint(trigger.breakpoint)}>
14 | Edit…
15 |
16 | props.removeBreakpoint(trigger.breakpoint)}>
17 | Remove
18 |
19 |
20 | >) : null}
21 | props.createBreakpoint()}>
22 | Add New…
23 |
24 | props.clearBreakpoints()} disabled={!hasBreakpoints}>
25 | Remove All…
26 |
27 | props.disableBreakpoints()} disabled={!hasEnabledBreakpoints}>
28 | Disable All
29 |
30 |
31 | );
32 | }
33 |
34 | BreakpointPanelContextMenu.propTypes = {
35 | id: PropTypes.string.isRequired,
36 | trigger: PropTypes.shape({
37 | breakpoint: PropTypes.object,
38 | }),
39 |
40 | toggleBreakpoint: PropTypes.func.isRequired,
41 | editBreakpoint: PropTypes.func.isRequired,
42 | removeBreakpoint: PropTypes.func.isRequired,
43 | createBreakpoint: PropTypes.func.isRequired,
44 | clearBreakpoints: PropTypes.func.isRequired,
45 | disableBreakpoints: PropTypes.func.isRequired,
46 | hasBreakpoints: PropTypes.bool.isRequired,
47 | hasEnabledBreakpoints: PropTypes.bool.isRequired,
48 | };
49 |
50 | export default connectMenu('breakpointPanel')(BreakpointPanelContextMenu);
51 |
--------------------------------------------------------------------------------
/src/components/CPU/BreakpointPanelItem.css:
--------------------------------------------------------------------------------
1 | .BreakpointPanelItem--selected {
2 | background: #39f;
3 | color: #fff;
4 | }
5 |
6 | .BreakpointPanelItem__break-check {
7 | margin: 0;
8 | padding: 0;
9 | }
10 |
11 | .BreakpointPanelItem__break-check-cell {
12 | text-align: center;
13 | }
14 |
--------------------------------------------------------------------------------
/src/components/CPU/BreakpointPanelItem.js:
--------------------------------------------------------------------------------
1 | import { ContextMenuTrigger } from 'react-contextmenu';
2 | import PropTypes from 'prop-types';
3 | import { useDebuggerContext } from '../DebuggerContext';
4 | import { toString08X } from '../../utils/format';
5 | import './BreakpointPanelItem.css';
6 |
7 | export default function BreakpointPanelItem(props) {
8 | const context = useDebuggerContext();
9 |
10 | const { breakpoint, selected } = props;
11 |
12 | const mapData = (props) => {
13 | return { breakpoint: breakpoint };
14 | };
15 | const attributes = {
16 | onDoubleClick: () => {
17 | props.gotoBreakpoint(breakpoint);
18 | return false;
19 | },
20 | onMouseDown: () => {
21 | props.onSelect();
22 | return false;
23 | },
24 | className: selected ? 'BreakpointPanelItem--selected' : null,
25 | };
26 |
27 | const breakCheckbox = (
28 |
29 | props.toggleBreakpoint(breakpoint)} />
30 |
31 | );
32 |
33 | if (breakpoint.type === 'execute') {
34 | return (
35 |
36 | {breakCheckbox}
37 | Execute
38 | 0x{toString08X(breakpoint.address)}
39 | {breakpoint.symbol || '-'}
40 | {breakpoint.code || '-'}
41 | {breakpoint.condition || '-'}
42 | -
43 |
44 | );
45 | } else if (breakpoint.type === 'memory') {
46 | return (
47 |
48 | {breakCheckbox}
49 | {mapMemoryBreakpointType(breakpoint)}
50 | 0x{toString08X(breakpoint.address)}
51 | 0x{toString08X(breakpoint.size)}
52 | {breakpoint.code || '-'}
53 | {breakpoint.condition || '-'}
54 | {breakpoint.hits}
55 |
56 | );
57 | } else {
58 | context.log('Unhandled breakpoint type: ' + breakpoint.type);
59 | return null;
60 | }
61 | }
62 |
63 | function mapMemoryBreakpointType(breakpoint) {
64 | const { write, read, change } = breakpoint;
65 |
66 | if ((!read && !write) || (!write && read && change)) {
67 | return 'Invalid';
68 | }
69 |
70 | let type;
71 | if (read && write) {
72 | type = 'Read/Write';
73 | } else if (read) {
74 | type = 'Read';
75 | } else if (write) {
76 | type = 'Write';
77 | }
78 |
79 | if (change) {
80 | return type + ' Change';
81 | } else {
82 | return type;
83 | }
84 | }
85 |
86 | BreakpointPanelItem.propTypes = {
87 | breakpoint: PropTypes.object.isRequired,
88 | selected: PropTypes.bool.isRequired,
89 | gotoBreakpoint: PropTypes.func.isRequired,
90 |
91 | toggleBreakpoint: PropTypes.func.isRequired,
92 | onSelect: PropTypes.func.isRequired,
93 | };
94 |
--------------------------------------------------------------------------------
/src/components/CPU/BreakpointPanelList.css:
--------------------------------------------------------------------------------
1 | #BreakpointPanelList {
2 | flex: 1;
3 | overflow-y: auto;
4 | }
5 |
6 | .BreakpointPanelList__no-breakpoints {
7 | font-size: larger;
8 | justify-content: center;
9 | padding: .5em;
10 | text-align: center;
11 | }
12 |
13 | .BreakpointPanelList__table {
14 | border-collapse: collapse;
15 | width: 100%;
16 | }
17 |
18 | .BreakpointPanelList__table th {
19 | background: #eee;
20 | border-bottom: 1px solid #ccc;
21 | text-align: start;
22 | padding: 3px;
23 | }
24 |
25 | .BreakpointPanelList__table td {
26 | padding: 2px;
27 | }
28 |
29 | .BreakpointPanelList__table-column {
30 | width: auto;
31 | }
32 |
33 | .BreakpointPanelList__table-column-type {
34 | width: 18ex;
35 | }
36 |
37 | .BreakpointPanelList__table-column-offset {
38 | width: 14ex;
39 | }
40 |
41 | .BreakpointPanelList__table-column-break {
42 | width: 6ex;
43 | }
44 |
45 | .BreakpointPanelList__table-column-hits {
46 | width: 14ex;
47 | }
48 |
--------------------------------------------------------------------------------
/src/components/CPU/BreakpointPanelList.js:
--------------------------------------------------------------------------------
1 | import { useState } from 'react';
2 | import { ContextMenuTrigger } from 'react-contextmenu';
3 | import PropTypes from 'prop-types';
4 | import { useDebuggerContext } from '../DebuggerContext';
5 | import { hasContextMenu } from '../../utils/dom';
6 | import BreakpointModal from './BreakpointModal';
7 | import BreakpointPanelContextMenu from './BreakpointPanelContextMenu';
8 | import BreakpointPanelItem from './BreakpointPanelItem';
9 | import './BreakpointPanelList.css';
10 |
11 | function handleToggleBreakpoint(context, breakpoint) {
12 | if (breakpoint.type === 'execute') {
13 | context.ppsspp.send({
14 | event: 'cpu.breakpoint.update',
15 | address: breakpoint.address,
16 | enabled: !breakpoint.enabled,
17 | });
18 | } else if (breakpoint.type === 'memory') {
19 | context.ppsspp.send({
20 | event: 'memory.breakpoint.update',
21 | address: breakpoint.address,
22 | size: breakpoint.size,
23 | enabled: !breakpoint.enabled,
24 | });
25 | }
26 | }
27 |
28 | function handleRemoveBreakpoint(context, breakpoint) {
29 | if (breakpoint.type === 'execute') {
30 | context.ppsspp.send({
31 | event: 'cpu.breakpoint.remove',
32 | address: breakpoint.address,
33 | });
34 | } else if (breakpoint.type === 'memory') {
35 | context.ppsspp.send({
36 | event: 'memory.breakpoint.remove',
37 | address: breakpoint.address,
38 | size: breakpoint.size,
39 | });
40 | }
41 | }
42 |
43 | function handleClearBreakpoints(context, breakpoints) {
44 | if (!window.confirm('Remove all breakpoints?')) {
45 | return;
46 | }
47 |
48 | for (let breakpoint of breakpoints) {
49 | handleRemoveBreakpoint(context, breakpoint);
50 | }
51 | }
52 |
53 | function handleDisableBreakpoints(context, breakpoints) {
54 | for (let breakpoint of breakpoints) {
55 | if (breakpoint.enabled) {
56 | handleToggleBreakpoint(context, breakpoint);
57 | }
58 | }
59 | }
60 |
61 | export default function BreakpointPanelList(props) {
62 | const context = useDebuggerContext();
63 |
64 | const { breakpoints, selectedRow, setSelectedRow, gotoDisasm } = props;
65 |
66 | const [editingBreakpoint, setEditingBreakpoint] = useState(null);
67 | const [creatingBreakpoint, setCreatingBreakpoint] = useState(false);
68 |
69 | const handleGotoBreakpoint = (breakpoint) => {
70 | if (breakpoint.type === 'execute') {
71 | gotoDisasm(breakpoint.address);
72 | } else if (breakpoint.type === 'memory') {
73 | // TODO: Go to address in memory view (whenever it's implemented)
74 | }
75 | };
76 |
77 | const handleCloseBreakpointModal = () => {
78 | setEditingBreakpoint(null);
79 | setCreatingBreakpoint(false);
80 | };
81 |
82 | const onKeyDown = (ev) => {
83 | if (hasContextMenu()) {
84 | return;
85 | }
86 |
87 | if (ev.key === 'ArrowUp' && selectedRow > 0) {
88 | setSelectedRow(selectedRow - 1);
89 | ev.preventDefault();
90 | }
91 | if (ev.key === 'ArrowDown' && selectedRow < breakpoints.length - 1) {
92 | setSelectedRow(selectedRow + 1);
93 | ev.preventDefault();
94 | }
95 |
96 | const selectedBreakpoint = breakpoints[selectedRow];
97 | if (!selectedBreakpoint) {
98 | return;
99 | }
100 | if (ev.key === 'ArrowRight') {
101 | handleGotoBreakpoint(selectedBreakpoint);
102 | ev.preventDefault();
103 | }
104 | if (ev.key === ' ') {
105 | handleToggleBreakpoint(context, selectedBreakpoint);
106 | ev.preventDefault();
107 | }
108 | if (ev.key === 'Enter') {
109 | setEditingBreakpoint(selectedBreakpoint);
110 | ev.preventDefault();
111 | }
112 | if (ev.key === 'Delete') {
113 | handleRemoveBreakpoint(context, selectedBreakpoint);
114 | ev.preventDefault();
115 | }
116 | };
117 |
118 | return (
119 | <>
120 |
121 |
122 |
123 |
124 | Break
125 | Type
126 | Offset
127 | Size/Label
128 | Opcode
129 | Condition
130 | Hits
131 |
132 |
133 |
134 |
135 |
136 |
137 |
138 |
139 |
140 |
141 |
142 |
143 | {breakpoints.map((breakpoint, index) =>
144 | handleToggleBreakpoint(context, breakpoint)}
150 | onSelect={() => setSelectedRow(index)} />
151 | )}
152 |
153 |
154 | {breakpoints.length === 0 ?
155 | No breakpoints set
156 | : null}
157 |
158 |
163 | handleToggleBreakpoint(context, breakpoint)}
165 | editBreakpoint={(breakpoint) => setEditingBreakpoint(breakpoint)}
166 | removeBreakpoint={(breakpoint) => handleRemoveBreakpoint(context, breakpoint)}
167 | createBreakpoint={() => setCreatingBreakpoint(true)}
168 | hasBreakpoints={breakpoints.length > 0}
169 | hasEnabledBreakpoints={breakpoints.filter(bp => bp.enabled).length > 0}
170 | clearBreakpoints={() => handleClearBreakpoints(context, breakpoints)}
171 | disableBreakpoints={() => handleDisableBreakpoints(context, breakpoints)} />
172 | >
173 | );
174 | }
175 |
176 | BreakpointPanelList.propTypes = {
177 | breakpoints: PropTypes.arrayOf(PropTypes.object).isRequired,
178 | selectedRow: PropTypes.number.isRequired,
179 | setSelectedRow: PropTypes.func.isRequired,
180 | gotoDisasm: PropTypes.func.isRequired,
181 | };
182 |
--------------------------------------------------------------------------------
/src/components/CPU/Disasm.css:
--------------------------------------------------------------------------------
1 | .Disasm {
2 | flex: 1;
3 | font-family: Consolas, monospace;
4 | font-size: 14px;
5 | min-height: 10em;
6 | max-height: calc(100% - 25px);
7 | line-height: 1.26;
8 | overflow-y: auto;
9 | }
10 |
11 | .Disasm--not-started {
12 | align-items: center;
13 | display: flex;
14 | flex: 1 1 auto;
15 | font-size: larger;
16 | justify-content: center;
17 | }
18 |
19 | .Disasm code {
20 | font-family: Consolas, monospace;
21 | }
22 |
23 | .Disasm__container {
24 | display: flex;
25 | flex-direction: column;
26 | position: relative;
27 | width: 100%;
28 | }
29 |
30 | .Disasm__list {
31 | position: relative;
32 | text-align: start;
33 | /* We handle selection. */
34 | user-select: none;
35 | }
36 |
37 | .Disasm .react-contextmenu-wrapper:focus {
38 | /* Already shown on selection. */
39 | outline: none;
40 | }
41 |
42 | .DisasmLine {
43 | white-space: nowrap;
44 | }
45 |
46 | .DisasmLine__breakpoint-icon {
47 | padding-left: 2px;
48 | fill: transparent;
49 | }
50 |
51 | .DisasmLine--current {
52 | /* This uses a linear-gradient to lighten the js-specified background-color value without affecting the text. */
53 | background: linear-gradient(to bottom, rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5));
54 | }
55 |
56 | .DisasmLine--selected {
57 | background: linear-gradient(to bottom, #bcd, #bcd);
58 | }
59 |
60 | .DisasmLine--focused {
61 | background: linear-gradient(to bottom, #39f, #39f);
62 | color: #fff;
63 | }
64 |
65 | .DisasmLine--focused.DisasmLine--cursor {
66 | background: linear-gradient(to bottom, #28f, #28f);
67 | }
68 |
69 | .DisasmLine--breakpoint {
70 | color: #f00;
71 | }
72 |
73 | .DisasmLine--breakpoint .DisasmLine__breakpoint-icon {
74 | fill: #f00;
75 | }
76 |
77 | .DisasmLine--disabled-breakpoint .DisasmLine__breakpoint-icon {
78 | fill: #999;
79 | }
80 |
81 | .DisasmLine--focused.DisasmLine--breakpoint {
82 | text-shadow: 0 0 2px rgba(255, 255, 255, 0.8);
83 | }
84 |
85 | .DisasmLine--focused.DisasmLine--breakpoint .DisasmLine__breakpoint-icon {
86 | filter: drop-shadow(0 0 1px rgba(255, 255, 255, 0.8));
87 | }
88 |
89 | .DisasmLine--focused.DisasmLine--disabled-breakpoint .DisasmLine__breakpoint-icon {
90 | fill: #bbb;
91 | }
92 |
93 | .DisasmLine__address {
94 | display: inline-block;
95 | overflow: hidden;
96 | text-overflow: ellipsis;
97 | vertical-align: top;
98 | width: 20ex;
99 | }
100 |
101 | .DisasmLine__address--nosymbol {
102 | text-align: center;
103 | }
104 |
105 | .DisasmLine__opcode {
106 | display: inline-block;
107 | font-weight: bold;
108 | vertical-align: top;
109 | width: 12ex;
110 | }
111 |
112 | .DisasmLine__opcode::before {
113 | content: "";
114 | display: inline-block;
115 | font-size: 12px;
116 | position: relative;
117 | left: -2px;
118 | width: 1ex;
119 | }
120 |
121 | .DisasmLine--current .DisasmLine__opcode::before {
122 | content: "\25a0";
123 | }
124 |
125 | .DisasmLine__params {
126 | display: inline-block;
127 | position: relative;
128 | text-overflow: ellipsis;
129 | vertical-align: top;
130 | }
131 |
132 | .DisasmLine__param--highlighted {
133 | color: #0ba;
134 | }
135 |
136 | .DisasmLine__highlight {
137 | display: inline-block;
138 | position: relative;
139 | z-index: 0;
140 | }
141 |
142 | .DisasmLine__highlight--end {
143 | width: 100%;
144 | }
145 |
146 | .DisasmLine__highlight::before {
147 | display: inline-block;
148 | content: '';
149 | background: #8cf;
150 | border-radius: 1ex;
151 | position: absolute;
152 | top: -1px;
153 | left: -1px;
154 | right: -1px;
155 | bottom: -1px;
156 | z-index: -1;
157 | }
158 |
159 | .DisasmLine--focused .DisasmLine__highlight::before {
160 | background: #4af;
161 | }
162 |
163 | .DisasmBranchGuide {
164 | position: absolute;
165 | width: 8px;
166 | }
167 |
168 | .DisasmBranchGuide path {
169 | fill: none;
170 | stroke: #23f;
171 | stroke-width: 1px;
172 | }
173 |
174 | .DisasmBranchGuide--selected path {
175 | stroke: #fa7a25;
176 | }
177 |
--------------------------------------------------------------------------------
/src/components/CPU/Disasm.js:
--------------------------------------------------------------------------------
1 | import { createRef, PureComponent } from 'react';
2 | import DebuggerContext, { DebuggerContextValues } from '../DebuggerContext';
3 | import PropTypes from 'prop-types';
4 | import BreakpointModal from './BreakpointModal';
5 | import DisasmContextMenu from './DisasmContextMenu';
6 | import DisasmList from './DisasmList';
7 | import DisasmSearch from './DisasmSearch';
8 | import StatusBar from './StatusBar';
9 | import { toString08X } from '../../utils/format';
10 | import listeners from '../../utils/listeners.js';
11 | import './Disasm.css';
12 |
13 | const MIN_BUFFER = 100;
14 | const MAX_BUFFER = 500;
15 |
16 | class Disasm extends PureComponent {
17 | state = {
18 | lines: [],
19 | branchGuides: [],
20 | range: { start: 0, end: 0 },
21 | lineHeight: 0,
22 | visibleLines: 0,
23 | displaySymbols: true,
24 | wantDisplaySymbols: true,
25 | cursor: null,
26 | searchString: null,
27 | searchInProgress: false,
28 | highlightText: null,
29 | editingBreakpoint: null,
30 | creatingBreakpoint: null,
31 | };
32 | /**
33 | * @type {DebuggerContextValues}
34 | */
35 | context;
36 | jumpStack = [];
37 | needsScroll = false;
38 | needsOffsetFix = false;
39 | updatesCancel = false;
40 | updatesSequence = Promise.resolve(null);
41 | lastSearch = null;
42 | ref;
43 | listRef;
44 | searchRef;
45 | listeners_;
46 |
47 | constructor(props) {
48 | super(props);
49 |
50 | this.ref = createRef();
51 | this.listRef = createRef();
52 | this.searchRef = createRef();
53 | }
54 |
55 | render() {
56 | const events = {
57 | onScroll: ev => this.onScroll(ev),
58 | onDragStart: ev => ev.preventDefault(),
59 | };
60 |
61 | if (!this.context.gameStatus.started) {
62 | return this.renderNotStarted();
63 | }
64 |
65 | return (
66 | <>
67 |
68 |
85 |
86 | l.address === this.state.cursor)} />
87 |
88 | {this.renderContextMenu()}
89 |
95 |
101 | >
102 | );
103 | }
104 |
105 | renderNotStarted() {
106 | return (
107 |
108 | Waiting for a game to start...
109 |
110 | );
111 | }
112 |
113 | renderContextMenu() {
114 | return ;
121 | }
122 |
123 | updateCursor = (cursor) => {
124 | if (this.state.cursor !== cursor) {
125 | this.needsScroll = 'nearest';
126 | this.setState({ cursor });
127 |
128 | this.updatesSequence = this.updatesSequence.then(() => {
129 | if (this.updatesCancel) {
130 | return null;
131 | }
132 |
133 | let { start, end } = this.state.range;
134 | const minBuffer = Math.max(MIN_BUFFER, this.state.visibleLines);
135 | const maxBuffer = Math.max(MAX_BUFFER, this.state.visibleLines * 5);
136 |
137 | // This is here for keyboard scrolling, mainly.
138 | if (cursor - minBuffer * 4 < start) {
139 | start = cursor - minBuffer * 2 * 4;
140 | } else if (cursor - maxBuffer * 4 > start) {
141 | start = cursor - (maxBuffer - minBuffer) * 4;
142 | }
143 | if (cursor + minBuffer * 4 > end) {
144 | end = cursor + minBuffer * 2 * 4;
145 | } else if (cursor + maxBuffer * 4 < end) {
146 | end = cursor + (maxBuffer - minBuffer) * 4;
147 | }
148 |
149 | if (start !== this.state.range.start || end !== this.state.range.end) {
150 | return this.updateDisasmNow('nearest', { address: start, end });
151 | }
152 | return null;
153 | });
154 | }
155 | };
156 |
157 | getSelectedLines = () => {
158 | const { selectionTop, selectionBottom } = this.props;
159 | const isSelected = line => line.address + line.addressSize > selectionTop && line.address <= selectionBottom;
160 | return this.state.lines.filter(isSelected);
161 | };
162 |
163 | getSelectedDisasm = () => {
164 | const lines = this.getSelectedLines();
165 |
166 | // Gather all branch targets without labels.
167 | let branchAddresses = {};
168 | const unlabeledBranches = lines.map(l => l.branch).filter(b => b && !b.symbol && b.targetAddress);
169 | for (const b of unlabeledBranches) {
170 | branchAddresses[b.targetAddress] = 'pos_' + toString08X(b.targetAddress);
171 | }
172 |
173 | let result = '';
174 | let firstLine = true;
175 | for (const l of lines) {
176 | const label = l.symbol || branchAddresses[l.address];
177 | if (label) {
178 | if (!firstLine) {
179 | result += '\n';
180 | }
181 | result += label + ':\n\n';
182 | }
183 |
184 | result += '\t' + l.name + ' ';
185 | if (l.branch && l.branch.targetAddress && !l.branch.symbol) {
186 | // Use the generated label.
187 | result += l.params.replace(/0x[0-9A-f]+/, branchAddresses[l.branch.targetAddress]);
188 | } else {
189 | result += l.params;
190 | }
191 | result += '\n';
192 |
193 | firstLine = false;
194 | }
195 |
196 | return result;
197 | };
198 |
199 | updateDisplaySymbols = (flag) => {
200 | this.setState({ wantDisplaySymbols: flag });
201 | };
202 |
203 | followBranch = (direction, line) => {
204 | if (direction) {
205 | let target = line.branch && line.branch.targetAddress;
206 | if (target === null) {
207 | target = line.relevantData && line.relevantData.address;
208 | }
209 | if (target !== null) {
210 | this.jumpStack.push(this.state.cursor);
211 | this.gotoAddress(target);
212 | }
213 | } else {
214 | if (this.jumpStack.length !== 0) {
215 | this.gotoAddress(this.jumpStack.pop());
216 | } else {
217 | this.gotoAddress(this.props.pc);
218 | }
219 | }
220 | };
221 |
222 | assembleInstruction = (line, startCode) => {
223 | const { address } = line;
224 | const code = prompt('Assemble instruction', startCode);
225 | if (!code) {
226 | return Promise.resolve(null);
227 | }
228 |
229 | const writeInstruction = () => {
230 | return this.context.ppsspp.send({
231 | event: 'memory.assemble',
232 | address: address,
233 | code,
234 | }).then(() => {
235 | if (address === this.state.cursor) {
236 | // Okay, move one down so we can fire 'em off.
237 | const lineIndex = this.state.lines.indexOf(line);
238 | if (lineIndex < this.state.lines.length - 1) {
239 | const nextAddress = this.state.lines[lineIndex + 1].address;
240 | this.gotoAddress(nextAddress, 'nearest');
241 | }
242 | }
243 |
244 | // Now, whether we moved or not, also update disasm.
245 | this.updateDisasm();
246 | });
247 | };
248 |
249 | // Check if this is actually a register assignment.
250 | const assignment = code.split(/\s*=\s*(.+)$/, 2);
251 | if (assignment.length >= 2) {
252 | return this.context.ppsspp.send({
253 | event: 'cpu.evaluate',
254 | thread: this.context.gameStatus.currentThread,
255 | expression: assignment[1],
256 | }).then((result) => {
257 | return this.context.ppsspp.send({
258 | event: 'cpu.setReg',
259 | thread: this.context.gameStatus.currentThread,
260 | name: assignment[0],
261 | value: result.uintValue,
262 | });
263 | }).then(({ uintValue }) => {
264 | this.context.log('Updated ' + assignment[0] + ' to ' + toString08X(uintValue));
265 | }, writeInstruction);
266 | } else {
267 | return writeInstruction();
268 | }
269 | };
270 |
271 | toggleBreakpoint = (line, keep) => {
272 | if (line.breakpoint === null) {
273 | this.context.ppsspp.send({
274 | event: 'cpu.breakpoint.add',
275 | address: line.address,
276 | enabled: true,
277 | });
278 | } else if (!line.breakpoint.enabled) {
279 | this.context.ppsspp.send({
280 | event: 'cpu.breakpoint.update',
281 | address: line.breakpoint.address || line.address,
282 | enabled: true,
283 | });
284 | } else if (keep) {
285 | this.context.ppsspp.send({
286 | event: 'cpu.breakpoint.update',
287 | address: line.breakpoint.address || line.address,
288 | enabled: false,
289 | });
290 | } else {
291 | if (line.breakpoint.condition !== null) {
292 | if (!window.confirm('This breakpoint has has a condition.\n\nAre you sure you want to remove it?')) {
293 | return;
294 | }
295 | }
296 |
297 | this.context.ppsspp.send({
298 | event: 'cpu.breakpoint.remove',
299 | address: line.breakpoint.address || line.address,
300 | });
301 | }
302 | };
303 |
304 | editBreakpoint = (line) => {
305 | // In case it's actually in the middle of a macro.
306 | const breakpointAddress = line.breakpoint?.address || line.address;
307 | this.context.ppsspp.send({
308 | event: 'cpu.breakpoint.list',
309 | address: breakpointAddress,
310 | enabled: true,
311 | }).then(({ breakpoints }) => {
312 | const bp = breakpoints.find(bp => bp.address === breakpointAddress);
313 | if (bp) {
314 | const editingBreakpoint = { ...bp, type: 'execute' };
315 | this.setState({ editingBreakpoint });
316 | } else {
317 | const creatingBreakpoint = { address: breakpointAddress, type: 'execute' };
318 | this.setState({ creatingBreakpoint });
319 | }
320 | });
321 | };
322 |
323 | closeEditBreakpoint = () => {
324 | this.setState({ editingBreakpoint: null, creatingBreakpoint: null });
325 | };
326 |
327 | gotoAddress = (addr, snap = 'center') => {
328 | this.needsScroll = snap;
329 | this.props.updateSelection({
330 | selectionTop: addr,
331 | selectionBottom: addr,
332 | });
333 | };
334 |
335 | searchDisasm = (cont) => {
336 | const continueLast = cont && this.lastSearch !== null;
337 | const { cursor, searchString } = this.state;
338 | if (!continueLast && !searchString) {
339 | // Prompt for a term so we can even do this.
340 | this.searchPrompt();
341 | return;
342 | }
343 |
344 | if (this.state.searchInProgress) {
345 | if (continueLast) {
346 | // Already doing that, silly.
347 | return;
348 | }
349 |
350 | this.searchCancel();
351 | }
352 |
353 | if (!continueLast) {
354 | this.lastSearch = {
355 | match: searchString,
356 | end: cursor,
357 | last: null,
358 | };
359 | }
360 |
361 | // Set a new object so we can detect cancel uniquely.
362 | const cancelState = { cancel: false };
363 | this.lastSearch.cancelState = cancelState;
364 | this.setState({ searchInProgress: true });
365 |
366 | const { match, end, last } = this.lastSearch;
367 | this.context.ppsspp.send({
368 | event: 'memory.searchDisasm',
369 | address: cursor === last ? cursor + 4 : cursor,
370 | end,
371 | match,
372 | }).then(({ address }) => {
373 | if (cancelState.cancel) {
374 | return;
375 | }
376 | if (this.lastSearch) {
377 | this.lastSearch.last = address;
378 | }
379 |
380 | if (address !== null) {
381 | this.gotoAddress(address);
382 | } else if (cursor !== end) {
383 | window.alert('Reached the starting point of the search');
384 | this.gotoAddress(end);
385 | } else {
386 | window.alert('The specified text was not found:\n\n' + match);
387 | }
388 | }).finally(() => {
389 | if (!cancelState.cancel) {
390 | this.setState({ searchInProgress: false });
391 | if (this.lastSearch) {
392 | this.lastSearch.cancelState = null;
393 | }
394 | }
395 | });
396 | };
397 |
398 | updateSearchString = (match) => {
399 | const highlightText = match ? match.toLowerCase() : null;
400 | this.setState({ highlightText, searchString: match });
401 | if (this.lastSearch && this.lastSearch.match !== match) {
402 | // Clear the search start position when changing the search.
403 | this.searchCancel();
404 | this.lastSearch = null;
405 | }
406 |
407 | if (match === null) {
408 | // Return focus to list.
409 | if (this.listRef.current) {
410 | this.listRef.current.ensureCursorInView(false);
411 | }
412 | }
413 | };
414 |
415 | searchPrompt = () => {
416 | this.updateSearchString(this.state.searchString || (this.lastSearch ? this.lastSearch.match : ''));
417 | this.searchRef.current.focus();
418 | };
419 |
420 | searchNext = () => {
421 | this.searchDisasm(true);
422 | };
423 |
424 | searchCancel = () => {
425 | if (this.lastSearch && this.lastSearch.cancelState) {
426 | this.lastSearch.cancelState.cancel = true;
427 | this.setState({ searchInProgress: false });
428 | this.lastSearch.cancelState = null;
429 | }
430 | };
431 |
432 | getSnapshotBeforeUpdate(prevProps, prevState) {
433 | if (this.needsOffsetFix && this.listRef.current && this.state.lines.length !== 0) {
434 | const { lines } = this.state;
435 | const centerAddress = lines[Math.floor(lines.length / 2)].address;
436 | const top = this.listRef.current.addressBoundingTop(centerAddress);
437 | if (top) {
438 | return { top, centerAddress };
439 | }
440 | }
441 | return null;
442 | }
443 |
444 | componentDidMount() {
445 | this.listeners_ = listeners.listen({
446 | 'connection': () => {
447 | if (this.context.gameStatus.started) {
448 | this.updateDisasm('center');
449 | }
450 | },
451 | 'cpu.stepping': () => {
452 | const hasRange = this.state.range.start !== 0 || this.state.range.end !== 0;
453 | this.updateDisasm(hasRange ? 'nearest' : 'center');
454 | },
455 | 'cpu.setReg': (result) => {
456 | // Need to re-render if pc is changed.
457 | if (result.category === 0 && result.register === 32) {
458 | this.updateDisasm('center');
459 | }
460 | },
461 | 'cpu.breakpoint.add': () => {
462 | this.updateDisasm();
463 | },
464 | 'cpu.breakpoint.update': () => {
465 | this.updateDisasm();
466 | },
467 | 'cpu.breakpoint.remove': () => {
468 | this.updateDisasm();
469 | },
470 | });
471 | this.updatesCancel = false;
472 | }
473 |
474 | componentWillUnmount() {
475 | this.searchCancel();
476 | this.updatesCancel = true;
477 | listeners.forget(this.listeners_);
478 | }
479 |
480 | componentDidUpdate(prevProps, prevState, snapshot) {
481 | const { selectionTop, selectionBottom } = this.props;
482 | const { range, lineHeight } = this.state;
483 |
484 | let disasmChange = null, updateDisasmRange = null;
485 | if (this.state.wantDisplaySymbols !== prevState.wantDisplaySymbols) {
486 | // Keep the existing range, just update.
487 | disasmChange = false;
488 | }
489 | if (this.props.currentThread !== prevProps.currentThread) {
490 | disasmChange = false;
491 | }
492 | if (selectionTop !== prevProps.selectionTop || selectionBottom !== prevProps.selectionBottom) {
493 | if (selectionTop < range.start || selectionBottom >= range.end) {
494 | disasmChange = 'center';
495 | updateDisasmRange = true;
496 | }
497 | }
498 | if (!disasmChange && !prevProps.setInitialPC && this.props.setInitialPC) {
499 | disasmChange = 'center';
500 | updateDisasmRange = true;
501 | }
502 | if (disasmChange !== null && this.props.setInitialPC) {
503 | this.updateDisasm(disasmChange, updateDisasmRange);
504 | }
505 |
506 | if (lineHeight === 0) {
507 | const foundLine = document.querySelector('.DisasmLine');
508 | if (foundLine) {
509 | // Defer for other updates.
510 | setTimeout(() => this.setState({ lineHeight: foundLine.getBoundingClientRect().height }), 0);
511 | }
512 | } else if (this.ref.current) {
513 | const visibleLines = Math.floor(this.ref.current.clientHeight / lineHeight);
514 | if (visibleLines !== this.state.visibleLines) {
515 | this.setState({ visibleLines });
516 | }
517 | }
518 |
519 | if (this.props.jumpMarker !== prevProps.jumpMarker) {
520 | this.needsScroll = 'center';
521 | }
522 | // Always associated with a state update.
523 | if (this.needsScroll && this.listRef.current) {
524 | const list = this.listRef.current;
525 | setTimeout(() => {
526 | list.ensureCursorInView(this.needsScroll);
527 | this.needsScroll = false;
528 | }, 0);
529 | }
530 |
531 | if (snapshot && this.listRef.current) {
532 | const top = this.listRef.current.addressBoundingTop(snapshot.centerAddress);
533 | this.ref.current.scrollTop -= snapshot.top - top;
534 | this.needsOffsetFix = false;
535 | }
536 | }
537 |
538 | updateDisasm(needsScroll, newRange) {
539 | this.updatesSequence = this.updatesSequence.then(() => {
540 | if (this.updatesCancel) {
541 | return null;
542 | }
543 | return this.updateDisasmNow(needsScroll, newRange);
544 | });
545 | }
546 |
547 | updateDisasmNow(needsScroll, newRange = null) {
548 | if (!this.props.setInitialPC) {
549 | return Promise.resolve(null);
550 | }
551 |
552 | const { range, visibleLines, wantDisplaySymbols: displaySymbols } = this.state;
553 | let updateRange = newRange;
554 | if (newRange === true || (newRange === null && range.start === 0 && range.end === 0)) {
555 | const minBuffer = Math.max(MIN_BUFFER, visibleLines);
556 | const defaultBuffer = Math.floor(minBuffer * 1.5);
557 | updateRange = {
558 | address: this.props.selectionTop - defaultBuffer * 4,
559 | count: defaultBuffer * 2,
560 | };
561 | } else if (newRange === null) {
562 | updateRange = {
563 | address: range.start,
564 | end: range.end,
565 | };
566 | }
567 |
568 | return Promise.resolve(null).then(() => {
569 | return this.context.ppsspp.send({
570 | event: 'memory.disasm',
571 | thread: this.context.gameStatus.currentThread,
572 | ...updateRange,
573 | displaySymbols,
574 | }).then((data) => {
575 | if (this.updatesCancel) {
576 | return;
577 | }
578 |
579 | const { range, branchGuides, lines } = data;
580 | if (needsScroll) {
581 | this.needsScroll = needsScroll;
582 | } else {
583 | this.needsOffsetFix = true;
584 | }
585 | this.setState({
586 | range,
587 | branchGuides: this.cleanupBranchGuides(branchGuides),
588 | lines,
589 | displaySymbols,
590 | });
591 | }, (err) => {
592 | if (!this.updatesCancel) {
593 | this.setState({
594 | range: { start: 0, end: 0 },
595 | branchGuides: [],
596 | lines: [],
597 | });
598 | }
599 | });
600 | });
601 | }
602 |
603 | cleanupBranchGuides(branchGuides) {
604 | // TODO: Temporary (?) workaround for a bug with duplicate branch guides.
605 | const unique = new Map();
606 | branchGuides.forEach((guide) => {
607 | const key = String(guide.top) + String(guide.bottom) + guide.direction;
608 | unique.set(key, guide);
609 | });
610 | return [...unique.values()];
611 | }
612 |
613 | onDoubleClick = (ev, data) => {
614 | this.toggleBreakpoint(data.line);
615 | };
616 |
617 | applyScroll = (dist) => {
618 | this.ref.current.scrollTop += dist * this.state.lineHeight;
619 | };
620 |
621 | onScroll(ev) {
622 | this.updatesSequence = this.updatesSequence.then(() => {
623 | if (this.updatesCancel) {
624 | return null;
625 | }
626 |
627 | const { bufferTop, bufferBottom } = this.bufferRange();
628 | const minBuffer = Math.max(MIN_BUFFER, this.state.visibleLines);
629 | const maxBuffer = Math.max(MAX_BUFFER, this.state.visibleLines * 5);
630 | let { start, end } = this.state.range;
631 |
632 | if (bufferTop < minBuffer) {
633 | start -= minBuffer * 4;
634 | } else if (bufferTop > maxBuffer) {
635 | start += Math.max(minBuffer, bufferTop - maxBuffer) * 4;
636 | }
637 |
638 | if (bufferBottom < minBuffer) {
639 | end += minBuffer * 4;
640 | } else if (bufferBottom > maxBuffer) {
641 | end -= Math.max(minBuffer, bufferBottom - maxBuffer) * 4;
642 | }
643 |
644 | if (start !== this.state.range.start || end !== this.state.range.end) {
645 | return this.updateDisasmNow(false, { address: start, end });
646 | }
647 | return null;
648 | });
649 | }
650 |
651 | bufferRange() {
652 | const { scrollHeight, scrollTop, clientHeight } = this.ref.current;
653 | const { lineHeight } = this.state;
654 | const bufferTop = lineHeight === 0 ? 0 : scrollTop / lineHeight;
655 | const bufferBottom = lineHeight === 0 ? 0 : (scrollHeight - scrollTop - clientHeight) / lineHeight;
656 | const visibleEachDirection = Math.floor((this.state.visibleLines - 1) / 2);
657 |
658 | return {
659 | bufferTop: bufferTop + visibleEachDirection,
660 | bufferBottom: bufferBottom + visibleEachDirection,
661 | };
662 | }
663 |
664 | static getDerivedStateFromProps(nextProps, prevState) {
665 | const { selectionTop, selectionBottom } = nextProps;
666 | if (prevState.cursor < selectionTop || prevState.cursor > selectionBottom) {
667 | let cursor = selectionTop;
668 | if (prevState.lines.length) {
669 | // Snap to in case we goto an address in the middle
670 | const line = prevState.lines.find(l => l.address <= selectionTop && l.address + l.addressSize > selectionTop);
671 | cursor = line ? line.address : cursor;
672 | }
673 | return { cursor };
674 | }
675 | return null;
676 | }
677 | }
678 |
679 | Disasm.propTypes = {
680 | selectionTop: PropTypes.number,
681 | selectionBottom: PropTypes.number,
682 | pc: PropTypes.number,
683 | setInitialPC: PropTypes.bool.isRequired,
684 | currentThread: PropTypes.number,
685 |
686 | updateSelection: PropTypes.func.isRequired,
687 | promptGoto: PropTypes.func.isRequired,
688 | };
689 |
690 | Disasm.contextType = DebuggerContext;
691 |
692 | export default Disasm;
693 |
--------------------------------------------------------------------------------
/src/components/CPU/DisasmBranchGuide.js:
--------------------------------------------------------------------------------
1 | import { PureComponent } from 'react';
2 | import PropTypes from 'prop-types';
3 | import classNames from 'clsx';
4 |
5 | class DisasmBranchGuide extends PureComponent {
6 | render() {
7 | const pos = this.calcPos();
8 | if (pos === null || this.props.lineHeight === 0) {
9 | return null;
10 | }
11 | const classes = classNames({
12 | 'DisasmBranchGuide': true,
13 | 'DisasmBranchGuide--selected': this.props.selected,
14 | });
15 |
16 | return (
17 |
22 | );
23 | }
24 |
25 | buildPath(height) {
26 | const { guide, range, lineHeight } = this.props;
27 | const yCenter = Math.floor(lineHeight / 2) + 0.5;
28 |
29 | // Parts: trailing connects to outside view.
30 | const trailingEdge = `m7.5,-${yCenter} l0,${lineHeight} m-7.5,-${lineHeight - yCenter}`;
31 | const sourceEdge = 'm3,0 l5,0 m-8,0';
32 | const arrowEdge = 'm5.5,-4 l-4,4 l4,4 m-3.5,-4 l6,0 m-8,0';
33 |
34 | // Start at the y center so our parts are reusable for top and bottom.
35 | let path = ['M0,' + yCenter];
36 |
37 | if (guide.top < range.start) {
38 | path.push(trailingEdge);
39 | } else if (guide.direction === 'up') {
40 | path.push(arrowEdge);
41 | } else {
42 | path.push(sourceEdge);
43 | }
44 |
45 | path.push('m7.5,0 l0,' + (height - lineHeight) + ' m-7.5,0');
46 |
47 | if (guide.bottom >= range.end) {
48 | path.push(trailingEdge);
49 | } else if (guide.direction === 'down') {
50 | path.push(arrowEdge);
51 | } else {
52 | path.push(sourceEdge);
53 | }
54 |
55 | return path.join(' ');
56 | }
57 |
58 | calcPos() {
59 | const { guide } = this.props;
60 | const top = this.findAddressOffset(guide.top, false);
61 | const bottom = this.findAddressOffset(guide.bottom, true);
62 | if (top === null || bottom === null) {
63 | return null;
64 | }
65 |
66 | const right = (16 - guide.lane) * 8;
67 | const height = bottom - top;
68 | return { top, right, height };
69 | }
70 |
71 | findAddressOffset(address, down) {
72 | const { offsets, range } = this.props;
73 | if (address >= range.end) {
74 | if ((range.end - 4) in offsets && down) {
75 | return offsets[range.end - 4] + this.props.lineHeight;
76 | }
77 | if ((range.end - 8) in offsets && down) {
78 | return offsets[range.end - 8] + this.props.lineHeight;
79 | }
80 | return null;
81 | }
82 |
83 | let off;
84 | if (address < range.start && !down) {
85 | off = 0;
86 | } else if (address in offsets) {
87 | off = offsets[address];
88 | } else if (address - 4 in offsets) {
89 | // Inside a macro?
90 | off = offsets[address - 4];
91 | } else {
92 | return null;
93 | }
94 |
95 | return down ? off + this.props.lineHeight : off;
96 | }
97 | }
98 |
99 | DisasmBranchGuide.propTypes = {
100 | guide: PropTypes.shape({
101 | direction: PropTypes.oneOf(['up', 'down']).isRequired,
102 | top: PropTypes.number.isRequired,
103 | bottom: PropTypes.number.isRequired,
104 | lane: PropTypes.number.isRequired,
105 | }).isRequired,
106 | offsets: PropTypes.object.isRequired,
107 | range: PropTypes.shape({
108 | start: PropTypes.number.isRequired,
109 | end: PropTypes.number.isRequired,
110 | }).isRequired,
111 | lineHeight: PropTypes.number.isRequired,
112 | selected: PropTypes.bool,
113 | };
114 |
115 | export default DisasmBranchGuide;
116 |
--------------------------------------------------------------------------------
/src/components/CPU/DisasmButtons.css:
--------------------------------------------------------------------------------
1 | .DisasmButtons {
2 | /* Matches with the Diasm max-height. */
3 | height: 25px;
4 | }
5 |
6 | .DisasmButtons__spacer {
7 | display: inline-block;
8 | width: 12px;
9 | }
10 |
11 | .DisasmButtons button {
12 | margin-right: 3px;
13 | }
14 |
15 | .DisasmButtons__thread--current {
16 | font-weight: bold;
17 | }
18 |
19 | .DisasmButtons__group {
20 | display: inline-block;
21 | padding-bottom: 2px;
22 | white-space: nowrap;
23 | }
24 |
25 | @media only screen and (max-width: 700px) {
26 | .DisasmButtons {
27 | height: auto;
28 | min-height: 25px;
29 | }
30 | }
31 |
32 | @media only screen and (min-width: 701px) {
33 | .DisasmButtons__nav {
34 | display: none;
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/src/components/CPU/DisasmButtons.js:
--------------------------------------------------------------------------------
1 | import { PureComponent } from 'react';
2 | import DebuggerContext, { DebuggerContextValues } from '../DebuggerContext';
3 | import PropTypes from 'prop-types';
4 | import BreakpointModal from './BreakpointModal';
5 | import listeners from '../../utils/listeners.js';
6 | import './DisasmButtons.css';
7 |
8 | class DisasmButtons extends PureComponent {
9 | state = {
10 | breakpointModalOpen: false,
11 | connected: false,
12 | lastThread: '',
13 | threads: [],
14 | };
15 | /**
16 | * @type {DebuggerContextValues}
17 | */
18 | context;
19 | listeners_;
20 |
21 | render() {
22 | const { started, paused, stepping } = this.context.gameStatus;
23 | const disabled = !started || !stepping || paused;
24 |
25 | return (
26 |
27 |
28 | Nav
29 |
30 |
31 |
32 |
33 | {stepping || !started ? 'Go' : 'Break'}
34 |
35 |
36 |
37 |
38 | Step Into
39 | Step Over
40 | Step Out
41 |
42 |
43 |
44 | Next HLE
45 |
46 |
47 |
48 | Breakpoint
49 |
50 |
51 |
52 |
53 | Thread: {this.renderThreadList()}
54 |
55 |
56 |
57 |
61 |
62 | );
63 | }
64 |
65 | renderThreadList() {
66 | if (this.state.threads.length === 0) {
67 | return '(none)';
68 | }
69 | return (
70 |
71 | {this.state.threads.map(thread => this.renderThread(thread))}
72 |
73 | );
74 | }
75 |
76 | renderThread(thread) {
77 | const classes = 'DisasmButtons__thread' + (thread.isCurrent ? ' DisasmButtons__thread--current' : '');
78 | return (
79 |
80 | {thread.name}{thread.isCurrent ? ' (current)' : ''}
81 |
82 | );
83 | }
84 |
85 | componentDidMount() {
86 | this.listeners_ = listeners.listen({
87 | 'connection.change': (connected) => this.setState({ connected }),
88 | 'cpu.stepping': () => this.updateThreadList(true),
89 | });
90 | }
91 |
92 | componentWillUnmount() {
93 | listeners.forget(this.listeners_);
94 | }
95 |
96 | componentDidUpdate(prevProps, prevState) {
97 | if (!prevState.connected && this.state.connected) {
98 | this.updateThreadList(this.context.gameStatus.currentThread === undefined);
99 | }
100 | }
101 |
102 | updateThreadList(resetCurrentThread) {
103 | if (!this.state.connected) {
104 | // This avoids a duplicate update during initial connect.
105 | return;
106 | }
107 |
108 | this.context.ppsspp.send({
109 | event: 'hle.thread.list',
110 | }).then(({ threads }) => {
111 | this.setState({ threads });
112 | if (resetCurrentThread) {
113 | const currentThread = threads.find(th => th.isCurrent);
114 | if (currentThread && currentThread.id !== this.context.gameStatus.currentThread) {
115 | this.props.updateCurrentThread(currentThread.id);
116 | }
117 | }
118 | }, () => {
119 | this.setState({ threads: [] });
120 | });
121 | }
122 |
123 | handleGoBreak = () => {
124 | if (this.context.gameStatus.stepping) {
125 | this.props.updateCurrentThread(undefined);
126 | }
127 | this.context.ppsspp.send({
128 | event: this.context.gameStatus.stepping ? 'cpu.resume' : 'cpu.stepping',
129 | }).catch(() => {
130 | // Already logged, let's assume the parent will have marked it disconnected/not started by now.
131 | });
132 | };
133 |
134 | handleStepInto = () => {
135 | this.props.updateCurrentThread(undefined);
136 | this.context.ppsspp.send({
137 | event: 'cpu.stepInto',
138 | thread: this.context.gameStatus.currentThread,
139 | }).catch(() => {
140 | // Already logged, let's assume the parent will have marked it disconnected/not started by now.
141 | });
142 | };
143 |
144 | handleStepOver = () => {
145 | this.props.updateCurrentThread(undefined);
146 | this.context.ppsspp.send({
147 | event: 'cpu.stepOver',
148 | thread: this.context.gameStatus.currentThread,
149 | }).catch(() => {
150 | // Already logged, let's assume the parent will have marked it disconnected/not started by now.
151 | });
152 | };
153 |
154 | handleStepOut = () => {
155 | const threadID = this.context.gameStatus.currentThread;
156 | this.props.updateCurrentThread(undefined);
157 | this.context.ppsspp.send({
158 | event: 'cpu.stepOut',
159 | thread: this.context.gameStatus.currentThread,
160 | }).catch(() => {
161 | // This might fail if they aren't inside a function call on this thread, so restore the thread.
162 | this.props.updateCurrentThread(threadID);
163 | });
164 | };
165 |
166 | handleNextHLE = () => {
167 | this.props.updateCurrentThread(undefined);
168 | this.context.ppsspp.send({
169 | event: 'cpu.nextHLE',
170 | }).catch(() => {
171 | // Already logged, let's assume the parent will have marked it disconnected/not started by now.
172 | });
173 | };
174 |
175 | handleThreadSelect = (ev) => {
176 | const currentThread = this.state.threads.find(th => th.id === Number(ev.target.value));
177 | if (currentThread) {
178 | this.props.updateCurrentThread(currentThread.id, currentThread.pc);
179 | }
180 | };
181 |
182 | handleBreakpointOpen = () => {
183 | this.setState({ breakpointModalOpen: true });
184 | };
185 |
186 | handleBreakpointClose = () => {
187 | this.setState({ breakpointModalOpen: false });
188 | };
189 |
190 | static getDerivedStateFromProps(nextProps, prevState) {
191 | let update = null;
192 | if (nextProps.currentThread && nextProps.currentThread !== prevState.lastThread) {
193 | update = { ...update, lastThread: nextProps.currentThread || '' };
194 | }
195 | if (nextProps.stepping || nextProps.started) {
196 | update = { ...update, connected: true };
197 | }
198 | if (!nextProps.started && prevState.threads.length) {
199 | update = { ...update, threads: [] };
200 | }
201 | return update;
202 | }
203 | }
204 |
205 | DisasmButtons.propTypes = {
206 | started: PropTypes.bool.isRequired,
207 | stepping: PropTypes.bool.isRequired,
208 | currentThread: PropTypes.number,
209 | showNavTray: PropTypes.func.isRequired,
210 |
211 | updateCurrentThread: PropTypes.func.isRequired,
212 | };
213 |
214 | DisasmButtons.contextType = DebuggerContext;
215 |
216 | export default DisasmButtons;
217 |
--------------------------------------------------------------------------------
/src/components/CPU/DisasmContextMenu.js:
--------------------------------------------------------------------------------
1 | import { PureComponent } from 'react';
2 | import DebuggerContext, { DebuggerContextValues } from '../DebuggerContext';
3 | import PropTypes from 'prop-types';
4 | import { ContextMenu, MenuItem, connectMenu } from 'react-contextmenu';
5 | import { copyText } from '../../utils/clipboard';
6 | import { toString08X } from '../../utils/format';
7 |
8 | class DisasmContextMenu extends PureComponent {
9 | /**
10 | * @type {DebuggerContextValues}
11 | */
12 | context;
13 |
14 | render() {
15 | const { id, trigger } = this.props;
16 | const disabled = !this.context.gameStatus.stepping || this.context.gameStatus.paused;
17 | const line = trigger && trigger.line;
18 |
19 | const followBranch = line && (line.branch !== null || line.relevantData !== null || line.type === 'data');
20 |
21 | return (
22 |
23 |
24 | Copy Address
25 |
26 |
27 | Copy Instruction (Hex)
28 |
29 |
30 | Copy Instruction (Disasm)
31 |
32 |
33 |
34 | Assemble Opcode...
35 |
36 |
37 |
38 | Run to Cursor
39 |
40 |
41 | Jump to Cursor
42 |
43 |
44 | Toggle Breakpoint
45 |
46 |
47 |
48 | Follow Branch
49 |
50 |
51 | Go to in Memory View
52 |
53 |
54 | Go to in Jit Compare
55 |
56 |
57 |
58 | Rename Function...
59 |
60 |
61 | Remove Function
62 |
63 |
64 | Add Function Here
65 |
66 |
67 | );
68 | }
69 |
70 | handleCopyAddress = (ev, data) => {
71 | copyText(toString08X(data.line.address));
72 | data.node.focus();
73 | };
74 |
75 | handleCopyHex = (ev, data) => {
76 | const hexLines = this.props.getSelectedLines().map(line => {
77 | // Include each opcode of a macro if it's a macro.
78 | return line.macroEncoding ? line.macroEncoding.map(toString08X).join('\n') : toString08X(line.encoding);
79 | });
80 | copyText(hexLines.join('\n'));
81 | data.node.focus();
82 | };
83 |
84 | handleCopyDisasm = (ev, data) => {
85 | const lines = this.props.getSelectedDisasm();
86 | copyText(lines);
87 | data.node.focus();
88 | };
89 |
90 | handleAssemble = (ev, data) => {
91 | // Delay so the context menu can close before the prompt.
92 | setTimeout(() => {
93 | this.props.assembleInstruction(data.line, '').catch(() => {
94 | // Exception logged.
95 | });
96 | }, 0);
97 | };
98 |
99 | handleRunUntil = (ev, data) => {
100 | this.context.ppsspp.send({
101 | event: 'cpu.runUntil',
102 | address: data.line.address,
103 | }).catch(() => {
104 | // Already logged, let's assume the parent will have marked it disconnected/not started by now.
105 | });
106 | data.node.focus();
107 | };
108 |
109 | handleJumpPC = (ev, data) => {
110 | this.context.ppsspp.send({
111 | event: 'cpu.setReg',
112 | thread: this.context.gameStatus.currentThread,
113 | name: 'pc',
114 | value: data.line.address,
115 | }).catch((err) => {
116 | this.context.log('Failed to update PC: ' + err);
117 | });
118 | };
119 |
120 | handleToggleBreakpoint = (ev, data) => {
121 | this.props.toggleBreakpoint(data.line);
122 | };
123 |
124 | handleFollowBranch = (ev, data) => {
125 | this.props.followBranch(true, data.line);
126 | };
127 |
128 | handleTodo = (ev, data) => {
129 | // TODO
130 | console.log(data);
131 | data.node.focus();
132 | };
133 | }
134 |
135 | DisasmContextMenu.propTypes = {
136 | id: PropTypes.string.isRequired,
137 | trigger: PropTypes.shape({
138 | line: PropTypes.shape({
139 | type: PropTypes.string,
140 | branch: PropTypes.object,
141 | relevantData: PropTypes.object,
142 | }),
143 | }),
144 |
145 | getSelectedLines: PropTypes.func.isRequired,
146 | getSelectedDisasm: PropTypes.func.isRequired,
147 | followBranch: PropTypes.func.isRequired,
148 | assembleInstruction: PropTypes.func.isRequired,
149 | toggleBreakpoint: PropTypes.func.isRequired,
150 | };
151 |
152 | DisasmContextMenu.contextType = DebuggerContext;
153 |
154 | export default connectMenu('disasm')(DisasmContextMenu);
155 |
--------------------------------------------------------------------------------
/src/components/CPU/DisasmLine.js:
--------------------------------------------------------------------------------
1 | import { createRef, PureComponent } from 'react';
2 | import PropTypes from 'prop-types';
3 | import { ContextMenuTrigger } from 'react-contextmenu';
4 | import { toString08X } from '../../utils/format';
5 | import { ensureInView } from '../../utils/dom';
6 | import classNames from 'clsx';
7 | import BreakpointIcon from './BreakpointIcon';
8 |
9 | class DisasmLine extends PureComponent {
10 | ref;
11 |
12 | constructor(props) {
13 | super(props);
14 |
15 | this.ref = createRef();
16 | }
17 |
18 | render() {
19 | const { line, selected, focused, cursor } = this.props;
20 |
21 | const className = classNames({
22 | 'DisasmLine': true,
23 | 'DisasmLine--selected': selected && !focused,
24 | 'DisasmLine--focused': focused,
25 | 'DisasmLine--cursor': cursor,
26 | 'DisasmLine--breakpoint': line.breakpoint && line.breakpoint.enabled,
27 | 'DisasmLine--disabled-breakpoint': line.breakpoint && !line.breakpoint.enabled,
28 | 'DisasmLine--current': line.isCurrentPC,
29 | });
30 |
31 | const mapData = (props) => {
32 | return { line, node: this.ref.current.parentNode };
33 | };
34 | const attributes = {
35 | onDoubleClick: (ev) => this.onDoubleClick(ev, mapData()),
36 | tabIndex: 0,
37 | };
38 |
39 | return (
40 |
48 | );
49 | }
50 |
51 | renderAddress(line) {
52 | const { symbol, address, encoding } = line;
53 | const addressHex = toString08X(address);
54 |
55 | if (this.props.displaySymbols) {
56 | if (symbol !== null) {
57 | return {this.highlight(symbol + ':')}
;
58 | }
59 | return {this.highlight(addressHex)}
;
60 | }
61 | return (
62 |
63 | {this.highlight(addressHex)} {toString08X(encoding)}
64 |
65 | );
66 | }
67 |
68 | renderConditional(line) {
69 | if (line.isCurrentPC && line.conditionMet !== null) {
70 | return line.conditionMet ? ' ; true' : ' ; false';
71 | }
72 | return '';
73 | }
74 |
75 | highlight(text, before = '', after = '') {
76 | const { highlight } = this.props;
77 | if (highlight !== null) {
78 | const fullText = (before ? before + ' ' : '') + text + ' ' + after;
79 | let pos = fullText.toLowerCase().indexOf(highlight);
80 | let matchLength = highlight.length;
81 | if (pos !== -1) {
82 | const beforeLength = before ? before.length + 1 : 0;
83 | if (pos < beforeLength) {
84 | matchLength -= beforeLength - pos;
85 | pos = matchLength > 0 ? 0 : -1;
86 | } else {
87 | pos -= beforeLength;
88 | }
89 | }
90 |
91 | if (pos >= 0 && pos < text.length) {
92 | const className = classNames({
93 | 'DisasmLine__highlight': true,
94 | 'DisasmLine__highlight--end': pos + matchLength > text.length + 1,
95 | });
96 | return (
97 | <>
98 | {text.substr(0, pos)}
99 | {text.substr(pos, matchLength)}
100 | {text.substr(pos + matchLength)}
101 | >
102 | );
103 | }
104 | }
105 | return text;
106 | }
107 |
108 | highlightParams(text, before = '', after = '') {
109 | const { highlight, highlightParams } = this.props;
110 | if (highlight === null) {
111 | const params = text.split(/([,()])/);
112 | const toHighlight = highlightParams || [];
113 | return params.map((param, key) => {
114 | let className = 'DisasmLine__param';
115 | if (toHighlight.includes(param)) {
116 | className += ' DisasmLine__param--highlighted';
117 | }
118 | return {param} ;
119 | });
120 | }
121 | return this.highlight(text, before, after);
122 | }
123 |
124 | onDoubleClick(ev, data) {
125 | if (ev.button === 0) {
126 | this.props.onDoubleClick(ev, data);
127 | }
128 | }
129 |
130 | ensureInView(needsScroll) {
131 | const triggerNode = this.ref.current.parentNode;
132 | if (needsScroll !== false) {
133 | ensureInView(triggerNode, { block: needsScroll });
134 | }
135 | triggerNode.focus();
136 | }
137 |
138 | static addressBoundingTop(parentNode, address) {
139 | const lineNode = parentNode.querySelector('.DisasmLine[data-address="' + address + '"]');
140 | if (lineNode) {
141 | const triggerNode = lineNode.parentNode;
142 | return triggerNode.getBoundingClientRect().top;
143 | }
144 | return null;
145 | }
146 | }
147 |
148 | DisasmLine.propTypes = {
149 | line: PropTypes.shape({
150 | address: PropTypes.number.isRequired,
151 | }).isRequired,
152 | selected: PropTypes.bool,
153 | focused: PropTypes.bool,
154 | cursor: PropTypes.bool,
155 | displaySymbols: PropTypes.bool,
156 | highlight: PropTypes.string,
157 | highlightParams: PropTypes.arrayOf(PropTypes.string),
158 | contextmenu: PropTypes.string.isRequired,
159 | onDoubleClick: PropTypes.func.isRequired,
160 | };
161 |
162 | export default DisasmLine;
163 |
--------------------------------------------------------------------------------
/src/components/CPU/DisasmList.js:
--------------------------------------------------------------------------------
1 | import { createRef, PureComponent } from 'react';
2 | import PropTypes from 'prop-types';
3 | import DisasmBranchGuide from './DisasmBranchGuide';
4 | import DisasmLine from './DisasmLine';
5 | import { hasContextMenu } from '../../utils/dom';
6 | import { listenCopy, forgetCopy } from '../../utils/clipboard';
7 | import { toString08X } from '../../utils/format';
8 | import { Timeout } from '../../utils/timeouts';
9 |
10 | class DisasmList extends PureComponent {
11 | state = {
12 | mouseDown: false,
13 | focused: false,
14 | lineOffsets: {},
15 | pendingLineParams: [],
16 | selectedLineParams: [],
17 | prevLines: null,
18 | prevSelectionTop: null,
19 | prevSelectionBottom: null,
20 | };
21 | focusTimeout;
22 | paramsTimeout;
23 | ref;
24 | cursorRef;
25 |
26 | constructor(props) {
27 | super(props);
28 |
29 | this.ref = createRef();
30 | this.cursorRef = createRef();
31 | this.focusTimeout = new Timeout(() => null, 20);
32 | this.paramsTimeout = new Timeout(() => {
33 | this.setState(prevState => ({ selectedLineParams: prevState.pendingLineParams }));
34 | }, 20);
35 | }
36 |
37 | render() {
38 | const events = {
39 | onMouseDownCapture: ev => this.onMouseDown(ev),
40 | onMouseUpCapture: this.state.mouseDown ? (ev => this.onMouseUp(ev)) : undefined,
41 | onMouseMove: this.state.mouseDown ? (ev => this.onMouseMove(ev)) : undefined,
42 | onKeyDown: ev => this.onKeyDown(ev),
43 | onBlur: ev => this.onFocusChange(ev, false),
44 | onFocus: ev => this.onFocusChange(ev, true),
45 | };
46 |
47 | return (
48 |
49 | {this.props.lines.map((line) => this.renderLine(line))}
50 | {this.props.branchGuides.map((guide) => this.renderBranchGuide(guide))}
51 |
52 | );
53 | }
54 |
55 | renderLine(line) {
56 | const { displaySymbols, cursor } = this.props;
57 | let props = {
58 | displaySymbols,
59 | line,
60 | selected: this.isLineSelected(line),
61 | cursor: line.address === cursor,
62 | onDoubleClick: this.props.onDoubleClick,
63 | ref: line.address === cursor ? this.cursorRef : undefined,
64 | // Avoid re-rendering lines unnecessarily.
65 | highlight: this.shouldHighlight(line, this.props.highlightText) ? this.props.highlightText : null,
66 | highlightParams: this.shouldHighlightParams(line) ? this.state.selectedLineParams : null,
67 | };
68 | props.focused = props.selected && this.state.focused;
69 |
70 | return ;
71 | }
72 |
73 | renderBranchGuide(guide) {
74 | const key = String(guide.top) + String(guide.bottom) + guide.direction;
75 | const { range, lineHeight, cursor } = this.props;
76 | const props = {
77 | key,
78 | guide,
79 | offsets: this.state.lineOffsets,
80 | range,
81 | lineHeight,
82 | selected: guide.top === cursor || guide.bottom === cursor,
83 | };
84 |
85 | return ;
86 | }
87 |
88 | shouldHighlight(line, match) {
89 | let parts = [];
90 | parts.push(toString08X(line.address));
91 | if (line.symbol !== null) {
92 | parts.push(line.symbol + ':');
93 | }
94 | parts.push(line.name + ' ' + line.params);
95 |
96 | return parts.some(part => part.toLowerCase().indexOf(match) !== -1);
97 | }
98 |
99 | shouldHighlightParams(line, match) {
100 | if (this.isLineSelected(line)) {
101 | return false;
102 | }
103 | return this.state.selectedLineParams.some(param => line.params.indexOf(param) !== -1);
104 | }
105 |
106 | // Exposed to parent.
107 | addressBoundingTop(address) {
108 | // It seems like pointless complexity to use a ref for this.
109 | return DisasmLine.addressBoundingTop(this.ref.current, address);
110 | }
111 |
112 | // Exposed to parent.
113 | ensureCursorInView(options) {
114 | if (this.cursorRef.current) {
115 | this.cursorRef.current.ensureInView(options);
116 | }
117 | }
118 |
119 | isLineSelected(line) {
120 | return DisasmList.isLineSelected(this.props, line);
121 | }
122 |
123 | static isLineSelected({ selectionTop, selectionBottom }, line) {
124 | return line.address + line.addressSize > selectionTop && line.address <= selectionBottom;
125 | }
126 |
127 | static calcOffsets(lines, lineHeight) {
128 | let pos = 0;
129 | let offsets = {};
130 | lines.forEach((line) => {
131 | offsets[line.address] = pos;
132 | pos += lineHeight;
133 | });
134 | return offsets;
135 | }
136 |
137 | static getSelectedLineParams({ lines, selectionTop, selectionBottom }) {
138 | const selectedLines = lines.filter(DisasmList.isLineSelected.bind(null, { selectionTop, selectionBottom }));
139 |
140 | let params = {};
141 | for (let line of selectedLines) {
142 | for (let param of line.params.split(/[,()]/)) {
143 | params[param] = param;
144 | }
145 | }
146 | delete params[''];
147 |
148 | return Object.values(params);
149 | }
150 |
151 | static getDerivedStateFromProps(nextProps, prevState) {
152 | const linesChanged = nextProps.lines !== prevState.prevLines;
153 | const selectionChanged = nextProps.selectionTop !== prevState.prevSelectionTop || nextProps.selectionBottom !== prevState.prevSelectionBottom;
154 |
155 | let offsetsUpdate = null;
156 | if (linesChanged) {
157 | const lineOffsets = DisasmList.calcOffsets(nextProps.lines, nextProps.lineHeight);
158 | offsetsUpdate = { lineOffsets, prevLines: nextProps.lines };
159 | }
160 |
161 | let selectedUpdate = null;
162 | if (linesChanged || selectionChanged) {
163 | selectedUpdate = {
164 | pendingLineParams: DisasmList.getSelectedLineParams(nextProps),
165 | prevSelectionTop: nextProps.selectionTop,
166 | prevSelectionBottom: nextProps.selectionBottom,
167 | };
168 | }
169 |
170 | if (offsetsUpdate !== null || selectedUpdate !== null) {
171 | return { ...offsetsUpdate, ...selectedUpdate };
172 | }
173 | return null;
174 | }
175 |
176 | componentDidMount() {
177 | listenCopy('.Disasm__list', this.onCopy);
178 | }
179 |
180 | componentWillUnmount() {
181 | forgetCopy('.Disasm__list', this.onCopy);
182 | this.paramsTimeout.cancel();
183 | }
184 |
185 | componentDidUpdate(prevProps, prevState) {
186 | if (this.state.pendingLineParams !== prevState.pendingLineParams) {
187 | // Apply the line params soon after selection change - not immediately for quicker scrolling.
188 | this.paramsTimeout.start();
189 | }
190 | }
191 |
192 | onCopy = (ev) => {
193 | return this.props.getSelectedDisasm();
194 | };
195 |
196 | onFocusChange(ev, focused) {
197 | const update = () => {
198 | if (focused !== this.state.focused) {
199 | this.setState({ focused });
200 | }
201 | };
202 |
203 | this.focusTimeout.cancel();
204 |
205 | if (!focused) {
206 | // We use an arbitrary short delay because a click will temporarily blur.
207 | this.focusTimeout.reset(update);
208 | this.focusTimeout.start();
209 | } else {
210 | update();
211 | }
212 | }
213 |
214 | onMouseDown(ev) {
215 | const line = this.mouseEventToLine(ev);
216 | // Don't change selection if right clicking within the selection.
217 | if (ev.button !== 2 || !this.isLineSelected(line)) {
218 | this.applySelection(ev, line);
219 | } else {
220 | // But do change the cursor.
221 | this.props.updateCursor(line.address);
222 | }
223 | if (ev.button === 0) {
224 | this.setState({ mouseDown: true });
225 | }
226 | }
227 |
228 | onMouseUp(ev) {
229 | const line = this.mouseEventToLine(ev);
230 | if (line) {
231 | this.applySelection(ev, line);
232 | }
233 | this.setState({ mouseDown: false });
234 | }
235 |
236 | onMouseMove(ev) {
237 | if (ev.buttons === 0) {
238 | this.setState({ mouseDown: false });
239 | return;
240 | }
241 |
242 | const line = this.mouseEventToLine(ev);
243 | if (line) {
244 | this.applySelection(ev, line);
245 | }
246 | }
247 |
248 | onKeyDown(ev) {
249 | if (hasContextMenu()) {
250 | return;
251 | }
252 |
253 | const modifiers = (ev.ctrlKey ? 'c' : '') + (ev.metaKey ? 'm' : '') + (ev.altKey ? 'a' : '') + (ev.shiftKey ? 's' : '');
254 |
255 | if (['ArrowUp', 'ArrowDown', 'PageUp', 'PageDown'].includes(ev.key)) {
256 | let dist = 1;
257 | if (ev.key === 'PageUp' || ev.key === 'PageDown') {
258 | dist = this.props.visibleLines;
259 | }
260 | if (ev.key === 'ArrowUp' || ev.key === 'PageUp') {
261 | dist = -dist;
262 | }
263 |
264 | if (modifiers === '' || modifiers === 's') {
265 | const lineIndex = this.findCursorLineIndex();
266 | const newIndex = Math.min(this.props.lines.length - 1, Math.max(0, lineIndex + dist));
267 | if (lineIndex !== newIndex) {
268 | this.applySelection(ev, this.props.lines[newIndex]);
269 | ev.preventDefault();
270 | }
271 | } else if (ev.ctrlKey) {
272 | this.props.applyScroll(dist);
273 | ev.preventDefault();
274 | }
275 | }
276 |
277 | if ((ev.key === 'ArrowLeft' || ev.key === 'ArrowRight') && modifiers === '') {
278 | this.props.followBranch(ev.key === 'ArrowRight', this.findCursorLine());
279 | ev.preventDefault();
280 | }
281 |
282 | if (ev.key === 'Tab' && modifiers === '') {
283 | this.props.updateDisplaySymbols(!this.props.displaySymbols);
284 | ev.preventDefault();
285 | }
286 |
287 | if (ev.key === 'a' && modifiers === 'c') {
288 | this.props.assembleInstruction(this.findCursorLine(), '');
289 | ev.preventDefault();
290 | }
291 |
292 | if ((ev.key === ' ' || ev.key === 'Spacebar') && modifiers === '') {
293 | this.props.toggleBreakpoint(this.findCursorLine());
294 | ev.preventDefault();
295 | }
296 | if (ev.key === 'd' && modifiers === 'c') {
297 | this.props.toggleBreakpoint(this.findCursorLine(), true);
298 | ev.preventDefault();
299 | }
300 |
301 | if ((ev.key === 's' || ev.key === 'f') && modifiers === 'c') {
302 | this.props.searchPrompt();
303 | ev.preventDefault();
304 | }
305 |
306 | if ((ev.key === 'F3') && modifiers === '') {
307 | this.props.searchNext();
308 | ev.preventDefault();
309 | }
310 |
311 | if (ev.key === 'g' && modifiers === 'c') {
312 | this.props.promptGoto();
313 | ev.preventDefault();
314 | }
315 |
316 | if (ev.key === 'e' && modifiers === 'c') {
317 | this.props.editBreakpoint(this.findCursorLine());
318 | ev.preventDefault();
319 | }
320 | }
321 |
322 | applySelection(ev, line) {
323 | let { selectionTop, selectionBottom } = this.props;
324 | if (ev.shiftKey) {
325 | selectionTop = Math.min(selectionTop, line.address);
326 | selectionBottom = Math.max(selectionBottom, line.address);
327 | } else {
328 | selectionTop = line.address;
329 | selectionBottom = line.address;
330 | }
331 |
332 | if (selectionTop !== this.props.selectionTop || selectionBottom !== this.props.selectionBottom) {
333 | this.props.updateSelection({
334 | selectionTop,
335 | selectionBottom,
336 | });
337 | }
338 | this.props.updateCursor(line.address);
339 | }
340 |
341 | mouseEventToLine(ev) {
342 | // Account for page and list scrolling.
343 | const y = ev.pageY - this.ref.current.getBoundingClientRect().top - window.pageYOffset;
344 | const index = Math.floor(y / this.props.lineHeight);
345 | return this.props.lines[index];
346 | }
347 |
348 | findCursorLineIndex() {
349 | const { cursor, lines, selectionTop } = this.props;
350 | const addr = cursor ? cursor : selectionTop;
351 | for (let i = 0; i < lines.length; ++i) {
352 | if (lines[i].address === addr) {
353 | return i;
354 | }
355 | }
356 |
357 | return undefined;
358 | }
359 |
360 | findCursorLine() {
361 | const lineIndex = this.findCursorLineIndex();
362 | return this.props.lines[lineIndex];
363 | }
364 | }
365 |
366 | DisasmList.propTypes = {
367 | lineHeight: PropTypes.number.isRequired,
368 | visibleLines: PropTypes.number.isRequired,
369 | displaySymbols: PropTypes.bool.isRequired,
370 | cursor: PropTypes.number,
371 | selectionTop: PropTypes.number,
372 | selectionBottom: PropTypes.number,
373 | highlightText: PropTypes.string,
374 |
375 | onDoubleClick: PropTypes.func.isRequired,
376 | updateCursor: PropTypes.func.isRequired,
377 | updateSelection: PropTypes.func.isRequired,
378 | updateDisplaySymbols: PropTypes.func.isRequired,
379 | getSelectedDisasm: PropTypes.func.isRequired,
380 | followBranch: PropTypes.func.isRequired,
381 | assembleInstruction: PropTypes.func.isRequired,
382 | toggleBreakpoint: PropTypes.func.isRequired,
383 | applyScroll: PropTypes.func.isRequired,
384 | promptGoto: PropTypes.func.isRequired,
385 | searchPrompt: PropTypes.func.isRequired,
386 | searchNext: PropTypes.func.isRequired,
387 | editBreakpoint: PropTypes.func.isRequired,
388 |
389 | range: PropTypes.shape({
390 | start: PropTypes.number.isRequired,
391 | end: PropTypes.number.isRequired,
392 | }).isRequired,
393 | lines: PropTypes.arrayOf(PropTypes.shape({
394 | address: PropTypes.number.isRequired,
395 | })).isRequired,
396 | branchGuides: PropTypes.arrayOf(PropTypes.shape({
397 | top: PropTypes.number.isRequired,
398 | bottom: PropTypes.number.isRequired,
399 | direction: PropTypes.string.isRequired,
400 | })).isRequired,
401 | };
402 |
403 | export default DisasmList;
404 |
--------------------------------------------------------------------------------
/src/components/CPU/DisasmSearch.css:
--------------------------------------------------------------------------------
1 | .DisasmSearch {
2 | background: #3999bd;
3 | padding: 7px;
4 | position: absolute;
5 | top: 25px;
6 | right: 20px;
7 | }
8 |
9 | .DisasmSearch__field {
10 | display: inline-block;
11 | font-size: 13px;
12 | overflow: hidden;
13 | position: relative;
14 | width: 24ex;
15 | max-width: 80%;
16 | vertical-align: top;
17 | }
18 |
19 | .DisasmSearch input[type="search"] {
20 | background: #fff;
21 | border: 0;
22 | box-sizing: border-box;
23 | font-size: inherit;
24 | padding: 2px 6px;
25 | width: 100%;
26 | }
27 |
28 | .DisasmSearch__field--progress::after {
29 | animation: 2s DisasmSearch__progress ease-in-out infinite;
30 | background: #f88;
31 | content: '';
32 | display: block;
33 | position: absolute;
34 | left: 0;
35 | bottom: 0;
36 | height: 2px;
37 | width: 30%;
38 | }
39 |
40 | @keyframes DisasmSearch__progress {
41 | from {
42 | left: -30%;
43 | }
44 |
45 | to {
46 | left: 100%;
47 | }
48 | }
49 |
50 | .DisasmSearch button {
51 | border: 0;
52 | background: transparent;
53 | padding: 2px;
54 | margin-left: 5px;
55 | vertical-align: top;
56 | }
57 |
58 | .DisasmSearch button svg {
59 | fill: transparent;
60 | stroke: #000;
61 | stroke-width: 16px;
62 | stroke-linecap: round;
63 | stroke-linejoin: miter;
64 | vertical-align: top;
65 | }
66 |
67 | .DisasmSearch button:hover,
68 | .DisasmSearch button:active,
69 | .DisasmSearch button:focus {
70 | background: rgba(255, 255, 255, 0.2);
71 | }
72 |
--------------------------------------------------------------------------------
/src/components/CPU/DisasmSearch.js:
--------------------------------------------------------------------------------
1 | import { createRef, PureComponent } from 'react';
2 | import PropTypes from 'prop-types';
3 | import classNames from 'clsx';
4 | import './DisasmSearch.css';
5 |
6 | class DisasmSearch extends PureComponent {
7 | ref;
8 |
9 | constructor(props) {
10 | super(props);
11 | this.ref = createRef();
12 | }
13 |
14 | render() {
15 | const value = this.props.searchString || '';
16 | const classes = classNames('DisasmSearch__field', { 'DisasmSearch__field--progress': this.props.inProgress });
17 |
18 | return (
19 | /* eslint no-script-url: "off" */
20 |
37 | );
38 | }
39 |
40 | componentDidUpdate(prevProps, prevState) {
41 | if (prevProps.searchString === null && this.props.searchString !== null) {
42 | this.focus();
43 | }
44 | }
45 |
46 | handleChange = (ev) => {
47 | this.props.updateSearchString(ev.target.value);
48 | };
49 |
50 | handleNext = (ev) => {
51 | this.props.searchNext();
52 | this.focus();
53 | ev.preventDefault();
54 | };
55 |
56 | handleCancel = (ev) => {
57 | this.props.updateSearchString(null);
58 | };
59 |
60 | handleKeyDown = (ev) => {
61 | if (ev.key === 'Escape') {
62 | this.handleCancel();
63 | ev.preventDefault();
64 | }
65 | };
66 |
67 | focus() {
68 | this.ref.current.focus();
69 | }
70 | }
71 |
72 | DisasmSearch.propTypes = {
73 | searchString: PropTypes.string,
74 | inProgress: PropTypes.bool.isRequired,
75 | updateSearchString: PropTypes.func.isRequired,
76 | searchNext: PropTypes.func.isRequired,
77 | };
78 |
79 | export default DisasmSearch;
80 |
--------------------------------------------------------------------------------
/src/components/CPU/FuncList.css:
--------------------------------------------------------------------------------
1 | .FuncList {
2 | display: flex;
3 | flex-direction: column;
4 | flex: 1 1 auto;
5 | overflow-x: hidden;
6 | overflow-y: auto;
7 | padding-left: 1ex;
8 | text-overflow: clip;
9 | }
10 |
11 | .FuncList__search {
12 | margin-top: 1ex;
13 | margin-bottom: 1ex;
14 | width: 95%;
15 | }
16 |
17 | .FuncList__loading {
18 | line-height: 1.3;
19 | }
20 |
21 | .FuncList__listing {
22 | flex: 1;
23 | }
24 |
25 | .FuncList__listing button {
26 | background: transparent;
27 | border: 0;
28 | cursor: pointer;
29 | font-size: inherit;
30 | padding: 0 3px;
31 | text-align: inherit;
32 | width: 100%;
33 | }
34 |
35 | .FuncList__listing button:hover,
36 | .FuncList__listing button:active,
37 | .FuncList__listing button:focus {
38 | background: #e0e8ff;
39 | }
40 |
--------------------------------------------------------------------------------
/src/components/CPU/FuncList.js:
--------------------------------------------------------------------------------
1 | import { PureComponent } from 'react';
2 | import DebuggerContext, { DebuggerContextValues } from '../DebuggerContext';
3 | import PropTypes from 'prop-types';
4 | import { AutoSizer, List } from 'react-virtualized';
5 | import listeners from '../../utils/listeners.js';
6 | import 'react-virtualized/styles.css';
7 | import './FuncList.css';
8 |
9 | class FuncList extends PureComponent {
10 | state = {
11 | connected: false,
12 | // Lots of functions can take a while...
13 | loading: true,
14 | rowHeight: null,
15 | functions: [],
16 | filter: '',
17 | filteredFunctions: [],
18 | };
19 | /**
20 | * @type {DebuggerContextValues}
21 | */
22 | context;
23 | listeners_;
24 |
25 | render() {
26 | const { filter } = this.state;
27 | return (
28 |
29 |
30 | {this.renderList()}
31 |
32 | );
33 | }
34 |
35 | renderList() {
36 | const { filter, loading, rowHeight } = this.state;
37 | if (loading || !rowHeight) {
38 | return Loading...
;
39 | }
40 |
41 | return (
42 |
43 |
{this.renderSizedList}
44 |
45 | );
46 | }
47 |
48 | renderSizedList = ({ height, width }) => {
49 | const { filter, filteredFunctions, rowHeight } = this.state;
50 | return (
51 |
59 | );
60 | };
61 |
62 | renderFunc = ({ index, key, style }) => {
63 | const func = this.state.filteredFunctions[index];
64 | return (
65 |
66 | {func.name}
67 |
68 | );
69 | };
70 |
71 | componentDidMount() {
72 | this.listeners_ = listeners.listen({
73 | 'connection.change': (connected) => this.setState({ connected }),
74 | 'game.start': () => this.updateList(),
75 | });
76 | if (this.state.connected) {
77 | this.updateList();
78 | }
79 | if (!this.state.rowHeight) {
80 | setTimeout(() => this.measureHeight(), 0);
81 | }
82 | }
83 |
84 | componentWillUnmount() {
85 | listeners.forget(this.listeners_);
86 | }
87 |
88 | componentDidUpdate(prevProps, prevState) {
89 | if (!prevState.connected && this.state.connected) {
90 | this.updateList();
91 | }
92 | if (!this.state.rowHeight) {
93 | setTimeout(() => this.measureHeight(), 0);
94 | }
95 | }
96 |
97 | measureHeight() {
98 | const node = document.querySelector('.FuncList__loading');
99 | if (node) {
100 | const rowHeight = node.getBoundingClientRect().height;
101 | this.setState({ rowHeight });
102 | }
103 | }
104 |
105 | updateList() {
106 | if (!this.state.connected) {
107 | // This avoids a duplicate update during initial connect.
108 | return;
109 | }
110 |
111 | if (!this.state.loading && this.state.functions.length === 0) {
112 | this.setState({ loading: true });
113 | }
114 |
115 | this.context.ppsspp.send({
116 | event: 'hle.func.list',
117 | }).then(({ functions }) => {
118 | functions.sort((a, b) => a.name.localeCompare(b.name));
119 | this.setState(prevState => ({
120 | functions,
121 | filteredFunctions: this.applyFilter(functions, prevState.filter),
122 | loading: false,
123 | }));
124 | }, () => {
125 | this.setState({ functions: [], filteredFunctions: [], loading: false });
126 | });
127 | }
128 |
129 | applyFilter(functions, filter) {
130 | if (filter === '') {
131 | return functions;
132 | }
133 | const match = filter.toLowerCase();
134 | return functions.filter(func => func.name.toLowerCase().indexOf(match) !== -1);
135 | }
136 |
137 | handleFilter = (ev) => {
138 | const filter = ev.target.value;
139 | this.setState(prevState => ({
140 | filter,
141 | filteredFunctions: this.applyFilter(prevState.functions, filter),
142 | }));
143 | };
144 |
145 | handleClick = (ev) => {
146 | const { address } = ev.target.dataset;
147 | this.props.gotoDisasm(Number(address));
148 | ev.preventDefault();
149 | };
150 |
151 | static getDerivedStateFromProps(nextProps, prevState) {
152 | let update = null;
153 | if (nextProps.started) {
154 | update = { ...update, connected: true };
155 | }
156 | if (!nextProps.started && prevState.functions.length) {
157 | update = { ...update, functions: [], filteredFunctions: [] };
158 | }
159 | return update;
160 | }
161 | }
162 |
163 | FuncList.propTypes = {
164 | started: PropTypes.bool,
165 | gotoDisasm: PropTypes.func.isRequired,
166 | };
167 |
168 | FuncList.contextType = DebuggerContext;
169 |
170 | export default FuncList;
171 |
--------------------------------------------------------------------------------
/src/components/CPU/LeftPanel.css:
--------------------------------------------------------------------------------
1 | .LeftPanel {
2 | display: flex;
3 | flex: 1;
4 | flex-direction: column;
5 | width: 22ex;
6 | }
7 |
8 | .LeftPanel .react-tabs {
9 | display: flex;
10 | flex: 1;
11 | flex-direction: column;
12 | }
13 |
14 | .LeftPanel .react-tabs__tab-panel--selected {
15 | display: flex;
16 | flex: 1;
17 | flex-direction: column;
18 | }
19 |
20 | .LeftPanel__tools {
21 | padding: 1ex 0;
22 | }
23 |
24 | .LeftPanel__tools button {
25 | background: transparent;
26 | border: 0;
27 | font-size: inherit;
28 | padding: 0 3px;
29 | text-align: inherit;
30 | }
31 |
32 | .LeftPanel__tools button:enabled {
33 | cursor: pointer;
34 | }
35 |
36 | .LeftPanel__tools button:enabled:active,
37 | .LeftPanel__tools button:enabled:hover,
38 | .LeftPanel__tools button:enabled:focus {
39 | color: #279;
40 | }
41 |
--------------------------------------------------------------------------------
/src/components/CPU/LeftPanel.js:
--------------------------------------------------------------------------------
1 | import { PureComponent } from 'react';
2 | import DebuggerContext, { DebuggerContextValues } from '../DebuggerContext';
3 | import PropTypes from 'prop-types';
4 | import { Tab, Tabs, TabList, TabPanel } from 'react-tabs';
5 | import FuncList from './FuncList';
6 | import RegPanel from './RegPanel';
7 | import VisualizeVFPU from './VisualizeVFPU';
8 | import SaveBreakpoints from './SaveBreakpoints';
9 | import './LeftPanel.css';
10 | import '../ext/react-tabs.css';
11 |
12 | class LeftPanel extends PureComponent {
13 | state = {
14 | vfpuModalOpen: false,
15 | breakpointModalOpen: false,
16 | // These can be slow, so we want to prevent render until it's selected.
17 | everShownFuncs: false,
18 | };
19 | /**
20 | * @type {DebuggerContextValues}
21 | */
22 | context;
23 |
24 | render() {
25 | return (
26 |
27 |
28 |
29 | Regs
30 | Funcs
31 | Tools
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 | Visualize VFPU
41 | Save Breakpoints
42 |
43 |
44 |
45 |
46 |
47 |
48 | );
49 | }
50 |
51 | handleSelect = (index) => {
52 | if (index === 1 && !this.state.everShownFuncs) {
53 | this.setState({ everShownFuncs: true });
54 | }
55 | };
56 |
57 | handleVFPUOpen = () => {
58 | this.setState({ vfpuModalOpen: true });
59 | };
60 |
61 | handleVFPUClose = () => {
62 | this.setState({ vfpuModalOpen: false });
63 | };
64 |
65 | handleBreakpointOpen = () => {
66 | this.setState({ breakpointModalOpen: true });
67 | };
68 |
69 | handleBreakpointClose = () => {
70 | this.setState({ breakpointModalOpen: false });
71 | };
72 | }
73 |
74 | LeftPanel.propTypes = {
75 | gotoDisasm: PropTypes.func.isRequired,
76 | };
77 |
78 | LeftPanel.contextType = DebuggerContext;
79 |
80 | export default LeftPanel;
81 |
--------------------------------------------------------------------------------
/src/components/CPU/RegList.js:
--------------------------------------------------------------------------------
1 | import { createRef, PureComponent } from 'react';
2 | import PropTypes from 'prop-types';
3 | import { ContextMenuTrigger } from 'react-contextmenu';
4 | import { toString08X } from '../../utils/format';
5 | import { ensureInView, hasContextMenu } from '../../utils/dom';
6 | import classNames from 'clsx';
7 |
8 | class RegList extends PureComponent {
9 | state = {
10 | cursor: 0,
11 | };
12 | cursorRef;
13 | needsScroll = false;
14 |
15 | constructor(props) {
16 | super(props);
17 |
18 | this.cursorRef = createRef();
19 | }
20 |
21 | render() {
22 | return (
23 |
24 | {this.props.registerNames.map((name, reg) => {
25 | return this.renderReg(name, reg);
26 | })}
27 |
28 | );
29 | }
30 |
31 | renderReg(name, reg) {
32 | const mapData = (props) => {
33 | return { cat: this.props.id, reg, value: this.format(reg) };
34 | };
35 | const attributes = {
36 | onDoubleClick: (ev) => this.onDoubleClick(ev, mapData()),
37 | onMouseDown: (ev) => this.setState({ cursor: reg }),
38 | onKeyDown: (ev) => this.onKeyDown(ev),
39 | tabIndex: 0,
40 | };
41 | const ddClasses = {
42 | 'RegPanel__item--changed': this.changed(reg),
43 | 'RegPanel__item--selected': this.state.cursor === reg,
44 | };
45 | const ref = this.state.cursor === reg ? this.cursorRef : undefined;
46 |
47 | return (
48 |
54 | );
55 | }
56 |
57 | format(reg) {
58 | if (this.props.id === 0) {
59 | return toString08X(this.props.uintValues[reg]);
60 | } else {
61 | return this.props.floatValues[reg];
62 | }
63 | }
64 |
65 | changed(reg) {
66 | return this.props.uintValues[reg] !== this.props.uintValuesLast[reg];
67 | }
68 |
69 | componentDidUpdate(prevProps, prevState) {
70 | // Always associated with a state update.
71 | if (this.needsScroll && this.cursorRef.current) {
72 | const triggerNode = this.cursorRef.current.parentNode;
73 | ensureInView(triggerNode, { block: 'nearest' });
74 | triggerNode.focus();
75 | this.needsScroll = false;
76 | }
77 | }
78 |
79 | onDoubleClick = (ev, data) => {
80 | if (ev.button === 0) {
81 | this.props.onDoubleClick(ev, data);
82 | }
83 | };
84 |
85 | onKeyDown(ev) {
86 | if (hasContextMenu()) {
87 | return;
88 | }
89 | if (ev.key === 'ArrowUp' && this.state.cursor > 0) {
90 | this.needsScroll = true;
91 | this.setState(prevState => ({ cursor: prevState.cursor - 1 }));
92 | ev.preventDefault();
93 | }
94 | if (ev.key === 'ArrowDown' && this.state.cursor < this.props.registerNames.length - 1) {
95 | this.needsScroll = true;
96 | this.setState(prevState => ({ cursor: prevState.cursor + 1 }));
97 | ev.preventDefault();
98 | }
99 | }
100 | }
101 |
102 | RegList.propTypes = {
103 | id: PropTypes.number.isRequired,
104 | contextmenu: PropTypes.string.isRequired,
105 | onDoubleClick: PropTypes.func.isRequired,
106 | registerNames: PropTypes.arrayOf(PropTypes.string).isRequired,
107 | uintValues: PropTypes.arrayOf(PropTypes.number).isRequired,
108 | floatValues: PropTypes.arrayOf(PropTypes.string).isRequired,
109 | uintValuesLast: PropTypes.arrayOf(PropTypes.number).isRequired,
110 | floatValuesLast: PropTypes.arrayOf(PropTypes.string).isRequired,
111 | };
112 |
113 | export default RegList;
114 |
--------------------------------------------------------------------------------
/src/components/CPU/RegPanel.css:
--------------------------------------------------------------------------------
1 | #RegPanel {
2 | display: flex;
3 | flex: 1;
4 | flex-direction: column;
5 | width: 22ex;
6 | }
7 |
8 | #RegPanel dl {
9 | flex: 1 1 auto;
10 | font-family: Consolas, monospace;
11 | line-height: 0.8em;
12 | margin: 0;
13 | margin-top: 1px;
14 | height: 0;
15 | overflow-y: auto;
16 | text-align: start;
17 | }
18 |
19 | #RegPanel dt {
20 | clear: both;
21 | cursor: default;
22 | float: left;
23 | margin: 0;
24 | margin-top: 1px;
25 | margin-bottom: 1px;
26 | padding: 0;
27 | width: 8ex;
28 | }
29 |
30 | #RegPanel dt::before {
31 | background: #e8efff;
32 | content: "\00A0";
33 | display: block;
34 | float: left;
35 | margin-left: 1px;
36 | margin-right: 2px;
37 | width: 14px;
38 | }
39 |
40 | #RegPanel dd {
41 | border: 1px solid transparent;
42 | cursor: default;
43 | margin: 0;
44 | margin-left: 16px;
45 | padding: 0;
46 | padding-left: 8ex;
47 | }
48 |
49 | #RegPanel dd:active {
50 | border-color: #666;
51 | }
52 |
53 | .RegPanel__item--selected {
54 | background: #e0e8ff;
55 | background-clip: padding-box;
56 | }
57 |
58 | .RegPanel__item--changed {
59 | color: red;
60 | }
61 |
62 | #RegPanel .react-contextmenu-wrapper:focus {
63 | /* Already shown on selection. */
64 | outline: none;
65 | }
66 |
67 | #RegPanel .react-tabs {
68 | display: flex;
69 | flex-direction: column;
70 | flex-grow: 1;
71 | }
72 |
73 | #RegPanel .react-tabs__tab-panel--selected {
74 | display: flex;
75 | flex: 1 1 auto;
76 | flex-direction: column;
77 | }
78 |
79 | /* These fix a bug in IE11. */
80 | .LeftPanel .react-tabs__tab-list {
81 | flex: 0 0 auto;
82 | }
83 |
--------------------------------------------------------------------------------
/src/components/CPU/RegPanel.js:
--------------------------------------------------------------------------------
1 | import { PureComponent } from 'react';
2 | import DebuggerContext, { DebuggerContextValues } from '../DebuggerContext';
3 | import PropTypes from 'prop-types';
4 | import { ContextMenu, MenuItem } from 'react-contextmenu';
5 | import { Tab, Tabs, TabList, TabPanel } from 'react-tabs';
6 | import RegList from './RegList';
7 | import listeners from '../../utils/listeners.js';
8 | import { copyText } from '../../utils/clipboard';
9 | import './RegPanel.css';
10 | import '../ext/react-contextmenu.css';
11 | import '../ext/react-tabs.css';
12 |
13 | class RegPanel extends PureComponent {
14 | state = {
15 | categories: [],
16 | };
17 | /**
18 | * @type {DebuggerContextValues}
19 | */
20 | context;
21 |
22 | render() {
23 | return (
24 |
25 | {this.renderTabs()}
26 | {this.renderContextMenu()}
27 |
28 | );
29 | }
30 |
31 | renderTabs() {
32 | const { categories } = this.state;
33 | // Seems react-tabs is buggy when tabs are initially empty.
34 | if (categories.length === 0) {
35 | return '';
36 | }
37 |
38 | return (
39 |
40 |
41 | {categories.map(c => {c.name} )}
42 |
43 | {categories.map(c => (
44 |
45 |
46 |
47 | ))}
48 |
49 | );
50 | }
51 |
52 | renderContextMenu() {
53 | const disabled = !this.context.gameStatus.stepping;
54 | return (
55 |
56 |
57 | Go to in Memory View
58 |
59 |
60 | Go to in Disassembly
61 |
62 |
63 |
64 | Copy Value
65 |
66 |
67 | Change...
68 |
69 |
70 | );
71 | }
72 |
73 | componentDidMount() {
74 | this.listeners_ = listeners.listen({
75 | 'connection': () => this.updateRegs(false),
76 | 'cpu.stepping': () => this.updateRegs(false),
77 | 'cpu.setReg': (result) => this.updateReg(result),
78 | });
79 | }
80 |
81 | componentWillUnmount() {
82 | listeners.forget(this.listeners_);
83 | }
84 |
85 | componentDidUpdate(prevProps, prevState) {
86 | if (this.props.currentThread && this.props.currentThread !== prevProps.currentThread) {
87 | this.updateRegs(true);
88 | }
89 | }
90 |
91 | handleViewMemory = (ev, data) => {
92 | // TODO
93 | console.log(data);
94 | };
95 |
96 | handleViewDisassembly = (ev, data) => {
97 | const uintValue = this.state.categories[data.cat].uintValues[data.reg];
98 | this.props.gotoDisasm(uintValue);
99 | };
100 |
101 | handleCopyReg = (ev, data, regNode) => {
102 | copyText(data.value);
103 | };
104 |
105 | handleChangeReg = (ev, data, regNode) => {
106 | const prevValue = (data.cat === 0 ? '0x' : '') + data.value;
107 | const registerName = this.state.categories[data.cat].registerNames[data.reg];
108 |
109 | const newValue = window.prompt('New value for ' + registerName, prevValue);
110 | if (newValue === null) {
111 | return;
112 | }
113 |
114 | const packet = {
115 | event: 'cpu.setReg',
116 | thread: this.context.gameStatus.currentThread,
117 | category: data.cat,
118 | register: data.reg,
119 | value: newValue,
120 | };
121 |
122 | // The result is automatically listened for.
123 | this.context.ppsspp.send(packet).catch((err) => {
124 | window.alert(err);
125 | });
126 | };
127 |
128 | updateRegs(keepLast) {
129 | this.context.ppsspp.send({
130 | event: 'cpu.getAllRegs',
131 | thread: this.context.gameStatus.currentThread,
132 | }).then((result) => {
133 | let { categories } = result;
134 | // Add values for change tracking.
135 | const hasPrev = this.state.categories.length !== 0;
136 | for (let cat of categories) {
137 | const prevCat = hasPrev ? this.state.categories[cat.id] : null;
138 | cat.uintValuesLast = hasPrev ? (keepLast ? prevCat.uintValuesLast : prevCat.uintValues) : cat.uintValues;
139 | cat.floatValuesLast = hasPrev ? (keepLast ? prevCat.floatValuesLast : prevCat.floatValues) : cat.floatValues;
140 | }
141 | this.setState({ categories });
142 | }, (err) => {
143 | // Leave regs alone.
144 | console.error(err);
145 | });
146 | }
147 |
148 | updateReg(result) {
149 | const replaceCopy = (arr, index, item) => {
150 | return arr.slice(0, index).concat([item]).concat(arr.slice(index + 1));
151 | };
152 |
153 | this.setState(prevState => ({
154 | categories: prevState.categories.map((cat) => {
155 | if (cat.id === result.category) {
156 | return {
157 | ...cat,
158 | // Keep values from last time, until next stepping.
159 | uintValuesLast: cat.uintValuesLast,
160 | floatValuesLast: cat.floatValuesLast,
161 | uintValues: replaceCopy(cat.uintValues, result.register, result.uintValue),
162 | floatValues: replaceCopy(cat.floatValues, result.register, result.floatValue),
163 | };
164 | }
165 | return cat;
166 | }),
167 | }));
168 | }
169 | }
170 |
171 | RegPanel.propTypes = {
172 | gotoDisasm: PropTypes.func.isRequired,
173 | currentThread: PropTypes.number,
174 | };
175 |
176 | RegPanel.contextType = DebuggerContext;
177 |
178 | export default RegPanel;
179 |
--------------------------------------------------------------------------------
/src/components/CPU/SaveBreakpoints.css:
--------------------------------------------------------------------------------
1 | .SaveBreakpoints__file-buttons {
2 | display: flex;
3 | gap: 2ex;
4 | margin-bottom: 2ex;
5 | }
6 |
--------------------------------------------------------------------------------
/src/components/CPU/SaveBreakpoints.js:
--------------------------------------------------------------------------------
1 | import FitModal from '../common/FitModal';
2 | import PropTypes from 'prop-types';
3 | import { useState } from 'react';
4 | import { useDebuggerContext } from '../DebuggerContext';
5 | import { setAutoPersistBreakpoints, isAutoPersistingBreakpoints, cleanBreakpointForPersist } from '../../utils/persist';
6 | import './SaveBreakpoints.css';
7 |
8 | function downloadBreakpoints(context) {
9 | let promises = [
10 | context.ppsspp.send({ event: 'game.status' }).then(({ game }) => game.id).catch(() => null),
11 | context.ppsspp.send({ event: 'cpu.breakpoint.list' }).then(({ breakpoints }) => breakpoints, () => []),
12 | context.ppsspp.send({ event: 'memory.breakpoint.list' }).then(({ breakpoints }) => breakpoints, () => []),
13 | ];
14 | Promise.all(promises).then(([id, cpu, memory]) => {
15 | const data = {
16 | version: '1.0',
17 | id,
18 | cpu: cpu.map(cleanBreakpointForPersist),
19 | memory: memory.map(cleanBreakpointForPersist),
20 | };
21 |
22 | let a = document.createElement('a');
23 | a.setAttribute('download', id + '.breakpoints.json');
24 | a.href = 'data:text/json;charset=utf-8,' + encodeURIComponent(JSON.stringify(data));
25 | a.hidden = true;
26 | document.body.appendChild(a);
27 | a.click();
28 | document.body.removeChild(a);
29 | });
30 | }
31 |
32 | function restoreBreakpoints(context, data) {
33 | let completion = [];
34 | for (let bp of data.cpu) {
35 | completion.push(context.ppsspp.send({
36 | event: 'cpu.breakpoint.add',
37 | ...bp,
38 | }).then(() => true, (err) => {
39 | console.error('Unable to import breakpoint', err);
40 | return false;
41 | }));
42 | }
43 | for (let bp of data.memory) {
44 | completion.push(context.ppsspp.send({
45 | event: 'memory.breakpoint.add',
46 | ...bp,
47 | }).then(() => true, (err) => {
48 | console.error('Unable to import breakpoint', err);
49 | return false;
50 | }));
51 | }
52 |
53 | Promise.all(completion).then((results) => {
54 | const succeeded = results.filter(v => v === true).length;
55 | const failed = results.filter(v => v === false).length;
56 |
57 | if (failed === 0 && succeeded === 0) {
58 | window.alert('No breakpoints were found to import.');
59 | } else if (failed === 0) {
60 | window.alert('Breakpoints imported successfully.');
61 | } else if (succeeded === 0) {
62 | window.alert('Failed to import breakpoints (see log.)');
63 | } else {
64 | window.alert('Some breakpoints failed to import (see log.)');
65 | }
66 | });
67 | }
68 |
69 | function promptRestoreBreakpoints(context) {
70 | // Could have canceled before, cleanup old inputs.
71 | if (document.getElementById('save-breakpoints-file')) {
72 | document.body.removeChild(document.getElementById('save-breakpoints-file'));
73 | }
74 |
75 | let input = document.createElement('input');
76 | input.id = 'save-breakpoints-file';
77 | input.setAttribute('type', 'file');
78 | input.setAttribute('accept', 'application/json');
79 | input.style.position = 'absolute';
80 | input.style.top = '0';
81 | input.style.left = '0';
82 | input.style.width = '1px';
83 | input.style.height = '1px';
84 | input.style.visibility = 'hidden';
85 |
86 | document.body.appendChild(input);
87 | input.addEventListener('change', (event) => {
88 | const fileList = event.target.files;
89 | const reader = new FileReader();
90 | reader.addEventListener('load', (event) => {
91 | const data = JSON.parse(reader.result);
92 | if (Array.isArray(data.cpu) && Array.isArray(data.memory) && data.version) {
93 | restoreBreakpoints(context, data);
94 | } else {
95 | window.alert('Format unrecogized, unable to import breakpoints.');
96 | }
97 | document.body.removeChild(input);
98 | });
99 | reader.readAsText(fileList[0]);
100 | });
101 | input.click();
102 | }
103 |
104 | export default function SaveBreakpoints(props) {
105 | const context = useDebuggerContext();
106 | const [persisting, setPersisting] = useState(isAutoPersistingBreakpoints());
107 | const updatePersisting = (ev) => {
108 | setAutoPersistBreakpoints(ev.target.checked);
109 | setPersisting(ev.target.checked);
110 | };
111 |
112 | return (
113 |
114 | Breakpoints
115 |
116 |
117 | downloadBreakpoints(context)}>Export to file
118 | promptRestoreBreakpoints(context)}>Import from file
119 |
120 |
121 |
122 |
123 | Save and restore on this device automatically
124 |
125 |
126 | );
127 | }
128 |
129 | SaveBreakpoints.propTypes = {
130 | isOpen: PropTypes.bool.isRequired,
131 | onClose: PropTypes.func.isRequired,
132 | };
133 |
--------------------------------------------------------------------------------
/src/components/CPU/StatusBar.css:
--------------------------------------------------------------------------------
1 | .StatusBar {
2 | background: #eee;
3 | border-top: 1px solid #ccc;
4 | border-bottom-left-radius: 1ex;
5 | border-bottom-right-radius: 1ex;
6 | display: flex;
7 | line-height: 1.5em;
8 | height: 1.5em;
9 | overflow: hidden;
10 | padding: 1px 1ex;
11 | text-overflow: ellipsis;
12 | white-space: nowrap;
13 | }
14 |
15 | .StatusBar__left {
16 | flex: 1 1 auto;
17 | }
18 |
19 | .StatusBar__right {
20 | text-align: right;
21 | }
22 |
--------------------------------------------------------------------------------
/src/components/CPU/StatusBar.js:
--------------------------------------------------------------------------------
1 | import { PureComponent } from 'react';
2 | import PropTypes from 'prop-types';
3 | import { toString02X, toString04X, toString08X } from '../../utils/format';
4 | import './StatusBar.css';
5 |
6 | class StatusBar extends PureComponent {
7 | render() {
8 | const { line } = this.props;
9 | if (!line) {
10 | return
;
11 | }
12 |
13 | return (
14 |
15 |
{this.renderLeftStatus(line)}
16 |
{line.function || line.symbol || ''}
17 |
18 | );
19 | }
20 |
21 | renderLeftStatus(line) {
22 | if (line.type === 'opcode' || line.type === 'macro') {
23 | if (line.dataAccess) {
24 | return this.renderDataAccess(line.dataAccess);
25 | } else if (line.branch) {
26 | return this.renderBranch(line.branch);
27 | } else if (line.relevantData && typeof line.relevantData.stringValue === 'string') {
28 | return this.renderString(line.relevantData);
29 | }
30 | } else if (line.type === 'data') {
31 | return this.renderDataSymbol(line.dataSymbol, line.address);
32 | }
33 |
34 | return '';
35 | }
36 |
37 | renderDataAccess(dataAccess) {
38 | if (dataAccess.uintValue === null) {
39 | return 'Invalid address ' + toString08X(dataAccess.address);
40 | }
41 |
42 | let status = '[' + toString08X(dataAccess.address) + '] = ';
43 | if (dataAccess.valueSymbol) {
44 | status += dataAccess.valueSymbol + ' (';
45 | }
46 | if (dataAccess.size === 1) {
47 | status += toString02X(dataAccess.uintValue);
48 | } else if (dataAccess.size === 2) {
49 | status += toString04X(dataAccess.uintValue);
50 | } else if (dataAccess.size >= 4) {
51 | // TODO: Show vectors better.
52 | status += toString08X(dataAccess.uintValue);
53 | }
54 | if (dataAccess.valueSymbol) {
55 | status += ')';
56 | }
57 |
58 | return status;
59 | }
60 |
61 | renderBranch(branch) {
62 | let status = branch.targetAddress ? toString08X(branch.targetAddress) : '';
63 | if (branch.symbol) {
64 | status += ' = ' + branch.symbol;
65 | }
66 | return status;
67 | }
68 |
69 | renderString(data) {
70 | return '[' + toString08X(data.address) + '] = ' + JSON.stringify(data.stringValue);
71 | }
72 |
73 | renderDataSymbol(dataSymbol, address) {
74 | let status = toString08X(dataSymbol.start);
75 | if (dataSymbol.label) {
76 | status += ' (' + dataSymbol.label + ')';
77 | }
78 | if (address !== dataSymbol.start) {
79 | status += ' + ' + toString08X(dataSymbol.start - address);
80 | }
81 | return status;
82 | }
83 | }
84 |
85 | StatusBar.propTypes = {
86 | line: PropTypes.object,
87 | };
88 |
89 | export default StatusBar;
90 |
--------------------------------------------------------------------------------
/src/components/CPU/VisualizeVFPU.css:
--------------------------------------------------------------------------------
1 | .VisualizeVFPU__list {
2 | column-count: 2;
3 | column-gap: 14px;
4 | }
5 |
6 | .VisualizeVFPU__matrix {
7 | border-collapse: collapse;
8 | margin-top: 10px;
9 | width: 100%;
10 | }
11 |
12 | .VisualizeVFPU__matrix th,
13 | .VisualizeVFPU__matrix td {
14 | border: 1px solid #ccc;
15 | padding: 3px;
16 | }
17 |
18 | .VisualizeVFPU__matrix:first-child {
19 | margin-top: 0;
20 | }
21 |
22 | th.VisualizeVFPU__name,
23 | th.VisualizeVFPU__row-header {
24 | background-color: #eee;
25 | border-top-color: #000;
26 | border-bottom-color: #000;
27 | }
28 |
29 | th.VisualizeVFPU__name,
30 | th.VisualizeVFPU__col-header {
31 | background-color: #eee;
32 | border-left-color: #000;
33 | border-right-color: #000;
34 | width: 4ex;
35 | }
36 |
37 | .VisualizeVFPU__options {
38 | display: flex;
39 | flex-direction: row;
40 | justify-content: space-between;
41 | margin-top: 1ex;
42 | }
43 |
44 | .VisualizeVFPU__format label {
45 | margin-right: 2ex;
46 | }
47 |
48 | .VisualizeVFPU__format input[type="radio"] {
49 | vertical-align: -1px;
50 | }
51 |
--------------------------------------------------------------------------------
/src/components/CPU/VisualizeVFPU.js:
--------------------------------------------------------------------------------
1 | import { PureComponent } from 'react';
2 | import PropTypes from 'prop-types';
3 | import FitModal from '../common/FitModal';
4 | import listeners from '../../utils/listeners';
5 | import { toString08X } from '../../utils/format';
6 | import '../ext/react-modal.css';
7 | import './VisualizeVFPU.css';
8 |
9 | // TODO: Consider using react-new-window for desktop?
10 |
11 | class VisualizeVFPU extends PureComponent {
12 | state = {
13 | categories: [],
14 | format: 'float',
15 | transposed: true,
16 | };
17 | listeners_;
18 |
19 | render() {
20 | const { categories, format, transposed } = this.state;
21 | if (categories.length < 3) {
22 | return null;
23 | }
24 |
25 | let matrices = [];
26 | for (let m = 0; m < categories[2].uintValues.length / 16; ++m) {
27 | matrices.push(this.renderMatrix(m));
28 | }
29 |
30 | return (
31 |
32 | VFPU Registers
33 |
34 | {matrices}
35 |
36 |
46 |
47 | );
48 | }
49 |
50 | renderMatrix(m) {
51 | return (
52 |
53 |
54 | {this.renderTopHeaders(m)}
55 |
56 |
57 | {this.renderTableRow(m, 0)}
58 | {this.renderTableRow(m, 1)}
59 | {this.renderTableRow(m, 2)}
60 | {this.renderTableRow(m, 3)}
61 |
62 |
63 | );
64 | }
65 |
66 | renderTopHeaders(m) {
67 | const { transposed } = this.state;
68 | if (!transposed) {
69 | return (
70 |
71 | M{m}00
72 | C{m}00
73 | C{m}10
74 | C{m}20
75 | C{m}30
76 |
77 | );
78 | }
79 |
80 | return (
81 |
82 | M{m}00
83 | R{m}00
84 | R{m}01
85 | R{m}02
86 | R{m}03
87 |
88 | );
89 | }
90 |
91 | renderTableRow(m, c) {
92 | const { transposed } = this.state;
93 | if (!transposed) {
94 | return (
95 |
96 | R{m}0{c}
97 | {this.renderValue(m, 0, c)}
98 | {this.renderValue(m, 1, c)}
99 | {this.renderValue(m, 2, c)}
100 | {this.renderValue(m, 3, c)}
101 |
102 | );
103 | }
104 |
105 | return (
106 |
107 | C{m}{c}0
108 | {this.renderValue(m, c, 0)}
109 | {this.renderValue(m, c, 1)}
110 | {this.renderValue(m, c, 2)}
111 | {this.renderValue(m, c, 3)}
112 |
113 | );
114 | }
115 |
116 | renderValue(m, c, r) {
117 | const reg = m * 4 + r * 32 + c;
118 | const cat = this.state.categories[2];
119 | if (this.state.format === 'uint') {
120 | return toString08X(cat.uintValues[reg]);
121 | }
122 | return cat.floatValues[reg];
123 | }
124 |
125 | componentDidMount() {
126 | this.listeners_ = listeners.listen({
127 | 'cpu.getAllRegs': ({ categories }) => this.setState({ categories }),
128 | 'cpu.setReg': ({ category, register, uintValue, floatValue }) => {
129 | const spliceCopy = (arr, index, value) => [...arr.slice(0, index), value, ...arr.slice(index + 1)];
130 |
131 | if (category !== 2) {
132 | return;
133 | }
134 | this.setState(prevState => {
135 | const newCategory = {
136 | ...prevState.categories[2],
137 | uintValues: spliceCopy(prevState.categories[2].uintValues, register, uintValue),
138 | floatValues: spliceCopy(prevState.categories[2].floatValues, register, floatValue),
139 | };
140 | const categories = spliceCopy(prevState.categories, 2, newCategory);
141 | return { categories };
142 | });
143 | },
144 | });
145 | }
146 |
147 | componentWillUnmount() {
148 | listeners.forget(this.listeners_);
149 | }
150 |
151 | handleFormat = (ev) => {
152 | const format = ev.target.value;
153 | this.setState({ format });
154 | };
155 |
156 | handleOrientation = (ev) => {
157 | const transposed = ev.target.checked;
158 | this.setState({ transposed });
159 | };
160 | }
161 |
162 | VisualizeVFPU.propTypes = {
163 | isOpen: PropTypes.bool.isRequired,
164 | onClose: PropTypes.func.isRequired,
165 | };
166 |
167 | export default VisualizeVFPU;
168 |
--------------------------------------------------------------------------------
/src/components/DebuggerContext.js:
--------------------------------------------------------------------------------
1 | import { createContext, useContext } from 'react';
2 | import PPSSPP from '../sdk/ppsspp.js';
3 | import { GameStatusValues } from '../utils/game.js';
4 |
5 | /**
6 | * @callback LogCallback
7 | * @param {string} message Message to log.
8 | */
9 |
10 | /**
11 | * @typedef {object} DebuggerContextValues
12 | * @property {PPSSPP|null} ppsspp PPSSPP SDK instance.
13 | * @property {GameStatusValues|null} gameStatus Basic thread/stepping information.
14 | * @property {LogCallback} log Function to display a log message.
15 | */
16 |
17 | /**
18 | * @type {DebuggerContextValues}
19 | */
20 | const defaultContext = {
21 | ppsspp: null,
22 | gameStatus: null,
23 | log: (message) => console.error(message),
24 | };
25 |
26 | const DebuggerContext = createContext(defaultContext);
27 | DebuggerContext.displayName = 'DebuggerContext';
28 | export default DebuggerContext;
29 |
30 | export function useDebuggerContext() {
31 | return useContext(DebuggerContext);
32 | }
33 |
34 | export function DebuggerProvider({ children, gameStatus, log, ppsspp }) {
35 | return (
36 |
37 | {children}
38 |
39 | );
40 | }
41 |
--------------------------------------------------------------------------------
/src/components/GPU.css:
--------------------------------------------------------------------------------
1 | #GPU {
2 | display: flex;
3 | flex: 1;
4 | flex-direction: column;
5 | }
6 |
7 | .GPU__main {
8 | align-items: center;
9 | display: flex;
10 | flex: 1;
11 | flex-direction: column;
12 | font-size: larger;
13 | justify-content: center;
14 | max-height: calc(75vh - 24px);
15 | min-height: 20em;
16 | }
17 |
18 | .GPU__info {
19 | font-size: 14px;
20 | margin-top: 2ex;
21 | }
22 |
23 | .GPU__form {
24 | display: block;
25 | margin-top: 2ex;
26 | }
27 |
--------------------------------------------------------------------------------
/src/components/GPU.js:
--------------------------------------------------------------------------------
1 | import { PureComponent } from 'react';
2 | import DebuggerContext, { DebuggerContextValues } from './DebuggerContext';
3 | import listeners from '../utils/listeners.js';
4 | import Log from './Log';
5 | import './GPU.css';
6 |
7 | class GPU extends PureComponent {
8 | state = {
9 | connected: false,
10 | started: false,
11 | paused: true,
12 | recording: false,
13 | };
14 | /**
15 | * @type {DebuggerContextValues}
16 | */
17 | context;
18 |
19 | render() {
20 | return (
21 |
22 | {this.renderMain()}
23 | {this.renderUtilityPanel()}
24 |
25 | );
26 | }
27 |
28 | renderMain() {
29 | return (
30 |
31 |
32 | {this.state.started && !this.state.paused ? 'Click below to generate a GE dump. It will download as a file.' : 'Waiting for a game to start...'}
33 |
34 | {this.state.started && !this.state.paused ? (
35 |
38 | ) : null}
39 |
40 | );
41 | }
42 |
43 | renderUtilityPanel() {
44 | return (
45 |
46 |
47 |
48 | );
49 | }
50 |
51 | componentDidMount() {
52 | this.listeners_ = listeners.listen({
53 | 'connection': () => this.onConnection(),
54 | 'connection.change': (connected) => this.onConnectionChange(connected),
55 | 'game.start': () => this.setState({ started: true, paused: false }),
56 | 'game.quit': () => this.setState({ started: false, paused: true }),
57 | 'game.pause': () => this.setState({ paused: true }),
58 | 'game.resume': () => this.setState({ paused: false }),
59 | });
60 | }
61 |
62 | componentWillUnmount() {
63 | listeners.forget(this.listeners_);
64 | }
65 |
66 | onConnectionChange(connected) {
67 | // On any reconnect, assume paused until proven otherwise.
68 | this.setState({ connected, started: false, paused: true });
69 | }
70 |
71 | onConnection() {
72 | // Update the status of this connection immediately too.
73 | this.context.ppsspp.send({ event: 'cpu.status' }).then((result) => {
74 | const { stepping, paused, pc } = result;
75 | const started = pc !== 0 || stepping;
76 |
77 | this.setState({ connected: true, started, paused });
78 | }, (err) => {
79 | this.setState({ paused: true });
80 | });
81 | }
82 |
83 | beginRecord = (ev) => {
84 | this.setState({ recording: true });
85 | this.context.ppsspp.send({ event: 'gpu.record.dump' }).then(result => {
86 | const { id } = this.context.gameStatus;
87 |
88 | let a = document.createElement('a');
89 | a.setAttribute('download', id ? id + '.ppdmp' : 'recording.ppdmp');
90 | a.href = result.uri;
91 | a.hidden = true;
92 | document.body.appendChild(a);
93 | a.click();
94 | document.body.removeChild(a);
95 |
96 | this.setState({ recording: false });
97 | });
98 | ev.preventDefault();
99 | };
100 | }
101 |
102 | GPU.contextType = DebuggerContext;
103 |
104 | export default GPU;
105 |
--------------------------------------------------------------------------------
/src/components/Log.css:
--------------------------------------------------------------------------------
1 | #Log {
2 | background: black;
3 | border-radius: 1ex;
4 | color: #ccc;
5 | flex: 1 1;
6 | font: 12px Consolas, monospace;
7 | max-width: 100%;
8 | min-height: 2em;
9 | overflow: auto;
10 | margin: 1ex;
11 | margin-top: 2px;
12 | padding: 1ex;
13 | white-space: pre-wrap;
14 | text-align: start;
15 | }
16 |
17 | .Log__message--internal::before {
18 | content: "\00BB\00A0";
19 | }
20 |
21 | .Log__message__timestamp {
22 | color: #fff;
23 | }
24 |
25 | .Log__message--level-1 {
26 | color: #0f0;
27 | }
28 |
29 | .Log__message--level-2 {
30 | color: #f00;
31 | }
32 |
33 | .Log__message--level-3 {
34 | color: #ff0;
35 | }
36 |
37 | .Log__message--level-4 {
38 | color: #0ff;
39 | }
40 |
--------------------------------------------------------------------------------
/src/components/Log.js:
--------------------------------------------------------------------------------
1 | import { useEffect, useRef } from 'react';
2 | import LogItem from './LogItem';
3 | import { useLogItems } from '../utils/logger';
4 | import './Log.css';
5 |
6 | export default function Log(props) {
7 | const logItems = useLogItems();
8 |
9 | const divRef = useRef();
10 |
11 | useEffect(() => {
12 | const div = divRef.current;
13 | div.scrollTop = div.scrollHeight - div.clientHeight;
14 | }, [divRef, logItems]);
15 |
16 | return (
17 |
18 | {logItems.map(item => )}
19 |
20 | );
21 | }
22 |
--------------------------------------------------------------------------------
/src/components/LogItem.js:
--------------------------------------------------------------------------------
1 | import { PureComponent } from 'react';
2 | import PropTypes from 'prop-types';
3 | import classNames from 'clsx';
4 |
5 | class LogItem extends PureComponent {
6 | render() {
7 | if (this.props.item.event === 'log') {
8 | return this.formatLogEvent();
9 | }
10 |
11 | return this.renderInternal();
12 | }
13 |
14 | renderInternal() {
15 | return (
16 |
17 | {this.props.item.message}
18 |
19 | );
20 | }
21 |
22 | formatLogEvent() {
23 | const { item } = this.props;
24 | return (
25 |
26 |
27 | {item.timestamp}
28 | {' ' + item.header + ' '}
29 |
30 | {item.message}
31 |
32 | );
33 | }
34 |
35 | makeClassName() {
36 | const { item } = this.props;
37 |
38 | return classNames('Log__message', {
39 | 'Log__message--internal': item.event !== 'log',
40 | ['Log__message--channel-' + item.channel]: item.event === 'log',
41 | ['Log__message--level-' + item.level]: true,
42 | });
43 | }
44 | }
45 |
46 | LogItem.propTypes = {
47 | item: PropTypes.shape({
48 | event: PropTypes.string,
49 | channel: PropTypes.string,
50 | level: PropTypes.number,
51 | timestamp: PropTypes.string,
52 | header: PropTypes.string,
53 | message: PropTypes.string.isRequired,
54 | }).isRequired,
55 | };
56 |
57 | export default LogItem;
58 |
--------------------------------------------------------------------------------
/src/components/NotConnected.css:
--------------------------------------------------------------------------------
1 | #NotConnected {
2 | display: flex;
3 | flex: 1;
4 | flex-direction: column;
5 | }
6 |
7 | .NotConnected__main {
8 | align-items: center;
9 | display: flex;
10 | flex: 1;
11 | flex-direction: column;
12 | font-size: larger;
13 | justify-content: center;
14 | max-height: calc(75vh - 24px);
15 | min-height: 20em;
16 | }
17 |
18 | .NotConnected__info {
19 | font-size: 14px;
20 | margin-top: 2ex;
21 | }
22 |
23 | .NotConnected__form {
24 | display: block;
25 | margin-top: 2ex;
26 | }
27 |
28 | .NotConnected__uri {
29 | margin-right: 1ex;
30 | width: 30ex;
31 | }
32 |
--------------------------------------------------------------------------------
/src/components/NotConnected.js:
--------------------------------------------------------------------------------
1 | import PropTypes from 'prop-types';
2 | import Log from './Log';
3 | import './NotConnected.css';
4 |
5 | export default function NotConnected(props) {
6 | const connectManually = (ev) => {
7 | const uri = ev.target.elements['debugger-uri'].value;
8 | props.connect(uri);
9 | ev.preventDefault();
10 | };
11 | const connectAutomatically = (ev) => {
12 | props.connect(null);
13 | ev.preventDefault();
14 | };
15 |
16 | const mainDiv = (
17 |
18 | {props.connecting ? 'Connecting to PPSSPP...' : 'Not connected to PPSSPP'}
19 |
20 |
21 | Make sure "Allow remote debugger" is enabled in Developer tools.
22 |
23 |
27 |
30 |
31 | );
32 |
33 | const utilityPanelDiv = (
34 |
35 |
36 |
37 | );
38 |
39 | return (
40 |
41 | {mainDiv}
42 | {utilityPanelDiv}
43 |
44 | );
45 | }
46 |
47 | NotConnected.propTypes = {
48 | connect: PropTypes.func.isRequired,
49 | };
50 |
--------------------------------------------------------------------------------
/src/components/common/Field.js:
--------------------------------------------------------------------------------
1 | import { Component } from 'react';
2 | import PropTypes from 'prop-types';
3 |
4 | class Field extends Component {
5 | render() {
6 | const { label, children } = this.props;
7 | return (
8 |
9 | {label}
10 | {this.renderInput()}
11 | {children}
12 |
13 | );
14 | }
15 |
16 | renderInput() {
17 | if (this.props.type === 'text') {
18 | return this.renderText();
19 | } else if (this.props.type === 'hex') {
20 | return this.renderHex();
21 | } else if (this.props.type === 'radio') {
22 | return this.renderRadio();
23 | } else if (this.props.type === 'checkboxes') {
24 | return this.renderCheckboxes();
25 | } else {
26 | return null;
27 | }
28 | }
29 |
30 | renderText(other = {}) {
31 | let { label, type, children, onChange, component, prop, ...primary } = this.props;
32 | primary.value = this.fieldValue();
33 | return ;
34 | }
35 |
36 | renderHex() {
37 | return this.renderText({ pattern: '(0x)?[0-9A-Fa-f]+', title: 'Hexadecimal value' });
38 | }
39 |
40 | renderRadio() {
41 | const value = this.fieldValue();
42 | return this.props.options.map(option => {
43 | const props = {
44 | name: this.fieldID(),
45 | value: option.value,
46 | checked: value === option.value,
47 | onChange: this.handleChange,
48 | disabled: this.props.disabled,
49 | };
50 | return (
51 |
52 |
53 | {' ' + option.label}
54 |
55 | );
56 | });
57 | }
58 |
59 | renderCheckboxes() {
60 | return this.props.options.map(option => {
61 | const props = {
62 | 'data-prop': option.value,
63 | checked: this.props.component.state[option.value],
64 | onChange: this.handleChange,
65 | disabled: this.props.disabled,
66 | };
67 | return (
68 |
69 |
70 | {' ' + option.label}
71 |
72 | );
73 | });
74 | }
75 |
76 | fieldID() {
77 | const { id, component, prop } = this.props;
78 | if (!id && component && prop) {
79 | return (component.displayName || component.constructor.name) + '__' + prop;
80 | }
81 | return id;
82 | }
83 |
84 | fieldValue() {
85 | const { value, component, prop } = this.props;
86 | if (value === undefined && component && prop) {
87 | return component.state[prop];
88 | }
89 | return value;
90 | }
91 |
92 | handleChange = (ev) => {
93 | if (this.props.onChange) {
94 | this.props.onChange(ev);
95 | } else if (this.props.component) {
96 | const { target } = ev;
97 | const prop = target.dataset.prop || this.props.prop;
98 | const value = target.type === 'checkbox' ? target.checked : target.value;
99 | this.props.component.setState({ [prop]: value });
100 | }
101 | };
102 | }
103 |
104 | Field.propTypes = {
105 | id: PropTypes.string,
106 | label: PropTypes.node.isRequired,
107 | type: PropTypes.oneOf(['text', 'hex', 'radio', 'checkboxes']),
108 | component: PropTypes.object,
109 | value: PropTypes.string,
110 | prop: PropTypes.string,
111 | options: PropTypes.arrayOf(PropTypes.shape({
112 | label: PropTypes.string.isRequired,
113 | value: PropTypes.string.isRequired,
114 | })),
115 | disabled: PropTypes.bool,
116 | onChange: PropTypes.func,
117 | children: PropTypes.node,
118 | };
119 |
120 | export default Field;
121 |
--------------------------------------------------------------------------------
/src/components/common/FitModal.js:
--------------------------------------------------------------------------------
1 | import { PureComponent } from 'react';
2 | import PropTypes from 'prop-types';
3 | import Modal from 'react-modal';
4 | import '../ext/react-modal.css';
5 |
6 | class FitModal extends PureComponent {
7 | render() {
8 | return (
9 |
15 | );
16 | }
17 |
18 | handleRequestClose = () => {
19 | if (this.props.confirmClose) {
20 | if (!window.confirm(this.props.confirmClose)) {
21 | // Don't close.
22 | return;
23 | }
24 | }
25 |
26 | this.props.onClose();
27 | };
28 | }
29 |
30 | FitModal.propTypes = {
31 | isOpen: PropTypes.bool.isRequired,
32 | onClose: PropTypes.func.isRequired,
33 | confirmClose: PropTypes.string,
34 | children: PropTypes.node,
35 | };
36 |
37 | export default FitModal;
38 |
--------------------------------------------------------------------------------
/src/components/common/Form.css:
--------------------------------------------------------------------------------
1 | .Form {
2 | margin: 0;
3 | }
4 |
5 | .Form__buttons {
6 | margin-top: 1ex;
7 | text-align: end;
8 | }
9 |
10 | .Form__buttons button {
11 | margin-left: 1ex;
12 | }
13 |
14 | .Field {
15 | margin-bottom: 1ex;
16 | }
17 |
18 | .Field input[type="checkbox"],
19 | .Field input[type="radio"] {
20 | vertical-align: -1px;
21 | }
22 |
23 | .Field input[type="text"] {
24 | box-sizing: border-box;
25 | width: 100%;
26 | }
27 |
28 | .Field__label {
29 | color: #333;
30 | display: block;
31 | font-weight: bold;
32 | }
33 |
--------------------------------------------------------------------------------
/src/components/common/Form.js:
--------------------------------------------------------------------------------
1 | import PropTypes from 'prop-types';
2 | import classNames from 'clsx';
3 | import './Form.css';
4 |
5 | function Form(props) {
6 | /* eslint no-script-url: "off" */
7 | return (
8 |
12 | );
13 | }
14 |
15 | Form.propTypes = {
16 | onSubmit: PropTypes.func.isRequired,
17 | className: PropTypes.string,
18 | children: PropTypes.node.isRequired,
19 | };
20 |
21 | Form.Buttons = function (props) {
22 | const saveHandler = props.onSave !== true ? props.onSave : undefined;
23 | const saveButton = props.onSave ? Save : null;
24 | const cancelButton = props.onCancel ? Cancel : null;
25 |
26 | return (
27 |
28 | {props.children}
29 | {saveButton}
30 | {cancelButton}
31 |
32 | );
33 | };
34 |
35 | Form.Buttons.propTypes = {
36 | onSave: PropTypes.oneOfType([PropTypes.func, PropTypes.bool]),
37 | onCancel: PropTypes.func,
38 | children: PropTypes.node,
39 | };
40 |
41 | export default Form;
42 |
--------------------------------------------------------------------------------
/src/components/common/GotoBox.css:
--------------------------------------------------------------------------------
1 | .GotoBox {
2 | flex: 0 0 auto;
3 | padding: 5px;
4 | padding-top: 2px;
5 | }
6 |
7 | .GotoBox__label {
8 | display: block;
9 | padding-bottom: 3px;
10 | }
11 |
12 | .GotoBox__address {
13 | width: 11ex;
14 | margin-right: 4px;
15 | }
16 |
17 | .GotoBox__button {
18 | background: transparent;
19 | border: 0;
20 | font-size: inherit;
21 | padding: 0 3px;
22 | }
23 |
24 | .GotoBox__button:enabled {
25 | cursor: pointer;
26 | }
27 |
28 | .GotoBox__button:enabled:active,
29 | .GotoBox__button:enabled:hover,
30 | .GotoBox__button:enabled:focus {
31 | color: #279;
32 | }
33 |
--------------------------------------------------------------------------------
/src/components/common/GotoBox.js:
--------------------------------------------------------------------------------
1 | import { createRef, PureComponent } from 'react';
2 | import DebuggerContext, { DebuggerContextValues } from '../DebuggerContext';
3 | import PropTypes from 'prop-types';
4 | import './GotoBox.css';
5 |
6 | class GotoBox extends PureComponent {
7 | state = {
8 | address: '',
9 | };
10 | /**
11 | * @type {DebuggerContextValues}
12 | */
13 | context;
14 | ref;
15 | id;
16 |
17 | constructor(props) {
18 | super(props);
19 | this.ref = createRef();
20 | this.id = 'GotoBox__address--' + Math.random().toString(36).substr(2, 9);
21 | }
22 |
23 | render() {
24 | return (
25 |
36 | );
37 | }
38 |
39 | renderPCButtons() {
40 | if (!this.props.includePC) {
41 | return null;
42 | }
43 | const disabled = !this.context.gameStatus.started;
44 | return (
45 | <>
46 | PC
47 | RA
48 | >
49 | );
50 | }
51 |
52 | componentDidUpdate(prevProps, prevState) {
53 | if (this.props.promptGotoMarker !== prevProps.promptGotoMarker) {
54 | this.ref.current.focus();
55 | this.ref.current.select();
56 | }
57 | }
58 |
59 | jumpToReg(name) {
60 | this.context.ppsspp.send({
61 | event: 'cpu.getReg',
62 | name,
63 | }).then((result) => {
64 | if (this.context.gameStatus.started) {
65 | this.props.gotoAddress(result.uintValue);
66 | }
67 | });
68 | }
69 |
70 | handleChange = (ev) => {
71 | this.setState({ address: ev.target.value });
72 | };
73 |
74 | handleSubmit = (ev) => {
75 | if (this.context.gameStatus.started) {
76 | this.context.ppsspp.send({
77 | event: 'cpu.evaluate',
78 | thread: this.context.gameStatus.currentThread,
79 | expression: this.state.address,
80 | }).then(({ uintValue }) => {
81 | if (this.context.gameStatus.started) {
82 | this.props.gotoAddress(uintValue);
83 | }
84 | }, err => {
85 | // Probably a bad reference.
86 | this.ref.current.focus();
87 | this.ref.current.select();
88 | });
89 | }
90 | ev.preventDefault();
91 | };
92 |
93 | handlePC = (ev) => {
94 | this.jumpToReg('pc');
95 | };
96 |
97 | handleRA = (ev) => {
98 | this.jumpToReg('ra');
99 | };
100 | }
101 |
102 | GotoBox.propTypes = {
103 | includePC: PropTypes.bool.isRequired,
104 | promptGotoMarker: PropTypes.any,
105 | gotoAddress: PropTypes.func.isRequired,
106 | };
107 |
108 | GotoBox.contextType = DebuggerContext;
109 |
110 | export default GotoBox;
111 |
--------------------------------------------------------------------------------
/src/components/ext/react-contextmenu.css:
--------------------------------------------------------------------------------
1 | .react-contextmenu {
2 | background-color: #fff;
3 | background-clip: padding-box;
4 | border: 1px solid rgba(0, 0, 0, 0.15);
5 | border-radius: .25rem;
6 | color: #373a3c;
7 | font-size: 13px;
8 | margin: 2px 0 0;
9 | min-width: 160px;
10 | outline: none;
11 | opacity: 0;
12 | padding: 3px 0;
13 | text-align: left;
14 | transition: opacity 0.1s ease-out;
15 | }
16 |
17 | .react-contextmenu.react-contextmenu--visible {
18 | opacity: 1;
19 | pointer-events: auto;
20 | z-index: 9999;
21 | }
22 |
23 | .react-contextmenu-item {
24 | background: 0 0;
25 | border: 0;
26 | color: #373a3c;
27 | cursor: pointer;
28 | font-weight: 400;
29 | line-height: 1.4;
30 | padding: 3px 16px;
31 | text-align: inherit;
32 | white-space: nowrap;
33 | }
34 |
35 | .react-contextmenu-item--active,
36 | .react-contextmenu-item--selected {
37 | color: #fff;
38 | background-color: #28f;
39 | text-decoration: none;
40 | }
41 |
42 | .react-contextmenu-item--disabled,
43 | .react-contextmenu-item--disabled:hover {
44 | background-color: transparent;
45 | color: #888;
46 | }
47 |
48 | .react-contextmenu-item--divider {
49 | border-bottom: 1px solid rgba(0, 0, 0, 0.15);
50 | cursor: inherit;
51 | margin-bottom: 3px;
52 | padding: 2px 0;
53 | }
54 |
55 | .react-contextmenu-submenu {
56 | padding: 0;
57 | }
58 |
59 | .react-contextmenu-submenu > .react-contextmenu-item:after {
60 | content: "\25B6";
61 | display: inline-block;
62 | position: absolute;
63 | right: 7px;
64 | }
65 |
--------------------------------------------------------------------------------
/src/components/ext/react-modal.css:
--------------------------------------------------------------------------------
1 | .ReactModal__FitOverlay {
2 | align-items: flex-start;
3 | background: rgba(0, 0, 0, 0.6);
4 | display: flex;
5 | justify-content: center;
6 | opacity: 0;
7 | padding-top: 50px;
8 | position: fixed;
9 | top: 0;
10 | left: 0;
11 | right: 0;
12 | bottom: 0;
13 | transition: 0.10s opacity;
14 | }
15 |
16 | .ReactModal__FitContent {
17 | background: #fff;
18 | border: 1px solid #ccc;
19 | border-radius: 4px;
20 | max-height: 90vh;
21 | max-width: 90vw;
22 | opacity: 0;
23 | outline: none;
24 | overflow: auto;
25 | -webkit-overflow-scrolling: touch;
26 | padding: 16px;
27 | position: static;
28 | transform: translateY(-10px) perspective(600px) rotateX(12deg) scale(0.95);
29 | transition: 0.15s transform, 0.10s opacity;
30 | }
31 |
32 | .ReactModal__Overlay--after-open {
33 | opacity: 1;
34 | transition: 0.15s opacity;
35 | }
36 |
37 | .ReactModal__Overlay--before-close {
38 | opacity: 0;
39 | }
40 |
41 | .ReactModal__Content--after-open {
42 | opacity: 1;
43 | transform: translateY(0) perspective(600px) rotateX(0) scale(1);
44 | transition: 0.15s transform, 0.15s opacity;
45 | }
46 |
47 | .ReactModal__Content--before-close {
48 | opacity: 0;
49 | transform: translateY(10px) perspective(600px) rotateX(0) scale(1);
50 | }
51 |
52 | .ReactModal__FitContent > :first-child,
53 | .ReactModal__FitContent > :first-child > :first-child {
54 | margin-top: 0;
55 | }
56 |
--------------------------------------------------------------------------------
/src/components/ext/react-tabs.css:
--------------------------------------------------------------------------------
1 | .react-tabs__tab-list {
2 | border-bottom: 2px solid #3999bd;
3 | margin: 0;
4 | padding: 0;
5 | text-align: start;
6 | }
7 |
8 | .react-tabs__tab {
9 | display: inline-block;
10 | border: 1px solid transparent;
11 | border-bottom: none;
12 | bottom: -1px;
13 | position: relative;
14 | list-style: none;
15 | padding: 3px 6px;
16 | cursor: pointer;
17 | }
18 |
19 | .react-tabs__tab--selected {
20 | background: #3999bd;
21 | color: #fff;
22 | }
23 |
24 | .react-tabs__tab--disabled {
25 | color: GrayText;
26 | cursor: default;
27 | }
28 |
29 | .react-tabs__tab:focus {
30 | background-color: #4cc2ed;
31 | outline: none;
32 | }
33 |
34 | .react-tabs__tab-panel {
35 | display: none;
36 | }
37 |
38 | .react-tabs__tab-panel--selected {
39 | display: block;
40 | }
41 |
42 | /* Nested tabs... */
43 | .react-tabs__tab-panel .react-tabs__tab-list {
44 | background: #3999bd;
45 | border-bottom: 2px solid #fff;
46 | color: #fff;
47 | }
48 |
49 | .react-tabs__tab-panel .react-tabs__tab--selected {
50 | background: #fff;
51 | color: #3999bd;
52 | }
53 |
--------------------------------------------------------------------------------
/src/index.css:
--------------------------------------------------------------------------------
1 | @import url('https://fonts.googleapis.com/css?family=Roboto');
2 |
3 | html {
4 | background: #fff;
5 | color: #000;
6 | min-height: 100%;
7 | }
8 |
9 | html, body, #root {
10 | display: flex;
11 | flex-direction: column;
12 | flex-grow: 1;
13 | font-family: Roboto, sans-serif;
14 | font-size: 13px;
15 | margin: 0;
16 | padding: 0;
17 | }
18 |
19 | body {
20 | min-height: 100vh;
21 | }
22 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { createRoot } from 'react-dom/client';
3 | import Modal from 'react-modal';
4 | import './index.css';
5 | import App from './components/App';
6 | import registerServiceWorker from './registerServiceWorker';
7 | import 'core-js/stable';
8 | import 'regenerator-runtime/runtime';
9 | import 'react-app-polyfill/ie11';
10 | import 'react-app-polyfill/stable';
11 |
12 | const root = createRoot(document.getElementById('root'));
13 | root.render( );
14 | Modal.setAppElement('#root');
15 | registerServiceWorker();
16 |
--------------------------------------------------------------------------------
/src/registerServiceWorker.js:
--------------------------------------------------------------------------------
1 | // In production, we register a service worker to serve assets from local cache.
2 |
3 | // This lets the app load faster on subsequent visits in production, and gives
4 | // it offline capabilities. However, it also means that developers (and users)
5 | // will only see deployed updates on the "N+1" visit to a page, since previously
6 | // cached resources are updated in the background.
7 |
8 | // To learn more about the benefits of this model, read https://goo.gl/KwvDNy.
9 | // This link also includes instructions on opting out of this behavior.
10 |
11 | const isLocalhost = Boolean(
12 | window.location.hostname === 'localhost' ||
13 | // [::1] is the IPv6 localhost address.
14 | window.location.hostname === '[::1]' ||
15 | // 127.0.0.1/8 is considered localhost for IPv4.
16 | window.location.hostname.match(
17 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/
18 | )
19 | );
20 |
21 | export default function register() {
22 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
23 | // The URL constructor is available in all browsers that support SW.
24 | const publicUrl = new URL(process.env.PUBLIC_URL, window.location);
25 | if (publicUrl.origin !== window.location.origin) {
26 | // Our service worker won't work if PUBLIC_URL is on a different origin
27 | // from what our page is served on. This might happen if a CDN is used to
28 | // serve assets; see https://github.com/facebookincubator/create-react-app/issues/2374
29 | return;
30 | }
31 |
32 | window.addEventListener('load', () => {
33 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`;
34 |
35 | if (isLocalhost) {
36 | // This is running on localhost. Lets check if a service worker still exists or not.
37 | checkValidServiceWorker(swUrl);
38 |
39 | // Add some additional logging to localhost, pointing developers to the
40 | // service worker/PWA documentation.
41 | navigator.serviceWorker.ready.then(() => {
42 | console.log(
43 | 'This web app is being served cache-first by a service ' +
44 | 'worker. To learn more, visit https://goo.gl/SC7cgQ'
45 | );
46 | });
47 | } else {
48 | // Is not local host. Just register service worker
49 | registerValidSW(swUrl);
50 | }
51 | });
52 | }
53 | }
54 |
55 | function registerValidSW(swUrl) {
56 | navigator.serviceWorker
57 | .register(swUrl)
58 | .then(registration => {
59 | registration.onupdatefound = () => {
60 | const installingWorker = registration.installing;
61 | installingWorker.onstatechange = () => {
62 | if (installingWorker.state === 'installed') {
63 | if (navigator.serviceWorker.controller) {
64 | // At this point, the old content will have been purged and
65 | // the fresh content will have been added to the cache.
66 | // It's the perfect time to display a "New content is
67 | // available; please refresh." message in your web app.
68 | console.log('New content is available; please refresh.');
69 | } else {
70 | // At this point, everything has been precached.
71 | // It's the perfect time to display a
72 | // "Content is cached for offline use." message.
73 | console.log('Content is cached for offline use.');
74 | }
75 | }
76 | };
77 | };
78 | })
79 | .catch(error => {
80 | console.error('Error during service worker registration:', error);
81 | });
82 | }
83 |
84 | function checkValidServiceWorker(swUrl) {
85 | // Check if the service worker can be found. If it can't reload the page.
86 | fetch(swUrl)
87 | .then(response => {
88 | // Ensure service worker exists, and that we really are getting a JS file.
89 | if (
90 | response.status === 404 ||
91 | response.headers.get('content-type').indexOf('javascript') === -1
92 | ) {
93 | // No service worker found. Probably a different app. Reload the page.
94 | navigator.serviceWorker.ready.then(registration => {
95 | registration.unregister().then(() => {
96 | window.location.reload();
97 | });
98 | });
99 | } else {
100 | // Service worker found. Proceed as normal.
101 | registerValidSW(swUrl);
102 | }
103 | })
104 | .catch(() => {
105 | console.log(
106 | 'No internet connection found. App is running in offline mode.'
107 | );
108 | });
109 | }
110 |
111 | export function unregister() {
112 | if ('serviceWorker' in navigator) {
113 | navigator.serviceWorker.ready.then(registration => {
114 | registration.unregister();
115 | });
116 | }
117 | }
118 |
--------------------------------------------------------------------------------
/src/sdk/ppsspp.js:
--------------------------------------------------------------------------------
1 | // PPSSPP debugger API client
2 | //
3 | // Most operations use promises and are asynchronous.
4 | //
5 | // Example usage:
6 | //
7 | // let ppsspp = new PPSSPP();
8 | // ppsspp.listen('cpu.stepping', function () {
9 | // console.log('Hit a breakpoint');
10 | // });
11 | // ppsspp.autoConnect().then(function () {
12 | // return ppsspp.send({ event: 'cpu.getReg', name: 'pc' });
13 | // }).then(function (result) {
14 | // console.log('pc: ', result.uintValue);
15 | // }).catch(function (err) {
16 | // console.log('Something went wrong', err);
17 | // });
18 | //
19 | // Methods:
20 | // - autoConnect(): Find and connect to PPSSPP automatically. Returns a promise.
21 | // - connect(uri): Connect to a specific WebSocket URI (begins with ws://.) Returns a promise.
22 | // - disconnect(): Disconnect from PPSSPP. No return.
23 | // - listen(name, handler): Adds a listener for unsolicited events (e.g. 'log') or '*' for all. No return.
24 | // The handler takes two parameter, the data object, and whether any previous handler returned true.
25 | // - send(object): Send an event to PPSSPP. Returns a promise resolving to the data object.
26 | // Note that not all PPSSPP events have a response. You may receive null or it may never resolve.
27 | //
28 | // Properties:
29 | // - onError: Set this to a function receiving (message, level) for errors.
30 | // - onClose: Set this to a function with no parameters called when on disconnect.
31 | //
32 | // For documentation on the actual events for send() and listen(), look in PPSSPP's source code.
33 | // Start with the comment in Core/Debugger/WebSocket.cpp.
34 | // Individual files in Core/Debugger/WebSocket/ also have comments describing the events and parameters.
35 |
36 | // We fast-resolve these to avoid confusion.
37 | const NoResponseEvents = [
38 | 'cpu.stepping',
39 | 'cpu.resume',
40 | ];
41 |
42 | const ErrorLevels = {
43 | NOTICE: 1,
44 | ERROR: 2,
45 | WARN: 3,
46 | INFO: 4,
47 | DEBUG: 5,
48 | VERBOSE: 6,
49 | };
50 |
51 | const PPSSPP_MATCH_API = '//report.ppsspp.org/match/list';
52 | const PPSSPP_SUB_PROTOCOL = 'debugger.ppsspp.org';
53 | const PPSSPP_DEFAULT_PATH = '/debugger';
54 |
55 | export default class PPSSPP {
56 | autoConnect() {
57 | if (this.socket_ !== null) {
58 | return Promise.reject(new Error('Already connected, disconnect first'));
59 | }
60 |
61 | let err = new Error('Couldn\'t connect');
62 |
63 | return fetch(PPSSPP_MATCH_API).then((response) => {
64 | return response.json();
65 | }).then((listing) => {
66 | return this.tryNextEndpoint_(listing);
67 | }).then(this.setupSocket_.bind(this), specificError => {
68 | err.message = specificError.message;
69 | err.originalError = specificError;
70 | throw err;
71 | });
72 | }
73 |
74 | connect(uri) {
75 | if (this.socket_ !== null) {
76 | return Promise.reject(new Error('Already connected, disconnect first'));
77 | }
78 |
79 | // In case it fails, prepare the error (and stack trace) now.
80 | let err = new Error('Couldn\'t connect to ' + uri);
81 |
82 | return new Promise((resolve, reject) => {
83 | const possibleSocket = new WebSocket(uri, PPSSPP_SUB_PROTOCOL);
84 |
85 | possibleSocket.onopen = () => {
86 | this.socket_ = possibleSocket;
87 | resolve();
88 | };
89 | possibleSocket.onclose = () => {
90 | reject(err);
91 | };
92 | }).then(this.setupSocket_.bind(this));
93 | }
94 |
95 | disconnect() {
96 | if (this.socket_ === null) {
97 | throw new Error('Not connected');
98 | }
99 |
100 | this.failAllPending_('Disconnected from PPSSPP');
101 |
102 | this.socket_.close(1000);
103 | this.socket_ = null;
104 | }
105 |
106 | listen(eventName, handler) {
107 | if (!(eventName in this.listeners_)) {
108 | this.listeners_[eventName] = [];
109 | }
110 |
111 | this.listeners_[eventName].push(handler);
112 |
113 | return {
114 | remove: () => {
115 | const list = this.listeners_[eventName];
116 | const index = list.indexOf(handler);
117 | if (index !== -1) {
118 | list.splice(index, 1);
119 | }
120 | },
121 | };
122 | }
123 |
124 | send(data) {
125 | if (this.socket_ === null) {
126 | return Promise.reject(new Error('Not connected'));
127 | }
128 |
129 | // Prepare a stack trace now, not when resolving (since that will trace to the onmessage.)
130 | let err = new Error('PPSSPP returned an error');
131 |
132 | // Avoid confusion by resolving immediately for known no-response events.
133 | if (NoResponseEvents.includes(data.event)) {
134 | this.socket_.send(JSON.stringify(data));
135 | return Promise.resolve(null);
136 | }
137 |
138 | return new Promise((resolve, reject) => {
139 | const ticket = this.makeTicket_();
140 |
141 | this.pendingTickets_[ticket] = function (result) {
142 | if (result.event === 'error') {
143 | err.name = 'DebuggerError';
144 | err.message = result.message;
145 | err.originalMessage = result;
146 | reject(err);
147 | } else {
148 | resolve(result);
149 | }
150 | };
151 |
152 | this.socket_.send(JSON.stringify({ ...data, ticket }));
153 | });
154 | }
155 |
156 | setupSocket_() {
157 | this.socket_.onclose = () => {
158 | if (this.onClose) {
159 | this.onClose();
160 | }
161 |
162 | this.failAllPending_('PPSSPP disconnected');
163 | this.socket_ = null;
164 | };
165 |
166 | this.socket_.onmessage = (e) => {
167 | try {
168 | const data = JSON.parse(e.data);
169 |
170 | if (data.event === 'error') {
171 | this.handleError_(data.message, data.level);
172 | }
173 |
174 | let handled = false;
175 | if ('ticket' in data) {
176 | handled = this.handleTicket_(data.ticket, data);
177 | }
178 | this.handleMessage_(data, handled);
179 | } catch (err) {
180 | this.handleError_('Failed to parse message from PPSSPP: ' + err.message, ErrorLevels.ERROR);
181 | }
182 | };
183 | }
184 |
185 | tryNextEndpoint_(listing) {
186 | return new Promise((resolve, reject) => {
187 | if (!listing || listing.length === 0) {
188 | return reject(new Error('Couldn\'t connect automatically. Is PPSSPP connected to the same network?'));
189 | }
190 |
191 | const endpoint = 'ws://' + listing[0].ip + ':' + listing[0].p + PPSSPP_DEFAULT_PATH;
192 | return this.connect(endpoint).then(resolve, err => {
193 | if (listing.length > 1) {
194 | resolve(this.tryNextEndpoint_(listing.slice(1)));
195 | } else {
196 | reject(err);
197 | }
198 | });
199 | });
200 | }
201 |
202 | handleTicket_(ticket, data) {
203 | if (ticket in this.pendingTickets_) {
204 | const handler = this.pendingTickets_[data.ticket];
205 | delete this.pendingTickets_[data.ticket];
206 |
207 | handler(data);
208 | return true;
209 | }
210 |
211 | this.handleError_('Received mismatched ticket: ' + JSON.stringify(data), ErrorLevels.ERROR);
212 | return false;
213 | }
214 |
215 | handleMessage_(data, handled) {
216 | handled = this.executeHandlers_(data.event, data, handled);
217 | this.executeHandlers_('*', data, handled);
218 | }
219 |
220 | executeHandlers_(name, data, handled) {
221 | if (name in this.listeners_) {
222 | for (let handler of this.listeners_[name]) {
223 | if (handler(data, handled)) {
224 | handled = true;
225 | }
226 | }
227 | }
228 | return handled;
229 | }
230 |
231 | handleError_(message, level) {
232 | if (this.onError) {
233 | this.onError(message, level);
234 | } else if (level === undefined || Number(level) === ErrorLevels.ERROR) {
235 | console.error(message);
236 | } else {
237 | console.log(message);
238 | }
239 | }
240 |
241 | makeTicket_() {
242 | let ticket = Math.random().toString(36).substr(2);
243 | if (ticket in this.pendingTickets_) {
244 | return this.makeTicket_();
245 | }
246 | return ticket;
247 | }
248 |
249 | failAllPending_(message) {
250 | const data = { event: 'error', message, level: ErrorLevels.ERROR };
251 |
252 | for (let ticket in this.pendingTickets_) {
253 | if (this.pendingTickets_.hasOwnProperty(ticket)) {
254 | this.pendingTickets_[ticket](data);
255 | }
256 | }
257 | this.pendingTickets_ = {};
258 | }
259 |
260 | onError = null;
261 | onClose = null;
262 |
263 | socket_ = null;
264 | pendingTickets_ = {};
265 | listeners_ = {};
266 | }
267 |
268 | export { ErrorLevels };
269 |
--------------------------------------------------------------------------------
/src/utils/clipboard.js:
--------------------------------------------------------------------------------
1 | // We set this to false when we trigger one.
2 | let userInitiated = true;
3 |
4 | let listeners = [];
5 |
6 | function createScratchpad() {
7 | const div = document.createElement('div');
8 | div.id = 'clipboard-scratchpad';
9 | div.style.width = 1;
10 | div.style.height = 1;
11 | div.style.opacity = 0;
12 | div.style.position = 'absolute';
13 | div.style.top = 0;
14 | div.style.left = 0;
15 | div.style.pointerEvents = 'none';
16 | div.style.whiteSpace = 'pre';
17 | document.body.appendChild(div);
18 |
19 | return div;
20 | }
21 |
22 | function getScratchpad() {
23 | const div = document.getElementById('clipboard-scratchpad');
24 | if (!div) {
25 | return createScratchpad();
26 | }
27 | return div;
28 | }
29 |
30 | export function copyText(text) {
31 | userInitiated = false;
32 |
33 | try {
34 | const scratchpad = getScratchpad();
35 | scratchpad.textContent = text;
36 |
37 | const textNode = scratchpad.firstChild;
38 | const range = document.createRange();
39 | range.setStart(textNode, 0);
40 | range.setEnd(textNode, text.length);
41 |
42 | const selection = window.getSelection();
43 | const oldRange = selection.rangeCount !== 0 ? selection.getRangeAt(0) : null;
44 |
45 | selection.removeAllRanges();
46 | selection.addRange(range);
47 |
48 | try {
49 | document.execCommand('copy');
50 | } catch (e) {
51 | window.prompt('Use Ctrl-C or Command-C to copy', text);
52 | }
53 |
54 | selection.removeAllRanges();
55 | if (oldRange) {
56 | selection.addRange(oldRange);
57 | }
58 | } finally {
59 | userInitiated = true;
60 | }
61 | }
62 |
63 | export function listenCopy(selector, handler) {
64 | listeners.push({ selector, handler });
65 | }
66 |
67 | export function forgetCopy(selector, handler) {
68 | listeners = listeners.filter(l => l.selector !== selector && l.handler !== handler);
69 | }
70 |
71 | if (!Element.prototype.matches) {
72 | Element.prototype.matches = Element.prototype.msMatchesSelector;
73 | }
74 |
75 | function hasClosestMatch(node, selector) {
76 | while (node && node !== document) {
77 | if (node.matches(selector)) {
78 | return true;
79 | }
80 | node = node.parentNode;
81 | }
82 |
83 | return false;
84 | }
85 |
86 | document.addEventListener('copy', (ev) => {
87 | const range = document.getSelection().rangeCount !== 0 ? document.getSelection().getRangeAt(0) : null;
88 | if ((range && !range.collapsed) || !userInitiated) {
89 | // Skip if the user has anything actually selected, or if we triggered this 'copy' command.
90 | return;
91 | }
92 |
93 | for (let listener of listeners) {
94 | if (!hasClosestMatch(document.activeElement, listener.selector)) {
95 | continue;
96 | }
97 |
98 | const result = listener.handler(ev);
99 | if (result) {
100 | // Unfortunate, but newline conversion isn't automatic...
101 | if (/win/i.test(window.navigator.platform)) {
102 | ev.clipboardData.setData('text/plain', result.replace(/\n/g, '\r\n'));
103 | } else {
104 | ev.clipboardData.setData('text/plain', result);
105 | }
106 | ev.preventDefault();
107 | }
108 | }
109 | });
110 |
--------------------------------------------------------------------------------
/src/utils/dom.js:
--------------------------------------------------------------------------------
1 | function isSubsetBounds(bounds, parentBounds) {
2 | if (bounds.top < parentBounds.top || bounds.left < parentBounds.left) {
3 | return false;
4 | }
5 | if (bounds.bottom > parentBounds.bottom || bounds.right > parentBounds.right) {
6 | return false;
7 | }
8 | return true;
9 | }
10 |
11 | export function isInView(node) {
12 | const windowBounds = { top: 0, left: 0, bottom: window.innerHeight, right: window.innerWidth };
13 | const rect = node.getBoundingClientRect();
14 | if (!isSubsetBounds(rect, windowBounds)) {
15 | return false;
16 | }
17 |
18 | let parentNode = node;
19 | while ((parentNode = parentNode.parentNode) !== null) {
20 | if (parentNode instanceof HTMLElement) {
21 | const style = window.getComputedStyle(parentNode);
22 | const overflows = style.overflow + style.overflowX + style.overflowY;
23 | if (style.position === 'static' && overflows.indexOf('auto') === -1 && overflows.indexOf('scroll') === -1) {
24 | continue;
25 | }
26 |
27 | if (!isSubsetBounds(rect, parentNode.getBoundingClientRect())) {
28 | return false;
29 | }
30 | }
31 | }
32 |
33 | return true;
34 | }
35 |
36 | export function ensureInView(node, options) {
37 | if (!isInView(node)) {
38 | node.scrollIntoView(options);
39 | return true;
40 | }
41 |
42 | return false;
43 | }
44 |
45 | let contextMenuStatus = false;
46 |
47 | window.addEventListener('REACT_CONTEXTMENU_SHOW', (ev) => {
48 | contextMenuStatus = true;
49 | });
50 | window.addEventListener('REACT_CONTEXTMENU_HIDE', (ev) => {
51 | contextMenuStatus = false;
52 | });
53 |
54 | export function hasContextMenu() {
55 | return contextMenuStatus;
56 | }
57 |
--------------------------------------------------------------------------------
/src/utils/format.js:
--------------------------------------------------------------------------------
1 | export function toString08X(val) {
2 | const hex = val.toString(16).toUpperCase();
3 | return ('00000000' + hex).substr(-8);
4 | }
5 |
6 | export function toString04X(val) {
7 | const hex = val.toString(16).toUpperCase();
8 | return ('0000' + hex).substr(-4);
9 | }
10 |
11 | export function toString02X(val) {
12 | const hex = val.toString(16).toUpperCase();
13 | return ('00' + hex).substr(-2);
14 | }
15 |
--------------------------------------------------------------------------------
/src/utils/game.js:
--------------------------------------------------------------------------------
1 | import listeners from '../utils/listeners.js';
2 |
3 | /**
4 | * @typedef GameStatusValues
5 | * @property {boolean} connected Whether a connection to PPSSPP is active.
6 | * @property {boolean} stepping Whether CPU is in stepping / break mode.
7 | * @property {boolean} paused Whether game emulation is paused.
8 | * @property {boolean} started Whether game emulation has been started yet.
9 | * @property {number} pc Integer PC value, or 0.
10 | * @property {number} currentThread Thread ID of active thread or undefined.
11 | * @property {Function} setState Method to update state.
12 | */
13 |
14 | class GameStatus {
15 | /**
16 | * @type {GameStatusValues}
17 | */
18 | state = {
19 | id: null,
20 | connected: false,
21 | stepping: false,
22 | paused: true,
23 | started: false,
24 | pc: 0,
25 | currentThread: undefined,
26 | setState: undefined,
27 | };
28 | stateListeners = [];
29 | listeners_;
30 | ppsspp_;
31 |
32 | init(ppsspp) {
33 | this.state.setState = values => {
34 | this.setState(values);
35 | };
36 |
37 | this.ppsspp_ = ppsspp;
38 | this.listeners_ = listeners.listen({
39 | 'connection': () => this.onConnection(),
40 | 'connection.change': (connected) => this.onConnectionChange(connected),
41 | 'cpu.stepping': (data) => this.onStepping(data),
42 | 'cpu.resume': () => this.setState({ stepping: false }),
43 | 'game.start': ({ game }) => {
44 | this.setState({ id: game && game.id, started: true, paused: false });
45 | },
46 | 'game.status': ({ game }) => {
47 | this.setState({ id: game && game.id });
48 | },
49 | 'game.quit': () => {
50 | this.setState({
51 | started: false,
52 | stepping: false,
53 | paused: true,
54 | pc: 0,
55 | currentThread: undefined,
56 | });
57 | },
58 | 'game.pause': () => this.setState({ paused: true }),
59 | 'game.resume': () => this.setState({ paused: false }),
60 | 'cpu.setReg': (result) => {
61 | if (result.category === 0 && result.register === 32) {
62 | this.setState({ pc: result.uintValue });
63 | }
64 | },
65 | 'cpu.getAllRegs': (result) => {
66 | const pc = result.categories[0].uintValues[32];
67 | this.setState({ pc });
68 | },
69 | });
70 | }
71 |
72 | shutdown() {
73 | listeners.forget(this.listeners_);
74 | this.listeners_ = null;
75 | }
76 |
77 | onConnectionChange(connected) {
78 | // On any reconnect, assume paused until proven otherwise.
79 | this.setState({ connected, started: false, stepping: false, paused: true });
80 | if (!connected) {
81 | this.setState({ currentThread: undefined });
82 | }
83 | }
84 |
85 | onConnection() {
86 | // Update the status of this connection immediately too.
87 | this.ppsspp_.send({ event: 'cpu.status' }).then((result) => {
88 | const { stepping, paused, pc } = result;
89 | const started = pc !== 0 || stepping;
90 |
91 | this.setState({ connected: true, started, stepping, paused, pc });
92 | }, (err) => {
93 | this.setState({ stepping: false, paused: true });
94 | });
95 | }
96 |
97 | onStepping(data) {
98 | this.setState({
99 | stepping: true,
100 | pc: data.pc,
101 | });
102 | }
103 |
104 | listenState(callback) {
105 | this.stateListeners.push(callback);
106 | }
107 |
108 | setState(values) {
109 | let changed = false;
110 | for (let k in values) {
111 | if (values[k] !== this.state[k]) {
112 | changed = true;
113 | }
114 | }
115 |
116 | if (changed) {
117 | this.state = { ...this.state, ...values };
118 | for (let callback of this.stateListeners) {
119 | callback(this.state);
120 | }
121 | }
122 | }
123 | }
124 |
125 | export default GameStatus;
126 |
--------------------------------------------------------------------------------
/src/utils/listeners.js:
--------------------------------------------------------------------------------
1 | class Listeners {
2 | constructor() {
3 | this.changeCallbacks_ = [];
4 | this.ppsspp_ = null;
5 | this.connected_ = false;
6 | }
7 |
8 | init(ppsspp) {
9 | this.ppsspp_ = ppsspp;
10 | }
11 |
12 | // Change the active connection.
13 | change(connected) {
14 | this.connected_ = connected;
15 | for (let callback of this.changeCallbacks_) {
16 | callback(connected);
17 | }
18 | }
19 |
20 | onConnectionChange(callback) {
21 | this.changeCallbacks_.push(callback);
22 | return {
23 | remove: () => {
24 | const index = this.changeCallbacks_.indexOf(callback);
25 | if (index !== -1) {
26 | this.changeCallbacks_.splice(index, 1);
27 | }
28 | },
29 | };
30 | }
31 |
32 | onConnection(callback) {
33 | const changeCallback = (connected) => {
34 | if (connected) {
35 | callback(true);
36 | }
37 | };
38 | if (this.connected_) {
39 | callback(true);
40 | }
41 |
42 | return this.onConnectionChange(changeCallback);
43 | }
44 |
45 | // Listen for events (and re-register as necessary.)
46 | listen(handlers) {
47 | let result = [];
48 | for (let name in handlers) {
49 | if (handlers.hasOwnProperty(name)) {
50 | let listener;
51 | if (name === 'connection') {
52 | listener = this.onConnection(handlers[name]);
53 | } else if (name === 'connection.change') {
54 | listener = this.onConnectionChange(handlers[name]);
55 | } else {
56 | listener = this.ppsspp_.listen(name, handlers[name]);
57 | }
58 | result.push(listener);
59 | }
60 | }
61 | return result;
62 | }
63 |
64 | forget(listeners) {
65 | for (let listener of listeners) {
66 | listener.remove();
67 | }
68 | }
69 | }
70 |
71 | const listeners = new Listeners();
72 | export default listeners;
73 |
--------------------------------------------------------------------------------
/src/utils/logger.js:
--------------------------------------------------------------------------------
1 | import listeners from './listeners';
2 | import { useEffect, useState } from 'react';
3 |
4 | const MAX_LINES = 5000;
5 |
6 | class Logger {
7 | constructor() {
8 | this.id_ = 0;
9 | this.items_ = [];
10 | this.changeListeners_ = [];
11 | }
12 |
13 | init(ppsspp) {
14 | ppsspp.onError = (message, level) => {
15 | const newItem = { message: message + '\n', level };
16 | this.addLogItem(newItem);
17 | };
18 |
19 | listeners.listen({
20 | 'log': this.onLogEvent.bind(this),
21 | });
22 | }
23 |
24 | onLogEvent(data) {
25 | const newItem = { ...data };
26 | this.addLogItem(newItem);
27 | }
28 |
29 | addLogItem(newItem) {
30 | const id = ++this.id_;
31 | const itemWithId = { id, ...newItem };
32 | this.items_ = this.items_.concat([itemWithId ]).slice(-MAX_LINES);
33 | for (let listener of this.changeListeners_) {
34 | listener(this.items_);
35 | }
36 | }
37 |
38 | listen(listener) {
39 | if (!this.changeListeners_.includes(listener)) {
40 | this.changeListeners_.push(listener);
41 | listener(this.items_); // Make sure listener receives initial state
42 | }
43 | }
44 |
45 | forget(listener) {
46 | this.changeListeners_ = this.changeListeners_.filter(e => e !== listener);
47 | }
48 | }
49 |
50 | const logger = new Logger();
51 | export default logger;
52 |
53 | export function useLogItems() {
54 | const [logItems, setLogItems] = useState([]);
55 |
56 | useEffect(() => {
57 | function changeListener(items) {
58 | setLogItems(items);
59 | }
60 |
61 | logger.listen(changeListener);
62 | return () => logger.forget(changeListener);
63 | }, []);
64 |
65 | return logItems;
66 | }
67 |
--------------------------------------------------------------------------------
/src/utils/persist.js:
--------------------------------------------------------------------------------
1 | import listeners from '../utils/listeners.js';
2 |
3 | export function setAutoPersistBreakpoints(flag) {
4 | if (flag) {
5 | localStorage.ppdbg_persist_breakpoints = '1';
6 | } else {
7 | delete localStorage.ppdbg_persist_breakpoints;
8 | }
9 | }
10 |
11 | export function isAutoPersistingBreakpoints() {
12 | try {
13 | return localStorage.ppdbg_persist_breakpoints === '1';
14 | } catch {
15 | return false;
16 | }
17 | }
18 |
19 | export function cleanBreakpointForPersist(data) {
20 | return {
21 | ...data,
22 | // These are informational.
23 | code: undefined,
24 | symbol: undefined,
25 | hits: undefined,
26 | // Can't send nulls, so remove here.
27 | condition: data.condition === null ? undefined : data.condition,
28 | logFormat: data.logFormat === null ? undefined : data.logFormat,
29 | };
30 | }
31 |
32 | class BreakpointPersister {
33 | ppsspp_;
34 | updateTimeouts_ = {};
35 | listeners_ = null;
36 | gameID_ = null;
37 |
38 | init(ppsspp) {
39 | this.ppsspp_ = ppsspp;
40 | this.listeners_ = listeners.listen({
41 | 'game.start': ({ game }) => this.applySaved(game),
42 | 'game.status': ({ game }) => this.applySaved(game),
43 | 'game.quit': () => this.gameID_ = null,
44 | 'cpu.breakpoint.add': () => this.scheduleUpdate('cpu'),
45 | 'cpu.breakpoint.update': () => this.scheduleUpdate('cpu'),
46 | 'cpu.breakpoint.remove': () => this.scheduleUpdate('cpu'),
47 | 'memory.breakpoint.add': () => this.scheduleUpdate('memory'),
48 | 'memory.breakpoint.update': () => this.scheduleUpdate('memory'),
49 | 'memory.breakpoint.remove': () => this.scheduleUpdate('memory'),
50 | 'cpu.breakpoint.list': ({ breakpoints }) => this.processUpdate('cpu', breakpoints),
51 | 'memory.breakpoint.list': ({ breakpoints }) => this.processUpdate('memory', breakpoints),
52 | });
53 | }
54 |
55 | shutdown() {
56 | listeners.forget(this.listeners_);
57 | this.listeners_ = null;
58 | }
59 |
60 | applySaved(game) {
61 | if (!isAutoPersistingBreakpoints() || !game.id) {
62 | return;
63 | }
64 |
65 | this.gameID_ = game.id;
66 |
67 | this.restoreBreakpoints('cpu');
68 | this.restoreBreakpoints('memory');
69 | }
70 |
71 | restoreBreakpoints(type) {
72 | if (!isAutoPersistingBreakpoints() || !this.gameID_) {
73 | return;
74 | }
75 |
76 | const key = 'ppdbg_breakpoints_' + type + '_' + this.gameID_;
77 | try {
78 | const list = JSON.parse(localStorage[key]);
79 | for (let bp of list) {
80 | this.ppsspp_.send({
81 | event: type === 'cpu' ? 'cpu.breakpoint.add' : 'memory.breakpoint.add',
82 | ...bp,
83 | }).then(() => null, (err) => {
84 | console.error('Unable to restore breakpoint', err);
85 | });
86 | }
87 | } catch (err) {
88 | console.error('Unable to restore breakpoints', err);
89 | }
90 | }
91 |
92 | scheduleUpdate(type) {
93 | if (!isAutoPersistingBreakpoints() || !this.gameID_) {
94 | return;
95 | }
96 | // Already scheduled? Leave it.
97 | if (this.updateTimeouts_[type]) {
98 | return;
99 | }
100 |
101 | // Let's try to debounce updates in case it's immediately listed by a component.
102 | this.updateTimeouts_[type] = setTimeout(() => {
103 | // Ignore the result - our listener will handle it.
104 | this.ppsspp_.send({
105 | event: type === 'cpu' ? 'cpu.breakpoint.list' : 'memory.breakpoint.list',
106 | }).then(() => null, () => null);
107 | delete this.updateTimeouts_[type];
108 | }, 100);
109 | }
110 |
111 | processUpdate(type, breakpoints) {
112 | if (!isAutoPersistingBreakpoints() || !this.gameID_) {
113 | return;
114 | }
115 |
116 | const key = 'ppdbg_breakpoints_' + type + '_' + this.gameID_;
117 | try {
118 | // Store, but remove some properties it's not useful to persist.
119 | localStorage[key] = JSON.stringify(breakpoints.map(cleanBreakpointForPersist));
120 | } catch (err) {
121 | console.error('Unable to save breakpoints', err);
122 | }
123 | }
124 | }
125 |
126 | export const breakpointPersister = new BreakpointPersister();
127 |
--------------------------------------------------------------------------------
/src/utils/timeouts.js:
--------------------------------------------------------------------------------
1 | export class Timeout {
2 | handle;
3 | func;
4 | ms;
5 |
6 | constructor(func, ms) {
7 | this.func = func;
8 | this.ms = ms;
9 | }
10 |
11 | start() {
12 | this.cancel();
13 | this.handle = setTimeout(() => {
14 | this.handle = null;
15 | this.func();
16 | }, this.ms);
17 | }
18 |
19 | reset(func) {
20 | this.func = func;
21 | }
22 |
23 | runEarly() {
24 | if (this.handle) {
25 | this.cancel();
26 | this.func();
27 | }
28 | }
29 |
30 | cancel() {
31 | if (this.handle !== null) {
32 | clearTimeout(this.handle);
33 | this.handle = null;
34 | }
35 | }
36 | }
37 |
--------------------------------------------------------------------------------