├── .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 | 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 | PPSSPP 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 | 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 |
73 | 74 | 75 | {this.renderMemory()} 76 | {this.renderExecute()} 77 | 78 | 79 | 80 | 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 | 125 | 126 | 127 | 128 | 129 | 130 | 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 |
BreakTypeOffsetSize/LabelOpcodeConditionHits
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 |
18 | 19 | 20 | 21 |
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 | 29 | 30 |
31 |
32 | 35 | 36 |
37 |
38 | 39 | 40 | 41 | 42 |
43 |
44 | 45 | 46 |
47 |
48 | 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 | 73 | ); 74 | } 75 | 76 | renderThread(thread) { 77 | const classes = 'DisasmButtons__thread' + (thread.isCurrent ? ' DisasmButtons__thread--current' : ''); 78 | return ( 79 | 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 | 41 |
42 | 43 | {this.renderAddress(line)} 44 | {this.highlight(line.name, '', line.params)} 45 | {this.highlightParams(line.params, line.name, '')}{this.renderConditional(line)} 46 |
47 |
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 | 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 | 41 | 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 | 49 |
{name}
50 |
51 | {this.format(reg)} 52 |
53 |
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 | 118 | 119 |
120 | 121 | 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 |
37 |
38 | Display as: 39 | 40 | 41 |
42 |
43 | 44 |
45 |
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 |
36 | 37 |
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 |
24 | 25 | 26 |
27 |
28 | 29 |
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 | 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 | 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 | 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 |
9 | {props.heading ?

{props.heading}

: null} 10 | {props.children} 11 |
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 ? : null; 24 | const cancelButton = props.onCancel ? : 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 |
26 | 27 | 34 | {this.renderPCButtons()} 35 |
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 | 47 | 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 | --------------------------------------------------------------------------------