diff --git a/.gitignore b/.gitignore index 0796447d..48ce7773 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ __pycache__/ .ruff_cache/ build/ dist/ +.env *.gen.h *.gen.lua diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e75ddb2f..84fee85e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -8,7 +8,9 @@ repos: - id: check-toml - id: check-yaml - id: end-of-file-fixer + exclude: \.patch$ # a blank line of context is one space - id: trailing-whitespace + exclude: \.patch$ # a blank line of context is one space - repo: https://github.com/astral-sh/ruff-pre-commit rev: v0.16.9 @@ -17,6 +19,11 @@ repos: args: [--fix] - id: ruff-format + - repo: https://github.com/shellcheck-py/shellcheck-py + rev: v0.11.0.1 + hooks: + - id: shellcheck + - repo: https://github.com/fsfe/reuse-tool rev: v6.2.0 hooks: diff --git a/LICENSES/GPL-2.0-or-later.txt b/LICENSES/GPL-2.0-or-later.txt new file mode 100644 index 00000000..17cb2864 --- /dev/null +++ b/LICENSES/GPL-2.0-or-later.txt @@ -0,0 +1,117 @@ +GNU GENERAL PUBLIC LICENSE +Version 2, June 1991 + +Copyright (C) 1989, 1991 Free Software Foundation, Inc. +51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA + +Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. + +Preamble + +The licenses for most software are designed to take away your freedom to share and change it. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change free software--to make sure the software is free for all its users. This General Public License applies to most of the Free Software Foundation's software and to any other program whose authors commit to using it. (Some other Free Software Foundation software is covered by the GNU Lesser General Public License instead.) You can apply it to your programs, too. + +When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for this service if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs; and that you know you can do these things. + +To protect your rights, we need to make restrictions that forbid anyone to deny you these rights or to ask you to surrender the rights. These restrictions translate to certain responsibilities for you if you distribute copies of the software, or if you modify it. + +For example, if you distribute copies of such a program, whether gratis or for a fee, you must give the recipients all the rights that you have. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights. + +We protect your rights with two steps: (1) copyright the software, and (2) offer you this license which gives you legal permission to copy, distribute and/or modify the software. + +Also, for each author's protection and ours, we want to make certain that everyone understands that there is no warranty for this free software. If the software is modified by someone else and passed on, we want its recipients to know that what they have is not the original, so that any problems introduced by others will not reflect on the original authors' reputations. + +Finally, any free program is threatened constantly by software patents. We wish to avoid the danger that redistributors of a free program will individually obtain patent licenses, in effect making the program proprietary. To prevent this, we have made it clear that any patent must be licensed for everyone's free use or not licensed at all. + +The precise terms and conditions for copying, distribution and modification follow. + +TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + +0. This License applies to any program or other work which contains a notice placed by the copyright holder saying it may be distributed under the terms of this General Public License. The "Program", below, refers to any such program or work, and a "work based on the Program" means either the Program or any derivative work under copyright law: that is to say, a work containing the Program or a portion of it, either verbatim or with modifications and/or translated into another language. (Hereinafter, translation is included without limitation in the term "modification".) Each licensee is addressed as "you". + +Activities other than copying, distribution and modification are not covered by this License; they are outside its scope. The act of running the Program is not restricted, and the output from the Program is covered only if its contents constitute a work based on the Program (independent of having been made by running the Program). Whether that is true depends on what the Program does. + +1. You may copy and distribute verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice and disclaimer of warranty; keep intact all the notices that refer to this License and to the absence of any warranty; and give any other recipients of the Program a copy of this License along with the Program. + +You may charge a fee for the physical act of transferring a copy, and you may at your option offer warranty protection in exchange for a fee. + +2. You may modify your copy or copies of the Program or any portion of it, thus forming a work based on the Program, and copy and distribute such modifications or work under the terms of Section 1 above, provided that you also meet all of these conditions: + + a) You must cause the modified files to carry prominent notices stating that you changed the files and the date of any change. + + b) You must cause any work that you distribute or publish, that in whole or in part contains or is derived from the Program or any part thereof, to be licensed as a whole at no charge to all third parties under the terms of this License. + + c) If the modified program normally reads commands interactively when run, you must cause it, when started running for such interactive use in the most ordinary way, to print or display an announcement including an appropriate copyright notice and a notice that there is no warranty (or else, saying that you provide a warranty) and that users may redistribute the program under these conditions, and telling the user how to view a copy of this License. (Exception: if the Program itself is interactive but does not normally print such an announcement, your work based on the Program is not required to print an announcement.) + +These requirements apply to the modified work as a whole. If identifiable sections of that work are not derived from the Program, and can be reasonably considered independent and separate works in themselves, then this License, and its terms, do not apply to those sections when you distribute them as separate works. But when you distribute the same sections as part of a whole which is a work based on the Program, the distribution of the whole must be on the terms of this License, whose permissions for other licensees extend to the entire whole, and thus to each and every part regardless of who wrote it. + +Thus, it is not the intent of this section to claim rights or contest your rights to work written entirely by you; rather, the intent is to exercise the right to control the distribution of derivative or collective works based on the Program. + +In addition, mere aggregation of another work not based on the Program with the Program (or with a work based on the Program) on a volume of a storage or distribution medium does not bring the other work under the scope of this License. + +3. You may copy and distribute the Program (or a work based on it, under Section 2) in object code or executable form under the terms of Sections 1 and 2 above provided that you also do one of the following: + + a) Accompany it with the complete corresponding machine-readable source code, which must be distributed under the terms of Sections 1 and 2 above on a medium customarily used for software interchange; or, + + b) Accompany it with a written offer, valid for at least three years, to give any third party, for a charge no more than your cost of physically performing source distribution, a complete machine-readable copy of the corresponding source code, to be distributed under the terms of Sections 1 and 2 above on a medium customarily used for software interchange; or, + + c) Accompany it with the information you received as to the offer to distribute corresponding source code. (This alternative is allowed only for noncommercial distribution and only if you received the program in object code or executable form with such an offer, in accord with Subsection b above.) + +The source code for a work means the preferred form of the work for making modifications to it. For an executable work, complete source code means all the source code for all modules it contains, plus any associated interface definition files, plus the scripts used to control compilation and installation of the executable. However, as a special exception, the source code distributed need not include anything that is normally distributed (in either source or binary form) with the major components (compiler, kernel, and so on) of the operating system on which the executable runs, unless that component itself accompanies the executable. + +If distribution of executable or object code is made by offering access to copy from a designated place, then offering equivalent access to copy the source code from the same place counts as distribution of the source code, even though third parties are not compelled to copy the source along with the object code. + +4. You may not copy, modify, sublicense, or distribute the Program except as expressly provided under this License. Any attempt otherwise to copy, modify, sublicense or distribute the Program is void, and will automatically terminate your rights under this License. However, parties who have received copies, or rights, from you under this License will not have their licenses terminated so long as such parties remain in full compliance. + +5. You are not required to accept this License, since you have not signed it. However, nothing else grants you permission to modify or distribute the Program or its derivative works. These actions are prohibited by law if you do not accept this License. Therefore, by modifying or distributing the Program (or any work based on the Program), you indicate your acceptance of this License to do so, and all its terms and conditions for copying, distributing or modifying the Program or works based on it. + +6. Each time you redistribute the Program (or any work based on the Program), the recipient automatically receives a license from the original licensor to copy, distribute or modify the Program subject to these terms and conditions. You may not impose any further restrictions on the recipients' exercise of the rights granted herein. You are not responsible for enforcing compliance by third parties to this License. + +7. If, as a consequence of a court judgment or allegation of patent infringement or for any other reason (not limited to patent issues), conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot distribute so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not distribute the Program at all. For example, if a patent license would not permit royalty-free redistribution of the Program by all those who receive copies directly or indirectly through you, then the only way you could satisfy both it and this License would be to refrain entirely from distribution of the Program. + +If any portion of this section is held invalid or unenforceable under any particular circumstance, the balance of the section is intended to apply and the section as a whole is intended to apply in other circumstances. + +It is not the purpose of this section to induce you to infringe any patents or other property right claims or to contest validity of any such claims; this section has the sole purpose of protecting the integrity of the free software distribution system, which is implemented by public license practices. Many people have made generous contributions to the wide range of software distributed through that system in reliance on consistent application of that system; it is up to the author/donor to decide if he or she is willing to distribute software through any other system and a licensee cannot impose that choice. + +This section is intended to make thoroughly clear what is believed to be a consequence of the rest of this License. + +8. If the distribution and/or use of the Program is restricted in certain countries either by patents or by copyrighted interfaces, the original copyright holder who places the Program under this License may add an explicit geographical distribution limitation excluding those countries, so that distribution is permitted only in or among countries not thus excluded. In such case, this License incorporates the limitation as if written in the body of this License. + +9. The Free Software Foundation may publish revised and/or new versions of the General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. + +Each version is given a distinguishing version number. If the Program specifies a version number of this License which applies to it and "any later version", you have the option of following the terms and conditions either of that version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of this License, you may choose any version ever published by the Free Software Foundation. + +10. If you wish to incorporate parts of the Program into other free programs whose distribution conditions are different, write to the author to ask for permission. For software which is copyrighted by the Free Software Foundation, write to the Free Software Foundation; we sometimes make exceptions for this. Our decision will be guided by the two goals of preserving the free status of all derivatives of our free software and of promoting the sharing and reuse of software generally. + +NO WARRANTY + +11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + +12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. + +END OF TERMS AND CONDITIONS + +How to Apply These Terms to Your New Programs + +If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms. + +To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively convey the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. + + one line to give the program's name and an idea of what it does. Copyright (C) yyyy name of author + + This program is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation; either version 2 of the License, or (at your option) any later version. + + This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with this program; if not, write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. Also add information on how to contact you by electronic and paper mail. + +If the program is interactive, make it output a short notice like this when it starts in an interactive mode: + + Gnomovision version 69, Copyright (C) year name of author Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, the commands you use may be called something other than `show w' and `show c'; they could even be mouse-clicks or menu items--whatever suits your program. + +You should also get your employer (if you work as a programmer) or your school, if any, to sign a "copyright disclaimer" for the program, if necessary. Here is a sample; alter the names: + + Yoyodyne, Inc., hereby disclaims all copyright interest in the program `Gnomovision' (which makes passes at compilers) written by James Hacker. + +signature of Ty Coon, 1 April 1989 Ty Coon, President of Vice diff --git a/README.md b/README.md index 5414fc2e..773b3f3b 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ release ULES01213), and for porting monsters to it from Monster Hunter Portable | `packages/` | Python libraries: the debugger client, the file formats, the game (`mhfu`, which also holds the address map every language is generated from), the porter | | `framework/` | The PRX plugin and its Lua host | | `apps/` | The studio (monster and map editor), the HUD, the Blender extension | +| `ppsspp/` | PPSSPP patched for automation, its build, and a headless container to run it in | | `examples/` | Example mods | | `docs/` | Guides and format references | @@ -33,13 +34,14 @@ uv run pytest Tests that read game files take them from `MHFU_DATA` and `MHP3RD_DATA`, each a directory of extracted `DATA.BIN` files, and skip when the variable is unset; tests against a running PPSSPP -take `PPSSPP_BINARY` and `PPSSPP_GAME` the same way. Nothing from the games is committed here: -no ISO, no extracted files, no memory dumps. +take `PPSSPP_BINARY` and `PPSSPP_GAME` the same way (`ppsspp/build.sh` builds the patched one). +Nothing from the games is committed here: no ISO, no extracted files, no memory dumps. ## Licence MIT, except the file-format library and everything that bundles it, which are GPL-3.0-or-later -because they contain code derived from [mhff](https://github.com/svanheulen/mhff). Every file's -licence is machine-readable ([REUSE](https://reuse.software/); check with `reuse lint`). +because they contain code derived from [mhff](https://github.com/svanheulen/mhff), and the +PPSSPP patches, which are GPL-2.0-or-later like PPSSPP. Every file's licence is machine-readable +([REUSE](https://reuse.software/); check with `reuse lint`). Not affiliated with or endorsed by Capcom. Monster Hunter is a trademark of Capcom Co., Ltd. diff --git a/REUSE.toml b/REUSE.toml index 85a145d3..e632144e 100644 --- a/REUSE.toml +++ b/REUSE.toml @@ -5,3 +5,9 @@ version = 1 path = "**" SPDX-FileCopyrightText = "2026 sp00ktober" SPDX-License-Identifier = "MIT" + +# patches to PPSSPP carry its licence; the last matching annotation wins +[[annotations]] +path = "ppsspp/patches/**" +SPDX-FileCopyrightText = ["2026 sp00ktober", "PPSSPP Project"] +SPDX-License-Identifier = "GPL-2.0-or-later" diff --git a/packages/ppsspp-debug/README.md b/packages/ppsspp-debug/README.md index e5967e1c..7f3d164d 100644 --- a/packages/ppsspp-debug/README.md +++ b/packages/ppsspp-debug/README.md @@ -36,8 +36,9 @@ PPSSPP opens the debugger at startup only with `RemoteDebuggerOnStartup = True` | PPSSPP (1.20.4) | The client | |---|---| | Sends its log only to the newest debugger connection, and stops it for all when any connection closes | Log streams get a connection of their own, opened when the stream starts | -| Does not say why the CPU stopped | Matches a breakpoint stop by its pc; counts as a watchpoint hit any stop this client did not ask for | -| Answers `cpu.stepping` and `cpu.resume` with a broadcast, not a reply; pausing a paused CPU answers nothing | `pause()` and `resume()` wait for the broadcast, and are fine to call twice | +| Does not say why the CPU stopped | Matches a breakpoint stop by its pc, and counts as a watchpoint hit any stop this client did not ask for; uses the reason where PPSSPP gives one | +| Reports a hit of a breakpoint that does not stop only as a log line | Parses it; takes `cpu.breakpoint.hit` events instead from a PPSSPP that sends them | +| Answers `cpu.stepping` and `cpu.resume` with a broadcast, not a reply; pausing a paused CPU answers nothing | `pause()` and `resume()` wait for the broadcast, and are fine to call twice; `resume()` also takes a new stop as its answer | | Stops and restarts the CPU for every memory request while the game runs (about 10 ms each) | `paused()` makes each request about 0.3 ms; one `read()` of a whole struct costs one stop | | Keeps breakpoints armed after the script that set them has died | `breakpoint()` and `watchpoint()` remove theirs when the block ends, however it ends, and let the game run if a hit arrived that nobody took | | Reports errors as events, and an unknown event as an error | Raises `DebuggerError`, or `Unsupported` for an event this PPSSPP does not have | @@ -58,14 +59,16 @@ with LocalEmulator("/path/to/PPSSPPSDL", "/path/to/game.iso") as emu: ppsspp.wait_for_game() ``` -`DockerEmulator(container)` drives PPSSPP in the modkit's headless container through its -`ppsspp-ctl`, and relaunches it when it dies right after reporting ready. A `state=` launch -skips the cold boot, so PRX plugins do not load. +`DockerEmulator()` drives PPSSPP in the modkit's headless container +([`ppsspp/`](../../ppsspp)) through its `ppsspp-ctl`, relaunches it when it dies right after +reporting ready, and captures its display with `screenshot()`. A `state=` launch skips the cold +boot, so PRX plugins do not load. ## Patched PPSSPP -`save_state()`, `load_state()` and `speed()` need the PPSSPP patches that ship with the modkit; -stock PPSSPP raises `Unsupported`. +The modkit's PPSSPP build ([`ppsspp/`](../../ppsspp)) fixes the first two rows of the table. +`save_state()` and `load_state()` need it; `speed()` and `set_speed()` need it or a PPSSPP newer +than 1.20.4. Stock 1.20.4 raises `Unsupported` for them. `screenshot()` depends on the GPU backend: PPSSPP 1.20.4 with Vulkan answers "Could not download output" whether the game runs or is stopped. @@ -75,9 +78,15 @@ download output" whether the game runs or is stopped. `ppsspp_debug.testing.FakePPSSPP` is a debugger server with PPSSPP's quirks, for testing code that uses this client without an emulator. +`FakePPSSPP(patched=True)` behaves like the modkit's build, `FakePPSSPP(hit_events=True)` like a +PPSSPP that sends `cpu.breakpoint.hit`. + The live tests run against a real PPSSPP when `PPSSPP_BINARY` and `PPSSPP_GAME` are set, and print what they measure with `-s`: ```bash PPSSPP_BINARY=/path/to/PPSSPPSDL PPSSPP_GAME=/path/to/game.iso uv run pytest packages/ppsspp-debug -s ``` + +`PPSSPP_CONTAINER=ppsspp` in place of `PPSSPP_BINARY` runs them in the modkit's container, with +`PPSSPP_GAME` a path inside it. diff --git a/packages/ppsspp-debug/src/ppsspp_debug/__init__.py b/packages/ppsspp-debug/src/ppsspp_debug/__init__.py index ed62dfc4..87eb207b 100644 --- a/packages/ppsspp-debug/src/ppsspp_debug/__init__.py +++ b/packages/ppsspp-debug/src/ppsspp_debug/__init__.py @@ -29,6 +29,7 @@ RawEvent, Resumed, Screenshot, + Speed, Stepping, Watchpoint, parse_hit, @@ -55,6 +56,7 @@ "RawEvent", "Resumed", "Screenshot", + "Speed", "Stepping", "Stream", "SyncStream", diff --git a/packages/ppsspp-debug/src/ppsspp_debug/_client.py b/packages/ppsspp-debug/src/ppsspp_debug/_client.py index 21399511..abbcdeef 100644 --- a/packages/ppsspp-debug/src/ppsspp_debug/_client.py +++ b/packages/ppsspp-debug/src/ppsspp_debug/_client.py @@ -31,7 +31,7 @@ # broadcast.config switches a stream can turn on; "stepping" stays on because pause, resume and # stops rely on it, and "logger" stays off because the log comes over a connection of its own -_SWITCHES = ("game", "input") +_SWITCHES = ("game", "input", "breakpoint") T = TypeVar("T") @@ -39,6 +39,8 @@ def _switch(event: str) -> str | None: if event.startswith(("game.", "input.")): return event.partition(".")[0] + if event == "cpu.breakpoint.hit": + return "breakpoint" return None @@ -124,7 +126,8 @@ class _LogFeed: """A connection of its own for PPSSPP's log. PPSSPP 1.20.4 sends the log only to its newest debugger connection, and closing any - connection stops the log for all of them; a fresh connection owns it again. + connection stops the log for all of them; a fresh connection owns it again. Newer builds + send the log to every connection, where one of its own does no harm. """ def __init__(self, ws: ClientConnection, dispatch: Callable[[dict[str, Any]], None]) -> None: @@ -136,7 +139,7 @@ async def open( cls, uri: str, timeout: float, dispatch: Callable[[dict[str, Any]], None] ) -> _LogFeed: ws = await _ws_open(uri, timeout) - off = {"logger": False, "game": True, "input": True, "stepping": True} + off = {"logger": False, "game": True, "input": True, "stepping": True} # 1.20.4's kinds await ws.send(json.dumps({"event": "broadcast.config.set", "disallowed": off})) return cls(ws, dispatch) @@ -206,6 +209,7 @@ def __init__(self, ws: ClientConnection, host: str, port: int, timeout: float) - self._subs: set[_Subscription] = set() self._disallowed: dict[str, bool] | None = None self._configurable = True + self._hit_events = False self._log: _LogFeed | None = None self._log_users = 0 self._log_lock = asyncio.Lock() @@ -257,6 +261,7 @@ async def _open( try: reply = await client.request("version", name="ppsspp-debug", version=_VERSION) client.server = f"{reply['name']} {reply['version']}" + client._hit_events = await client._probe_hit_events() await client._sync_broadcasts() except BaseException: await client.close() @@ -364,15 +369,20 @@ async def _call( return self._check(event, msg) async def _command( - self, event: str, ack: str, sub: _Subscription | None = None, **params: Any + self, + event: str, + ack: str | tuple[str, ...], + sub: _Subscription | None = None, + **params: Any, ) -> dict[str, Any]: """Send an event PPSSPP answers with an `ack` broadcast rather than a reply. The ticket still matters: PPSSPP sends errors back ticketed. """ + acks = {ack} if isinstance(ack, str) else set(ack) async with AsyncExitStack() as stack: if sub is None: - sub = await stack.enter_async_context(self._subscribed({ack})) + sub = await stack.enter_async_context(self._subscribed(acks)) ticket, reply = self._ticket() broadcast = asyncio.ensure_future(sub.get()) stops = ack == "cpu.stepping" @@ -386,7 +396,8 @@ async def _command( self._check(event, reply.result()) return await broadcast except TimeoutError: - raise TimeoutError(f"{event}: no {ack} within {self.timeout:g}s") from None + expected = " or ".join(sorted(acks)) + raise TimeoutError(f"{event}: no {expected} within {self.timeout:g}s") from None except BaseException: if stops and not broadcast.done(): self._requested_stops = max(0, self._requested_stops - 1) @@ -431,13 +442,22 @@ async def _close_log(self) -> None: await self._log.close() self._log = None + async def _probe_hit_events(self) -> bool: + """Whether PPSSPP sends cpu.breakpoint.hit; if so, it stays off until a stream wants it.""" + try: + await self._call("broadcast.config.set", {"disallowed": {"breakpoint": True}}) + except DebuggerError: # 1.20.4 rejects a kind it does not have + return False + return True + async def _sync_broadcasts(self) -> bool: """Ask PPSSPP to send only the broadcast kinds an open stream wants. Returns whether a kind was switched on. """ + switches = _SWITCHES if self._hit_events else _SWITCHES[:-1] wanted = set().union(*(s.switches for s in self._subs)) - disallowed = {"logger": True} | {s: s not in wanted for s in _SWITCHES} + disallowed = {"logger": True} | {s: s not in wanted for s in switches} before = self._disallowed if not self._configurable or disallowed == before: return False @@ -447,7 +467,7 @@ async def _sync_broadcasts(self) -> bool: except Unsupported: self._configurable = False return False - return before is not None and any(before[s] and not disallowed[s] for s in _SWITCHES) + return before is not None and any(before[s] and not disallowed[s] for s in switches) async def request(self, event: str, /, **params: Any) -> dict[str, Any]: """Send any debugger event and return its reply, for events without a method here.""" @@ -539,7 +559,8 @@ async def paused(self) -> AsyncIterator[t.Stepping]: async def resume(self) -> None: """Let the CPU run; a running CPU is fine.""" try: - await self._command("cpu.resume", "cpu.resume") + # a breakpoint that trips again at once gets PPSSPP to report the new stop instead + await self._command("cpu.resume", ("cpu.resume", "cpu.stepping")) except DebuggerError as e: if e.message != "CPU not stepping": raise @@ -655,22 +676,28 @@ async def breakpoint( ) -> AsyncIterator[Stream[t.Hit]]: """An execution breakpoint for the block; yields its hits and removes it on exit. - With `stop`, each hit leaves the CPU stopped until `resume()`. Without, hits come from - the log and the game keeps running. + With `stop`, each hit leaves the CPU stopped until `resume()`. Without, the game keeps + running; a `log_format` message comes from the log. """ def match(event: t.Event) -> t.Hit | None: if isinstance(event, t.Stepping): - # PPSSPP 1.20.4 sends no reason with a breakpoint stop; the pc tells it apart - if not event.requested and event.pc == address: - return t.Hit("exec", address, event.pc, True) + if event.hit is not None: + return event.hit if _trips(event.hit, "exec", address) else None + if self._hit_events or event.requested or event.pc != address: + return None + # stock PPSSPP 1.20.4 gives no reason; the pc tells + if event.reason in (None, "cpu.breakpoint"): + return t.Hit("exec", address, event.pc, True, start=address) + elif isinstance(event, t.Hit): + return event if _trips(event, "exec", address) else None elif isinstance(event, t.LogLine): hit = event.hit if hit is not None and hit.kind == "exec" and hit.pc == address: return hit return None - async with self._subscribed({"cpu.stepping"} if stop else {"log"}) as sub: + async with self._subscribed(self._hit_source(stop, log_format)) as sub: await self.add_breakpoint( address, stop=stop, log=not stop, condition=condition, log_format=log_format ) @@ -697,30 +724,33 @@ async def watchpoint( ) -> AsyncIterator[Stream[t.Hit]]: """A memory breakpoint for the block; yields its hits and removes it on exit. - With `stop`, each hit leaves the CPU stopped until `resume()`; its `address` is the - watchpoint's start and `pc` the accessing instruction. Without, hits come from the log, - name the exact access and the game keeps running. + With `stop`, each hit leaves the CPU stopped until `resume()`. Without, the game keeps + running; a `log_format` message comes from the log. - PPSSPP 1.20.4 does not say why the CPU stopped, so a stop this client did not ask for - and that is not at one of its breakpoints counts as a hit, a pause from PPSSPP's own - UI included. + Stock PPSSPP 1.20.4 does not say why the CPU stopped, so there a stop this client did + not ask for and that is not at one of its breakpoints counts as a hit, a pause from + PPSSPP's own UI included. """ def match(event: t.Event) -> t.Hit | None: if isinstance(event, t.Stepping): - if event.requested: + if event.hit is not None: + return event.hit if _trips(event.hit, "memory", address) else None + if self._hit_events or event.requested: return None if event.reason == "memory.breakpoint" and event.related_address == address: - return t.Hit("memory", address, event.pc, True) + return t.Hit("memory", address, event.pc, True, start=address) if event.reason is None and event.pc not in self._stopping_breakpoints: - return t.Hit("memory", address, event.pc, True) + return t.Hit("memory", address, event.pc, True, start=address) + elif isinstance(event, t.Hit): + return event if _trips(event, "memory", address) else None elif isinstance(event, t.LogLine): hit = event.hit if hit is not None and hit.kind == "memory" and 0 <= hit.address - address < size: return hit return None - async with self._subscribed({"cpu.stepping"} if stop else {"log"}) as sub: + async with self._subscribed(self._hit_source(stop, log_format)) as sub: await self.add_watchpoint( address, size, @@ -740,6 +770,12 @@ def match(event: t.Event) -> t.Hit | None: await self.remove_watchpoint(address, size) await self._release_untaken(hits, stop) + def _hit_source(self, stop: bool, log_format: str | None) -> set[str]: + """The broadcasts a breakpoint's hits arrive as.""" + if stop: + return {"cpu.stepping"} + return {"cpu.breakpoint.hit"} if self._hit_events and log_format is None else {"log"} + async def _release_untaken(self, hits: Stream[t.Hit], stop: bool) -> None: """Resume a CPU left stopped by a hit that arrived after its caller stopped reading.""" # a stop broadcast comes after the reply to the removal, but before this one @@ -785,6 +821,15 @@ async def reset(self, *, stop: bool = False) -> None: """Restart the game; with `stop`, the CPU waits at the first instruction.""" await self._call("game.reset", {"break": stop}) + async def speed(self) -> t.Speed: + """The emulation speed; stock PPSSPP 1.20 and older raise Unsupported.""" + return t.speed(await self._call("game.speed.get", {})) + + async def set_speed(self, percent: int | None = None, *, fast_forward: bool = False) -> t.Speed: + """Run at `percent` of real time, at the game's own rate with None, or unlimited.""" + params = {"percent": percent, "fastForward": fast_forward} + return t.speed(await self._call("game.speed.set", params)) + async def frame_stats(self) -> t.FrameStats: """Rates after the next frame; times out while the CPU is stopped.""" return t.frame_stats(await self._call("gpu.stats.get", {})) @@ -793,19 +838,19 @@ async def screenshot(self) -> t.Screenshot: """The current output frame; some GPU backends answer only while stepping.""" return t.screenshot(await self._call("gpu.buffer.screenshot", {"type": "uri"})) - # patched PPSSPP only: Unsupported on stock builds + # the modkit's patched PPSSPP only: Unsupported on stock builds + + async def save_state(self, path: str | os.PathLike[str], timeout: float = 30.0) -> None: + """Write a savestate to `path`, as the emulator sees it; returns once it is written.""" + await self._call("savestate.save", {"path": os.fspath(path)}, timeout) - async def save_state(self, path: str | os.PathLike[str]) -> None: - """Queue a savestate to `path`, as the emulator sees it; returns once accepted.""" - await self._call("savestate.save", {"path": os.fspath(path)}) + async def load_state(self, path: str | os.PathLike[str], timeout: float = 30.0) -> None: + """Load the savestate at `path`, as the emulator sees it; returns once it is loaded.""" + await self._call("savestate.load", {"path": os.fspath(path)}, timeout) - async def load_state(self, path: str | os.PathLike[str]) -> None: - """Queue loading the savestate at `path`, as the emulator sees it; returns once accepted.""" - await self._call("savestate.load", {"path": os.fspath(path)}) - async def speed(self, limit: int | None = None) -> int: - """Set the frame-rate limit (60 real time, 240 four times, 0 unlimited); returns it.""" - return int((await self._call("game.speed", _given(limit=limit)))["limit"]) +def _trips(hit: t.Hit, kind: str, start: int) -> bool: + return hit.kind == kind and hit.start == start def _uri(host: str, port: int) -> str: diff --git a/packages/ppsspp-debug/src/ppsspp_debug/_launch.py b/packages/ppsspp-debug/src/ppsspp_debug/_launch.py index 866822c8..a19e9382 100644 --- a/packages/ppsspp-debug/src/ppsspp_debug/_launch.py +++ b/packages/ppsspp-debug/src/ppsspp_debug/_launch.py @@ -133,7 +133,7 @@ def _signal(proc: subprocess.Popen[bytes], sig: int) -> None: class DockerEmulator: - """PPSSPP in a long-lived container, driven through the headless rig's `ppsspp-ctl`. + """PPSSPP in the modkit's container (ppsspp/docker), driven through its `ppsspp-ctl`. `game` and `state` are paths inside the container. `ppsspp-ctl` pins the debugger port and reports ready only once the new process owns it. @@ -141,7 +141,7 @@ class DockerEmulator: def __init__( self, - container: str, + container: str = "ppsspp", *, game: str | None = None, state: str | None = None, @@ -213,6 +213,11 @@ def log(self) -> str: """The emulator's recent output.""" return self._ctl("log").stdout + def screenshot(self) -> bytes: + """The container's display as a PNG; works where the debugger's screenshot does not.""" + command = [self._docker, "exec", self.container, "ppsspp-ctl", "screenshot"] + return subprocess.run(command, capture_output=True, check=True).stdout + def __enter__(self) -> DockerEmulator: self.start() return self diff --git a/packages/ppsspp-debug/src/ppsspp_debug/_sync.py b/packages/ppsspp-debug/src/ppsspp_debug/_sync.py index 4ef46d62..745bae5d 100644 --- a/packages/ppsspp-debug/src/ppsspp_debug/_sync.py +++ b/packages/ppsspp-debug/src/ppsspp_debug/_sync.py @@ -264,9 +264,10 @@ def _enter_stream(self, acm: AbstractAsyncContextManager[Stream[T]]) -> Iterator game = _blocking(AsyncClient.game) wait_for_game = _blocking(AsyncClient.wait_for_game) reset = _blocking(AsyncClient.reset) + speed = _blocking(AsyncClient.speed) + set_speed = _blocking(AsyncClient.set_speed) frame_stats = _blocking(AsyncClient.frame_stats) screenshot = _blocking(AsyncClient.screenshot) save_state = _blocking(AsyncClient.save_state) load_state = _blocking(AsyncClient.load_state) - speed = _blocking(AsyncClient.speed) diff --git a/packages/ppsspp-debug/src/ppsspp_debug/_types.py b/packages/ppsspp-debug/src/ppsspp_debug/_types.py index e765e531..1de8dbee 100644 --- a/packages/ppsspp-debug/src/ppsspp_debug/_types.py +++ b/packages/ppsspp-debug/src/ppsspp_debug/_types.py @@ -104,6 +104,15 @@ def speed(self) -> float: return self.vblanks / self.vblanks_target if self.vblanks_target else 0.0 +@dataclass(frozen=True, slots=True) +class Speed: + """The emulation speed; `limit_fps` is the frame rate PPSSPP aims for, 0 for unlimited.""" + + fast_forward: bool + percent: int | None + limit_fps: int + + @dataclass(frozen=True, slots=True) class Screenshot: """The current output frame as a PNG.""" @@ -117,12 +126,14 @@ class Screenshot: class Hit: """One trip of a breakpoint or watchpoint. - From a stop: `pc` and `address` (the watchpoint's start) are known, the CPU is stepping. - From a log line: `access`, `size` and `message` too; `pc` is None when a watchpoint has a - custom log format. + `address` is the instruction, or the byte a watchpoint saw accessed; `start` is where the + breakpoint or watchpoint itself begins. A PPSSPP without cpu.breakpoint.hit, such as 1.20.4, + reports less: a stop gives only `pc`, with the watchpoint's start as `address`, and a log + line has no `start`, and no `pc` under a custom log format. `message` is the formatted log + line, only from the log. An access in a branch delay slot stops the CPU on the branch. """ - kind: Literal["exec", "memory"] + kind: Literal["exec", "memory", "register"] address: int pc: int | None stopped: bool @@ -130,6 +141,7 @@ class Hit: size: int | None = None source: str | None = None message: str | None = None + start: int | None = None # Broadcasts @@ -140,7 +152,8 @@ class Stepping: """The CPU stopped (cpu.stepping). `requested` is whether this client asked for it, with a pause or a step. `reason` and - `related_address` say why when PPSSPP tells; 1.20.4 leaves them out for breakpoints. + `related_address` say why, except on stock 1.20.4; `hit` says which breakpoint, on builds + with cpu.breakpoint.hit. """ pc: int @@ -148,6 +161,7 @@ class Stepping: reason: str | None related_address: int | None requested: bool = False + hit: Hit | None = None @dataclass(frozen=True, slots=True) @@ -187,7 +201,7 @@ class RawEvent: data: dict[str, Any] -Event = Stepping | Resumed | LogLine | GameEvent | RawEvent +Event = Stepping | Resumed | Hit | LogLine | GameEvent | RawEvent def parse_event(msg: dict[str, Any]) -> Event: @@ -197,6 +211,8 @@ def parse_event(msg: dict[str, Any]) -> Event: return stepping(msg) if name == "cpu.resume": return Resumed() + if name == "cpu.breakpoint.hit": + return hit(msg["hit"]) if name == "log": return LogLine( msg["timestamp"], @@ -248,6 +264,20 @@ def stepping(msg: dict[str, Any]) -> Stepping: msg.get("reason"), msg.get("relatedAddress"), msg.get("_requested", False), + hit(msg["hit"]) if "hit" in msg else None, + ) + + +def hit(h: dict[str, Any]) -> Hit: + return Hit( + h["kind"], + h["address"], + h["pc"], + h["paused"], + access=h.get("access"), + size=h.get("size"), + source=h.get("source"), + start=h["breakpoint"]["start"] if "breakpoint" in h else None, ) @@ -296,6 +326,10 @@ def frame_stats(msg: dict[str, Any]) -> FrameStats: return FrameStats(fps["actual"], fps["target"], vblanks["actual"], vblanks["target"]) +def speed(msg: dict[str, Any]) -> Speed: + return Speed(msg["fastForward"], msg["percent"], msg["limitFps"]) + + def screenshot(msg: dict[str, Any]) -> Screenshot: header, _, data = msg["uri"].partition(",") if header != "data:image/png;base64": diff --git a/packages/ppsspp-debug/src/ppsspp_debug/testing.py b/packages/ppsspp-debug/src/ppsspp_debug/testing.py index 5d52acda..954a333c 100644 --- a/packages/ppsspp-debug/src/ppsspp_debug/testing.py +++ b/packages/ppsspp-debug/src/ppsspp_debug/testing.py @@ -6,10 +6,10 @@ Quirks it keeps: cpu.stepping and cpu.resume answer with a broadcast instead of a reply, errors come back with the request's ticket, an unknown event is an error, cpu.stepping on a stopped CPU -does nothing, a breakpoint stop comes without its reason, the log goes only to the newest -connection and stops for all when any connection closes, log lines held while the log was -switched off arrive when it is switched on, gpu.stats.get never answers while stopped, and -input.buttons.press answers only once the button is released. +does nothing, log lines held while the log was switched off arrive when it is switched on, +gpu.stats.get never answers while stopped, and input.buttons.press answers only once the button +is released. Like stock PPSSPP 1.20.4, a breakpoint stop comes without its reason, and the log +goes only to the newest connection and stops for all when any closes. """ from __future__ import annotations @@ -36,9 +36,11 @@ class FakePPSSPP: """A PPSSPP debugger server on a free local port, with `size` bytes of memory at `base`. - `patched` adds the modkit build's savestate and game.speed commands. `handshake_delay` - stalls each WebSocket upgrade, as PPSSPP's startup phone-home does. `reasons` adds why the - CPU stopped to cpu.stepping, which PPSSPP 1.20.4 leaves out for breakpoints. + `patched` makes it the modkit's PPSSPP build instead of stock 1.20.4: stops say why, the + log goes to every connection, and it has game.speed.get/set and the savestate commands. + `hit_events` makes it a newer PPSSPP instead: the same without savestates, plus a + cpu.breakpoint.hit for every hit and the hit on each stop. `handshake_delay` stalls each + WebSocket upgrade, as PPSSPP's startup phone-home does. """ def __init__( @@ -47,11 +49,12 @@ def __init__( base: int = 0x1000_0000, size: int = 0x1_0000, patched: bool = False, + hit_events: bool = False, handshake_delay: float = 0.0, - reasons: bool = False, ) -> None: self.base = base - self.reasons = reasons + self.hit_events = hit_events + self._fixed = patched or hit_events self.memory = bytearray(size) self.patched = patched self.handshake_delay = handshake_delay @@ -63,6 +66,9 @@ def __init__( self.breakpoints: dict[int, dict[str, Any]] = {} self.watchpoints: dict[tuple[int, int], dict[str, Any]] = {} self.speed = 60 + """The frame rate frame_stats reports.""" + self.percent: int | None = None + self.fast_forward = False self.states: dict[str, bytes] = {} self.received: list[dict[str, Any]] = [] self.port = 0 @@ -70,7 +76,8 @@ def __init__( self._config: dict[ServerConnection, dict[str, bool]] = {} self._log_owner: ServerConnection | None = None self._server: Server | None = None - self._held: list[dict[str, Any]] = [] + self._held: dict[ServerConnection, list[dict[str, Any]]] = {} + self._hits = 0 self._table = self._handlers() async def __aenter__(self) -> FakePPSSPP: @@ -100,8 +107,11 @@ def disallowed(self) -> dict[str, bool]: async def _serve(self, ws: ServerConnection) -> None: self.connections.append(ws) - self._config[ws] = {"logger": False, "game": False, "input": False, "stepping": False} - self._log_owner, self._held = ws, [] + kinds = ["logger", "game", "input", "stepping"] + ["breakpoint"] * self.hit_events + self._config[ws] = dict.fromkeys(kinds, False) + self._held[ws] = [] + if not self._fixed: + self._log_owner = ws try: async for raw in ws: msg = json.loads(raw) @@ -115,7 +125,7 @@ async def _serve(self, ws: ServerConnection) -> None: pass finally: self.connections.remove(ws) - del self._config[ws] + del self._config[ws], self._held[ws] self._log_owner = None # what tests drive @@ -123,19 +133,19 @@ async def _serve(self, ws: ServerConnection) -> None: async def broadcast(self, msg: dict[str, Any]) -> None: """Send an event to every client that has not switched its kind off.""" name = msg["event"] - switch = ( - "logger" - if name == "log" - else "stepping" - if name in ("cpu.stepping", "cpu.resume") - else name.partition(".")[0] - ) + switch = { + "log": "logger", + "cpu.stepping": "stepping", + "cpu.resume": "stepping", + "cpu.breakpoint.hit": "breakpoint", + }.get(name, name.partition(".")[0]) if switch == "logger": - owner = self._log_owner - if owner is not None and self._config[owner]["logger"]: - self._held = [*self._held, msg][-1024:] - elif owner is not None: - await owner.send(json.dumps(msg)) + owners = self.connections if self._fixed else [self._log_owner] + for ws in [w for w in owners if w is not None]: + if self._config[ws]["logger"]: + self._held[ws] = [*self._held[ws], msg][-1024:] + else: + await ws.send(json.dumps(msg)) return for ws in list(self.connections): if not self._config[ws].get(switch, False): @@ -153,26 +163,50 @@ async def log(self, message: str, channel: str = "MEMMAP") -> None: } ) - async def stop(self, reason: str | None = None, related: int = 0) -> None: + async def stop( + self, reason: str | None = None, related: int = 0, hit: dict[str, Any] | None = None + ) -> None: """Enter stepping, as a breakpoint or a pause does.""" self.stepping = True self.pc = related if reason == "cpu.breakpoint" else self.pc msg: dict[str, Any] = {"event": "cpu.stepping", "pc": self.pc, "ticks": self.ticks} if reason is not None: msg |= {"reason": reason, "relatedAddress": related} + if hit is not None: + msg["hit"] = hit await self.broadcast(msg) + async def _stop_at(self, reason: str, related: int, hit: dict[str, Any]) -> None: + """A breakpoint stop, with as much as this PPSSPP says about it.""" + if not self._fixed: + return await self.stop() + await self.stop(reason, related, hit if self.hit_events else None) + + async def _hit(self, hit: dict[str, Any]) -> None: + self._hits += 1 + await self.broadcast({"event": "cpu.breakpoint.hit", "sequence": self._hits, "hit": hit}) + async def execute(self, pc: int) -> None: """Run the instruction at `pc`, tripping a breakpoint there.""" self.pc, self.ticks = pc, self.ticks + 1 bp = self.breakpoints.get(pc) if bp is None or self.stepping: return + hit = { + "kind": "exec", + "pc": pc, + "address": pc, + "logged": bp["log"], + "paused": bp["enabled"], + "breakpoint": {"start": pc, "end": pc}, + } + if self.hit_events: + await self._hit(hit) if bp["log"]: text = f": {bp['logFormat']}" if bp.get("logFormat") else " (z_un_test)" await self.log(f"BKP PC={pc:08x}{text}", "JIT") if bp["enabled"]: - await self.stop("cpu.breakpoint" if self.reasons else None, pc) + await self._stop_at("cpu.breakpoint", pc, hit) async def access(self, address: int, size: int = 4, *, write: bool = True, pc: int = 0) -> None: """A memory access by the game at `pc`, tripping a watchpoint that covers it.""" @@ -180,6 +214,19 @@ async def access(self, address: int, size: int = 4, *, write: bool = True, pc: i for (start, length), wp in self.watchpoints.items(): if not (start <= address < start + length) or not wp["write" if write else "read"]: continue + hit = { + "kind": "memory", + "pc": self.pc, + "address": address, + "logged": wp["log"], + "paused": wp["enabled"], + "size": size, + "access": "write" if write else "read", + "source": "CPU", + "breakpoint": {"start": start, "end": start + length}, + } + if self.hit_events and not self.stepping: + await self._hit(hit) if wp["log"]: kind = f"{'Write' if write else 'Read'}{size * 8}(CPU) at {address:08x}" text = ( @@ -189,7 +236,7 @@ async def access(self, address: int, size: int = 4, *, write: bool = True, pc: i ) await self.log(f"CHK {kind}{text}") if wp["enabled"] and not self.stepping: - await self.stop("memory.breakpoint" if self.reasons else None, start) + await self._stop_at("memory.breakpoint", start, hit) # protocol @@ -247,25 +294,28 @@ def _handlers(self) -> dict[str, Handler]: for width in (8, 16, 32): handlers[f"memory.read_u{width}"] = self._read_int handlers[f"memory.write_u{width}"] = self._write_int + if self._fixed: + handlers |= {"game.speed.get": self._speed, "game.speed.set": self._speed} if self.patched: - handlers |= { - "savestate.save": self._save_state, - "savestate.load": self._load_state, - "game.speed": self._speed, - } + handlers |= {"savestate.save": self._save_state, "savestate.load": self._load_state} return handlers async def _ok(self, ws: ServerConnection, msg: dict[str, Any]) -> None: await self._reply(ws, msg) async def _version(self, ws: ServerConnection, msg: dict[str, Any]) -> None: - await self._reply(ws, msg, name="PPSSPP", version="v1.20.4-fake") + version = "newer-fake" if self.hit_events else "v1.20.4-fake" + await self._reply(ws, msg, name="PPSSPP", version=version) async def _broadcast_config(self, ws: ServerConnection, msg: dict[str, Any]) -> None: - self._config[ws] |= msg.get("disallowed", {}) + disallowed = msg.get("disallowed", {}) + for key in disallowed: + if key not in self._config[ws]: + return await self._fail(ws, msg, f"Unsupported 'disallowed' object key '{key}'") + self._config[ws] |= disallowed await self._reply(ws, msg, disallowed=self._config[ws]) - if ws is self._log_owner and not self._config[ws]["logger"]: - held, self._held = self._held, [] + if not self._config[ws]["logger"]: + held, self._held[ws] = self._held[ws], [] for line in held: await ws.send(json.dumps(line)) @@ -434,6 +484,10 @@ async def _load_state(self, ws: ServerConnection, msg: dict[str, Any]) -> None: await self._reply(ws, msg) async def _speed(self, ws: ServerConnection, msg: dict[str, Any]) -> None: - if "limit" in msg: - self.speed = msg["limit"] - await self._reply(ws, msg, limit=self.speed) + if msg["event"] == "game.speed.set": + self.fast_forward = msg.get("fastForward", self.fast_forward) + self.percent = msg.get("percent", self.percent) + self.speed = 60 * (self.percent or 100) // 100 + limit = 0 if self.fast_forward else self.speed + state = {"fastForward": self.fast_forward, "percent": self.percent, "limitFps": limit} + await self._reply(ws, msg, **state) diff --git a/packages/ppsspp-debug/tests/conftest.py b/packages/ppsspp-debug/tests/conftest.py index 5134654c..a7533cd1 100644 --- a/packages/ppsspp-debug/tests/conftest.py +++ b/packages/ppsspp-debug/tests/conftest.py @@ -2,13 +2,14 @@ from collections.abc import AsyncIterator, Iterator import pytest -from ppsspp_debug import AsyncClient, LocalEmulator +from ppsspp_debug import AsyncClient, DockerEmulator, LocalEmulator from ppsspp_debug.testing import FakePPSSPP -@pytest.fixture -async def fake() -> AsyncIterator[FakePPSSPP]: - async with FakePPSSPP(patched=True) as server: +@pytest.fixture(params=["stock", "patched", "hit_events"]) +async def fake(request: pytest.FixtureRequest) -> AsyncIterator[FakePPSSPP]: + mode = request.param + async with FakePPSSPP(patched=mode == "patched", hit_events=mode == "hit_events") as server: yield server @@ -19,10 +20,17 @@ async def client(fake: FakePPSSPP) -> AsyncIterator[AsyncClient]: @pytest.fixture(scope="session") -def emulator() -> Iterator[LocalEmulator]: - """A real PPSSPP booting PPSSPP_GAME, when PPSSPP_BINARY and PPSSPP_GAME are set.""" - binary, game = os.environ.get("PPSSPP_BINARY"), os.environ.get("PPSSPP_GAME") - if not (binary and game): - pytest.skip("set PPSSPP_BINARY and PPSSPP_GAME to run against a real PPSSPP") - with LocalEmulator(binary, game) as emu: +def emulator() -> Iterator[LocalEmulator | DockerEmulator]: + """A real PPSSPP cold booting PPSSPP_GAME: PPSSPP_BINARY, or the rig's PPSSPP_CONTAINER.""" + game, binary = os.environ.get("PPSSPP_GAME"), os.environ.get("PPSSPP_BINARY") + container = os.environ.get("PPSSPP_CONTAINER") + if not (game and (binary or container)): + pytest.skip("set PPSSPP_GAME and PPSSPP_BINARY or PPSSPP_CONTAINER for a real PPSSPP") + if container: + emu: LocalEmulator | DockerEmulator = DockerEmulator(container, game=game) + emu.restart() + else: + assert binary + emu = LocalEmulator(binary, game) + with emu: yield emu diff --git a/packages/ppsspp-debug/tests/test_client.py b/packages/ppsspp-debug/tests/test_client.py index eedb9af2..216b64ba 100644 --- a/packages/ppsspp-debug/tests/test_client.py +++ b/packages/ppsspp-debug/tests/test_client.py @@ -8,6 +8,7 @@ Disconnected, Hit, LogLine, + Speed, Stepping, Unsupported, Watchpoint, @@ -17,6 +18,9 @@ BASE = 0x1000_0000 +patched_only = pytest.mark.parametrize("fake", ["patched"], indirect=True) +hit_events_only = pytest.mark.parametrize("fake", ["hit_events"], indirect=True) + async def test_memory_round_trip(client): await client.write_u32(BASE, 0xDEADBEEF) @@ -37,11 +41,14 @@ async def test_error_reply_raises(client): async def test_unknown_event_is_unsupported(): async with FakePPSSPP() as stock, AsyncClient.connect(port=stock.port) as c: with pytest.raises(Unsupported): - await c.speed(240) + await c.speed() +@patched_only async def test_patched_commands(client): - assert await client.speed(240) == 240 + assert await client.set_speed(200) == Speed(False, 200, 120) + assert (await client.set_speed(fast_forward=True)).limit_fps == 0 + assert await client.set_speed() == await client.speed() == Speed(False, None, 60) await client.write_u8(BASE, 7) await client.save_state("/states/a.ppst") await client.write_u8(BASE, 0) @@ -88,7 +95,7 @@ async def test_breakpoint_stops_and_is_removed(client, fake): async with client.breakpoint(BASE + 0x10) as hits: assert BASE + 0x10 in fake.breakpoints await fake.execute(BASE + 0x10) - assert await hits.next(1) == Hit("exec", BASE + 0x10, BASE + 0x10, True) + assert await hits.next(1) == Hit("exec", BASE + 0x10, BASE + 0x10, True, start=BASE + 0x10) assert (await client.status()).stepping await client.resume() assert not fake.breakpoints @@ -97,7 +104,10 @@ async def test_breakpoint_stops_and_is_removed(client, fake): async def test_watchpoint_stop(client, fake): async with client.watchpoint(BASE, 8) as hits: await fake.access(BASE + 4, 2, pc=BASE + 0x40) - assert await hits.next(1) == Hit("memory", BASE, BASE + 0x40, True) + exact = Hit("memory", BASE + 4, BASE + 0x40, True, "write", 2, "CPU", start=BASE) + assert await hits.next(1) == ( + exact if fake.hit_events else Hit("memory", BASE, BASE + 0x40, True, start=BASE) + ) assert fake.stepping await client.resume() @@ -117,11 +127,13 @@ async def test_breakpoint_stop_is_not_a_watchpoint_hit(client, fake): await writes.next(0.05) -async def test_stops_with_reasons(): - async with FakePPSSPP(reasons=True) as server, AsyncClient.connect(port=server.port) as c: - async with c.breakpoint(BASE) as hits: - await server.execute(BASE) - assert (await hits.next(1)).pc == BASE +async def test_resume_answered_by_a_new_stop(client, fake): + async def trip_again(ws, msg): + await fake.stop() + + fake._table["cpu.resume"] = trip_again + await client.pause() + await client.resume() async def test_held_log_lines_are_not_hits(client, fake): @@ -167,10 +179,25 @@ async def test_log_only_breakpoint(client, fake): async with client.breakpoint(BASE, stop=False) as hits: await fake.execute(BASE) hit = await hits.next(1) - assert (hit.pc, hit.stopped, hit.message) == (BASE, False, "z_un_test") + assert (hit.pc, hit.stopped, hit.message) == ( + BASE, + False, + None if fake.hit_events else "z_un_test", + ) assert not fake.stepping +@hit_events_only +async def test_hit_events_need_no_log(client, fake): + async with client.watchpoint(BASE, 8, stop=False) as hits: + assert len(fake.connections) == 1 and not fake.disallowed["breakpoint"] + await fake.access(BASE + 4, pc=BASE + 0x40) + assert await hits.next(1) == Hit( + "memory", BASE + 4, BASE + 0x40, False, "write", 4, "CPU", start=BASE + ) + assert fake.disallowed["breakpoint"] + + async def test_lists(client): await client.add_breakpoint(BASE, stop=False, log=True, condition="a0 == 1") await client.add_watchpoint(BASE, 2, change=True) @@ -181,7 +208,8 @@ async def test_lists(client): async def test_broadcasts_off_unless_streamed(client, fake): - assert fake.disallowed == {"logger": True, "game": True, "input": True, "stepping": False} + off = {"logger": True, "game": True, "input": True, "stepping": False} + assert fake.disallowed == off | ({"breakpoint": True} if fake.hit_events else {}) async with client.events("game.start") as events: assert not fake.disallowed["game"] await fake.broadcast({"event": "game.start", "game": None}) diff --git a/packages/ppsspp-debug/tests/test_launch.py b/packages/ppsspp-debug/tests/test_launch.py index 59f03e53..53410001 100644 --- a/packages/ppsspp-debug/tests/test_launch.py +++ b/packages/ppsspp-debug/tests/test_launch.py @@ -45,6 +45,7 @@ def test_local_without_a_port_times_out(): echo started ;; stop) rm -f "$FAKE_DIR/alive"; echo stopped ;; log) echo "Could not find a graphics and a present queue" ;; + screenshot) printf '\\211PNG' ;; esac """ @@ -81,3 +82,7 @@ def test_docker_gives_up(docker): emu = DockerEmulator("rig", docker=str(docker), tries=1, settle=0) with pytest.raises(RuntimeError, match="present queue"): emu.start() + + +def test_docker_screenshot(docker): + assert DockerEmulator(docker=str(docker)).screenshot() == b"\x89PNG" diff --git a/packages/ppsspp-debug/tests/test_live.py b/packages/ppsspp-debug/tests/test_live.py index 95f54125..cc202c5d 100644 --- a/packages/ppsspp-debug/tests/test_live.py +++ b/packages/ppsspp-debug/tests/test_live.py @@ -4,7 +4,14 @@ import time import pytest -from ppsspp_debug import AsyncClient, Client, DebuggerError, Unsupported, find_debuggers +from ppsspp_debug import ( + AsyncClient, + Client, + DebuggerError, + DockerEmulator, + Unsupported, + find_debuggers, +) from websockets.sync.client import connect as raw_connect USER_RAM = 0x0880_0000 # noaddr: the PSP's user memory, the same for every game @@ -50,7 +57,19 @@ def hot(ppsspp): return periodic[0] +@pytest.fixture(scope="module") +def fixed(ppsspp): + """Whether this PPSSPP has the fixes the modkit patches into 1.20.4, told by game.speed.get.""" + try: + ppsspp.speed() + except Unsupported: + return False + return True + + def test_finds_its_port(ppsspp, emulator): + if isinstance(emulator, DockerEmulator): + pytest.skip("the process runs in a container") assert emulator.port() in find_debuggers() @@ -128,10 +147,34 @@ def test_breakpoint_stops_and_resumes(ppsspp, hot): def test_watchpoint_stops_and_resumes(ppsspp, hot): with ppsspp.watchpoint(hot[1], 4) as hits: hit = hits.next(10) - regs = ppsspp.registers() + pc = ppsspp.registers()["pc"] + stopped_at = ppsspp.disasm(pc)[0] + ppsspp.resume() + assert hit.stopped and not ppsspp.watchpoints() + if pc != hit.pc: # an access in a delay slot stops the CPU on its branch + report(f"stopped at {pc:08x} {stopped_at}, the access at {hit.pc:08x}") + assert hit.pc == pc + 4 and stopped_at.name[0] in "bj" + + +def test_stop_says_why(ppsspp, hot, fixed): + with ppsspp.events("cpu.stepping") as stops, ppsspp.watchpoint(hot[1], 4) as hits: + hits.next(10) + stop = stops.next(1) ppsspp.resume() - assert hit.stopped and regs["pc"] == hit.pc - assert not ppsspp.watchpoints() + why = ("memory.breakpoint", hot[1]) if fixed else (None, None) + assert (stop.reason, stop.related_address) == why + + +def test_log_reaches_every_connection(ppsspp, hot, fixed): + with ppsspp.breakpoint(hot[0], stop=False) as calls: + calls.next(10) + with raw_connect(f"ws://127.0.0.1:{ppsspp.port}/debugger", open_timeout=5): + pass + if fixed: + calls.next(5) + else: # stock: the closing connection took the log with it + with pytest.raises(TimeoutError): + calls.next(2) def test_log_only_hits_keep_the_game_running(ppsspp, hot): @@ -184,13 +227,34 @@ def test_screenshot_while_stopped(ppsspp): report(f"screenshot while stopped: {shot.width}x{shot.height}, {len(shot.png)} bytes") -def test_patched_commands(ppsspp): +def test_speed(ppsspp): try: - limit = ppsspp.speed() + doubled = ppsspp.set_speed(200) except Unsupported: - report("stock PPSSPP: game.speed and savestate.* raise Unsupported") + report("PPSSPP without game.speed.*: speed() raises Unsupported") return - assert limit >= 0 + assert doubled.limit_fps == 120 + assert ppsspp.set_speed() == ppsspp.speed() and ppsspp.speed().limit_fps == 60 + + +def test_savestate_round_trip(ppsspp, emulator, tmp_path): + # a path as the emulator sees it + state = "/tmp/live.ppst" if isinstance(emulator, DockerEmulator) else tmp_path / "live.ppst" + with ppsspp.paused(): + before = ppsspp.read(USER_RAM, 4) + start = time.perf_counter() + try: + ppsspp.save_state(state) + except Unsupported: + report("stock PPSSPP: savestate.* raise Unsupported") + return + saved = time.perf_counter() - start + ppsspp.write(USER_RAM, bytes(b ^ 0xFF for b in before)) + start = time.perf_counter() + ppsspp.load_state(state) + loaded = time.perf_counter() - start + assert ppsspp.read(USER_RAM, 4) == before + report(f"savestate written in {ms(saved)}, loaded in {ms(loaded)}") async def test_async_client_alongside(ppsspp, hot): diff --git a/ppsspp/.dockerignore b/ppsspp/.dockerignore new file mode 100644 index 00000000..da41f821 --- /dev/null +++ b/ppsspp/.dockerignore @@ -0,0 +1 @@ +docker/.env diff --git a/ppsspp/README.md b/ppsspp/README.md new file mode 100644 index 00000000..4230c533 --- /dev/null +++ b/ppsspp/README.md @@ -0,0 +1,69 @@ +# PPSSPP for automation + +PPSSPP v1.20.4 with five patches for scripted debugging, and a container that runs it headless, +watchable in a browser. [`ppsspp-debug`](../packages/ppsspp-debug) works with stock PPSSPP as well; +it uses what the patches add where the emulator has it. + +| Patch | Adds | +|---|---| +| `0001-debugger-savestate-save-load` | `savestate.save` and `savestate.load` debugger commands that answer once the file is written or the state loaded | +| `0002-arm-jit-vfpu-memcheck-offset` | On ARM hosts, Apple Silicon included, watchpoints catch VFPU loads and stores with a negative offset | +| `0003-debugger-log-to-every-connection` | Every debugger connection gets the log, and closing one no longer stops it for the rest | +| `0004-debugger-stepping-reason` | `cpu.stepping` says why the CPU stopped | +| `0005-debugger-game-speed` | `game.speed.get` and `game.speed.set`, to run faster or slower than real time | + +The last three are backports from PPSSPP's master branch and go when the pin moves to a release +that has them. The patches are GPL-2.0-or-later, like PPSSPP. + +## Build + +```bash +ppsspp/build.sh +``` + +It fetches PPSSPP at the pinned commit into `~/.cache/modkit/ppsspp` (2.4 GB with its submodules; +`PPSSPP_BUILD` moves it), applies the patches, builds, and prints the path of the binary. It needs +git, CMake and Ninja, plus Xcode's command line tools on macOS, or on Linux the packages the +Dockerfile installs. + +## Headless in Docker + +```bash +cd ppsspp/docker +./setup.sh ~/isos ~/ppsspp-home # once: the game images and PPSSPP's user directory +docker compose build --build-arg JOBS=4 # compiles PPSSPP; JOBS caps it where memory is short +docker compose up -d +docker compose exec ppsspp ppsspp-ctl start --iso /iso/game.iso +``` + +Watch and play at http://localhost:6080/vnc.html?autoconnect=1&resize=scale; +`WEB_BIND=0.0.0.0 ./setup.sh ...` opens it to the network, without a password. The debugger +listens on 127.0.0.1:12345: + +```python +from ppsspp_debug import Client, DockerEmulator + +with DockerEmulator(game="/iso/game.iso") as emu, Client.connect(port=emu.port()) as ppsspp: + ppsspp.wait_for_game() + png = emu.screenshot() +``` + +`ppsspp-ctl` starts, stops and restarts PPSSPP inside the container, shows its log and captures +its display (`docker compose exec -T ppsspp ppsspp-ctl screenshot > shot.png`). Paths given to it, +or to `save_state()`, are the container's: `/iso` holds the images and `/config/ppsspp/PSP` is the +memory stick. A `--state` launch skips the cold boot, so PRX plugins do not load. + +On Linux, `setup.sh` hands the container the GPU's render node for Vulkan; without one, as under +Docker Desktop, PPSSPP renders in software. + +What the container takes care of: + +- PPSSPP silently moves to a random port when its own is busy. `ppsspp-ctl start` waits until the + port is free and returns only once the new process owns it. +- PPSSPP announces itself to report.ppsspp.org before its debugger accepts connections, which + stalls the first handshake. The container resolves that name to loopback. +- PPSSPP rewrites `ppsspp.ini` on exit. `ppsspp-ctl` sets the debugger and frame timing settings + again before each start. + +A container that has been up for days can start failing in ways that look like your code; +restart it first. diff --git a/ppsspp/build.sh b/ppsspp/build.sh new file mode 100755 index 00000000..65ac9854 --- /dev/null +++ b/ppsspp/build.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +# Build PPSSPP at the pinned commit with patches/ applied, on macOS or Linux. +# ppsspp/build.sh [extra cmake args] +# PPSSPP_BUILD (default ~/.cache/modkit/ppsspp) holds the checkout and the build, outside the repo +# so tools that walk it skip 2.4 GB of PPSSPP; JOBS caps the compile. +set -euo pipefail + +COMMIT=fa50bb1976065c4f8b1b47af227d367fe9771555 # v1.20.4 +REPO=https://github.com/hrydgard/ppsspp.git + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +BUILD="${PPSSPP_BUILD:-${XDG_CACHE_HOME:-$HOME/.cache}/modkit/ppsspp}" +SRC="$BUILD/src" +OUT="$SRC/build" # inside the tree: the macOS signing step finds its entitlements from there + +if [ "$(git -C "$SRC" rev-parse HEAD 2>/dev/null)" != "$COMMIT" ]; then + [ -d "$SRC/.git" ] || git init -q "$SRC" + git -C "$SRC" fetch -q --depth 1 "$REPO" "$COMMIT" + git -C "$SRC" checkout -q --force FETCH_HEAD + git -C "$SRC" clean -q -ffdx + git -C "$SRC" submodule -q update --init --recursive --force --depth 1 --jobs 8 + rm -rf "$OUT" "$BUILD/patches.stamp" +fi + +# re-apply only when the patches changed, so an unchanged tree keeps its mtimes +stamp="$(cat "$HERE"/patches/*.patch | git hash-object --stdin)" +if [ "$(cat "$BUILD/patches.stamp" 2>/dev/null)" != "$stamp" ]; then + git -C "$SRC" reset -q --hard + git -C "$SRC" apply "$HERE"/patches/*.patch + echo "$stamp" > "$BUILD/patches.stamp" +fi + +args=(-DCMAKE_BUILD_TYPE=Release -DUSE_DISCORD=OFF -DUNITTEST=OFF -DHEADLESS=OFF) +if [ "$(uname)" = Linux ]; then + args+=(-DUSING_QT_UI=OFF -DUSE_SYSTEM_LIBSDL2=ON -DUSE_SYSTEM_FFMPEG=OFF -DUSE_WAYLAND_WSI=OFF) +fi +# CMake 4 refuses the cmake_minimum_required of some bundled libraries +cmake -S "$SRC" -B "$OUT" -G Ninja -DCMAKE_POLICY_VERSION_MINIMUM=3.5 "${args[@]}" "$@" +cmake --build "$OUT" --target PPSSPPSDL ${JOBS:+--parallel "$JOBS"} + +if [ -d "$OUT/PPSSPPSDL.app" ]; then + echo "$OUT/PPSSPPSDL.app/Contents/MacOS/PPSSPPSDL" +else + echo "$OUT/PPSSPPSDL" +fi diff --git a/ppsspp/docker/Dockerfile b/ppsspp/docker/Dockerfile new file mode 100644 index 00000000..8b61ec00 --- /dev/null +++ b/ppsspp/docker/Dockerfile @@ -0,0 +1,43 @@ +# PPSSPP on a virtual display, watchable in a browser over noVNC, its debugger on port 12345. +# The build stage runs the same build.sh as a native build; the build context is ppsspp/. +ARG DEBIAN=trixie + +FROM debian:${DEBIAN} AS build +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential cmake ninja-build git ca-certificates pkg-config python3 \ + libsdl2-dev libgl-dev libglu1-mesa-dev libvulkan-dev libpng-dev zlib1g-dev libzip-dev \ + && rm -rf /var/lib/apt/lists/* +COPY build.sh /ppsspp/ +COPY patches /ppsspp/patches +# caps parallel compiles where memory is short, such as a Docker Desktop VM +ARG JOBS +ENV PPSSPP_BUILD=/ppsspp/build +# the checkout and the build live in a cache, so a rebuild compiles only what changed +RUN --mount=type=cache,target=/ppsspp/build \ + /ppsspp/build.sh \ + && mkdir /opt/ppsspp \ + && cp -r /ppsspp/build/src/build/PPSSPPSDL /ppsspp/build/src/build/assets /opt/ppsspp/ + +FROM debian:${DEBIAN}-slim +RUN apt-get update && apt-get install -y --no-install-recommends \ + xvfb x11vnc novnc websockify x11-utils x11-apps netpbm \ + libsdl2-2.0-0 libgl1 libglu1-mesa libegl1 libgl1-mesa-dri libglx-mesa0 \ + libvulkan1 mesa-vulkan-drivers \ + libpng16-16t64 zlib1g libzip5 \ + ca-certificates procps tini \ + && rm -rf /var/lib/apt/lists/* \ + && ln -sf /usr/share/novnc/vnc.html /usr/share/novnc/index.html +COPY --from=build /opt/ppsspp /opt/ppsspp +# a library the build linked but this stage lacks fails here, not at the first launch +RUN ! ldd /opt/ppsspp/PPSSPPSDL | grep "not found" + +COPY docker/entrypoint.sh docker/ppsspp-ctl /usr/local/bin/ +RUN mkdir -p /config /iso /run/ppsspp && chmod 777 /config /run/ppsspp + +ENV DISPLAY=:99 \ + SCREEN_GEOMETRY=960x544x24 \ + HOME=/config \ + XDG_CONFIG_HOME=/config \ + SDL_AUDIODRIVER=dummy + +ENTRYPOINT ["/usr/bin/tini", "--", "/usr/local/bin/entrypoint.sh"] diff --git a/ppsspp/docker/compose.gpu.yaml b/ppsspp/docker/compose.gpu.yaml new file mode 100644 index 00000000..ecf249dc --- /dev/null +++ b/ppsspp/docker/compose.gpu.yaml @@ -0,0 +1,9 @@ +# Vulkan on the host's GPU; setup.sh adds this file to COMPOSE_FILE when it finds a render node. +services: + ppsspp: + group_add: + - "${RENDER_GID}" + devices: + - "${RENDER_NODE}:${RENDER_NODE}" + environment: + GPU_BACKEND: vulkan diff --git a/ppsspp/docker/compose.yaml b/ppsspp/docker/compose.yaml new file mode 100644 index 00000000..2b64d1cf --- /dev/null +++ b/ppsspp/docker/compose.yaml @@ -0,0 +1,34 @@ +# The container is a long-lived display; PPSSPP starts and stops inside it through ppsspp-ctl, +# because automation restarts the emulator between runs. setup.sh writes the .env it reads. +name: ppsspp + +services: + ppsspp: + build: + context: .. + dockerfile: docker/Dockerfile + image: modkit-ppsspp + container_name: ppsspp + # the owner of the mounts, so savestates and logs stay editable from the host + user: "${PPSSPP_UID:-1000}:${PPSSPP_GID:-1000}" + ports: + # noVNC: watch and play from a browser; WEB_BIND=0.0.0.0 opens it to the network + - "${WEB_BIND:-127.0.0.1}:${WEB_PORT:-6080}:6080" + # the debugger reads and writes emulator memory unauthenticated, so it stays local + - "127.0.0.1:${DEBUG_PORT:-12345}:12345" + volumes: + - "${ISO_DIR:?run setup.sh first}:/iso:ro" + # ppsspp.ini, the memory stick, and savestates in ppsspp/PSP/PPSSPP_STATE + - "${PPSSPP_HOME:?run setup.sh first}:/config" + # PPSSPP announces itself to report.ppsspp.org before its debugger accepts connections, + # which stalls the first handshake for seconds and publishes this host's address; pointing + # the name at loopback for both families makes the announcement fail at once + extra_hosts: + - "report.ppsspp.org:127.0.0.1" + - "report.ppsspp.org:::1" + environment: + GPU_BACKEND: "${GPU_BACKEND:-software}" + # X11 shared memory and PPSSPP's own buffers outgrow the 64 MB default + shm_size: 512m + restart: unless-stopped + stop_grace_period: 10s diff --git a/ppsspp/docker/entrypoint.sh b/ppsspp/docker/entrypoint.sh new file mode 100755 index 00000000..93331d8b --- /dev/null +++ b/ppsspp/docker/entrypoint.sh @@ -0,0 +1,25 @@ +#!/bin/bash +# Bring up the virtual display, VNC and noVNC, then idle; `ppsspp-ctl start` runs the emulator. +set -euo pipefail + +n="${DISPLAY#:}" +LOGS=/run/ppsspp +mkdir -p "$LOGS" + +# /tmp survives `docker restart`, and Xvfb refuses to start over its old lock +rm -f "/tmp/.X$n-lock" "/tmp/.X11-unix/X$n" + +Xvfb "$DISPLAY" -screen 0 "${SCREEN_GEOMETRY:-960x544x24}" -nolisten tcp -ac +extension GLX \ + +render -noreset >"$LOGS/xvfb.log" 2>&1 & +for _ in $(seq 100); do + xdpyinfo >/dev/null 2>&1 && break + sleep 0.1 +done +xdpyinfo >/dev/null 2>&1 || { cat "$LOGS/xvfb.log"; exit 1; } + +x11vnc -display "$DISPLAY" -forever -shared -nopw -rfbport 5900 -noxdamage -repeat -quiet \ + >"$LOGS/x11vnc.log" 2>&1 & +websockify --web=/usr/share/novnc 6080 localhost:5900 >"$LOGS/websockify.log" 2>&1 & + +echo "display $DISPLAY ready, GPU backend ${GPU_BACKEND:-software}; start PPSSPP with ppsspp-ctl start" +exec tail -f /dev/null diff --git a/ppsspp/docker/ppsspp-ctl b/ppsspp/docker/ppsspp-ctl new file mode 100755 index 00000000..cb10e09f --- /dev/null +++ b/ppsspp/docker/ppsspp-ctl @@ -0,0 +1,171 @@ +#!/bin/bash +# Start and stop PPSSPP inside the container. +# ppsspp-ctl start [--iso PATH] [--state PATH] [--backend vulkan|opengl|software] +# ppsspp-ctl stop | restart | status | log [-f] | config +# ppsspp-ctl screenshot > shot.png +# `start` returns once this PPSSPP owns the debugger port. A --state launch skips the cold boot, +# so PRX plugins do not load. +set -uo pipefail + +BIN="${PPSSPP_BIN:-/opt/ppsspp/PPSSPPSDL}" +PIDFILE=/run/ppsspp/ppsspp.pid +LOG=/run/ppsspp/ppsspp.log +INI="$XDG_CONFIG_HOME/ppsspp/PSP/SYSTEM/ppsspp.ini" +PORT=12345 +BACKEND="${GPU_BACKEND:-software}" +ISO="" +STATE="" + +# PPSSPP rewrites ppsspp.ini on exit, so these are set again before every launch +ini_set() { + local section="$1" key="$2" val="$3" + mkdir -p "$(dirname "$INI")" + touch "$INI" + awk -v s="[$section]" -v k="$key" -v v="$val" ' + /^\[/ { + if (insec && !done) { print k " = " v; done = 1 } + insec = ($0 == s) + if (insec) seen = 1 + } + insec && $0 ~ "^" k "[ \t]*=" { print k " = " v; done = 1; next } + { print } + END { + if (insec && !done) print k " = " v + else if (!seen) { print s; print k " = " v } + } + ' "$INI" > "$INI.tmp" && mv "$INI.tmp" "$INI" +} + +apply_config() { + ini_set General RemoteDebuggerOnStartup True + ini_set General RemoteDebuggerLocal False # the published port reaches it from outside + ini_set General RemoteISOPort "$PORT" + ini_set General CheckForNewVersion False + ini_set General EnableAutoLoad 0 + ini_set General IgnoreBadMemAccess True + ini_set Sound Enable False + # dropped or doubled frames break timing that automation measures in frames + ini_set Graphics FrameSkip 0 + ini_set Graphics AutoFrameSkip False + ini_set Graphics VSync False + ini_set Graphics InternalResolution 1 + # a start that dies or is killed marks its backend failed, and PPSSPP never tries it again + rm -f "$(dirname "$INI")/FailedGraphicsBackends.txt" +} + +# inodes of the sockets listening on PORT (state 0A) +port_inodes() { + local hex; hex=$(printf '%04X' "$PORT") + awk -v p="$hex" '$4 == "0A" { split($2, a, ":"); if (a[2] == p) print $10 }' \ + /proc/net/tcp /proc/net/tcp6 2>/dev/null +} + +# Whether pid $1 owns the listening socket. A bare connect test is not enough: with the port +# still held, PPSSPP silently listens on a random one instead, and the connect reaches the old +# instance. +owns_port() { + local want target ino + want=" $(port_inodes | tr '\n' ' ') " + [ "$want" = " " ] && return 1 + for fd in /proc/"$1"/fd/*; do + target=$(readlink "$fd" 2>/dev/null) || continue + case "$target" in + socket:\[*\]) ino=${target#socket:[}; ino=${ino%]} + case "$want" in *" $ino "*) return 0 ;; esac ;; + esac + done + return 1 +} + +port_free() { + for _ in $(seq "${1:-100}"); do + [ -z "$(port_inodes)" ] && return 0 + sleep 0.1 + done + return 1 +} + +running_pid() { + local p + p=$(cat "$PIDFILE" 2>/dev/null) && [ -n "$p" ] && kill -0 "$p" 2>/dev/null && echo "$p" +} + +cmd_start() { + if pid=$(running_pid); then echo "already running (pid $pid)"; return 0; fi + if [ -z "$ISO" ]; then + for f in /iso/*.[iI][sS][oO] /iso/*.[cC][sS][oO]; do [ -f "$f" ] && { ISO="$f"; break; }; done + fi + [ -f "$ISO" ] || { echo "no game image in /iso; pass --iso" >&2; return 1; } + [ -x "$BIN" ] || { echo "no PPSSPP at $BIN" >&2; return 1; } + apply_config + if ! port_free 50; then + pkill -f PPSSPPSDL + port_free 100 || { echo "port $PORT stays taken" >&2; return 1; } + fi + + local args=(--fullscreen "--graphics=$BACKEND") + [ -n "$STATE" ] && args+=("--state=$STATE") + echo "$BIN ${args[*]} $ISO" + "$BIN" "${args[@]}" "$ISO" >"$LOG" 2>&1 & + echo $! > "$PIDFILE" + + # a cold boot takes a few seconds, a savestate launch longer + for _ in $(seq 900); do + pid=$(running_pid) || { echo "PPSSPP exited while starting:" >&2; tail -30 "$LOG" >&2; return 1; } + owns_port "$pid" && { echo "debugger on port $PORT (pid $pid)"; return 0; } + sleep 0.1 + done + echo "pid $(cat "$PIDFILE") never took port $PORT" >&2 + tail -30 "$LOG" >&2 + return 1 +} + +cmd_stop() { + if ! pid=$(running_pid); then + pkill -f PPSSPPSDL + rm -f "$PIDFILE" + echo "not running" + return 0 + fi + kill -TERM "$pid" + for _ in $(seq 50); do kill -0 "$pid" 2>/dev/null || break; sleep 0.1; done + kill -KILL "$pid" 2>/dev/null + rm -f "$PIDFILE" + # the socket outlives the process for a moment; a restart racing it gets a random port + port_free 100 || echo "port $PORT still held after stop" >&2 + echo "stopped" +} + +cmd_status() { + if ! pid=$(running_pid); then echo "stopped"; return 0; fi + if owns_port "$pid"; then + echo "running (pid $pid), debugger on port $PORT" + else + echo "running (pid $pid), debugger port $PORT not its own" + fi +} + +action="${1:-status}"; shift || true +while [ $# -gt 0 ]; do + case "$1" in + --state) STATE="$2"; shift 2 ;; + --state=*) STATE="${1#*=}"; shift ;; + --iso) ISO="$2"; shift 2 ;; + --iso=*) ISO="${1#*=}"; shift ;; + --backend) BACKEND="$2"; shift 2 ;; + --backend=*) BACKEND="${1#*=}"; shift ;; + -f) FOLLOW=1; shift ;; + *) echo "unknown option $1" >&2; exit 2 ;; + esac +done + +case "$action" in + start) cmd_start ;; + stop) cmd_stop ;; + restart) cmd_stop; cmd_start ;; + status) cmd_status ;; + log) if [ "${FOLLOW:-0}" = 1 ]; then tail -f "$LOG"; else tail -100 "$LOG"; fi ;; + config) apply_config; echo "wrote $INI" ;; + screenshot) xwd -root -silent | xwdtopnm 2>/dev/null | pnmtopng ;; + *) sed -n '2,6p' "$0" >&2; exit 2 ;; +esac diff --git a/ppsspp/docker/setup.sh b/ppsspp/docker/setup.sh new file mode 100755 index 00000000..c8a80daf --- /dev/null +++ b/ppsspp/docker/setup.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# Write the .env compose reads: the ISO directory, PPSSPP's user directory, their owner, and the +# GPU render node when the host has one (Linux only; otherwise PPSSPP renders in software). +# [WEB_BIND=0.0.0.0] docker/setup.sh ISO_DIR PPSSPP_HOME +set -euo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +[ $# -eq 2 ] || { sed -n '2,4p' "$0" >&2; exit 2; } +ISO_DIR="$(cd "$1" && pwd)" +mkdir -p "$2/ppsspp/PSP/SYSTEM" "$2/ppsspp/PSP/PPSSPP_STATE" +PPSSPP_HOME="$(cd "$2" && pwd)" + +# a discrete GPU over an integrated one: the last node whose driver is not i915 +node="" +for n in /dev/dri/renderD*; do + [ -e "$n" ] || continue + driver="$(basename "$(readlink -f "/sys/class/drm/${n##*/}/device/driver")")" + if [ "$driver" = i915 ]; then node="${node:-$n}"; else node="$n"; fi +done + +{ + echo "ISO_DIR=$ISO_DIR" + echo "PPSSPP_HOME=$PPSSPP_HOME" + echo "PPSSPP_UID=$(id -u)" + echo "PPSSPP_GID=$(id -g)" + echo "WEB_BIND=${WEB_BIND:-127.0.0.1}" + if [ -n "$node" ]; then + echo "COMPOSE_FILE=compose.yaml:compose.gpu.yaml" + echo "RENDER_NODE=$node" + echo "RENDER_GID=$(stat -c %g "$node")" + fi +} > "$HERE/.env" +cat "$HERE/.env" +[ -n "$node" ] || echo "no GPU render node: PPSSPP renders in software" diff --git a/ppsspp/patches/0001-debugger-savestate-save-load.patch b/ppsspp/patches/0001-debugger-savestate-save-load.patch new file mode 100644 index 00000000..85f58c8e --- /dev/null +++ b/ppsspp/patches/0001-debugger-savestate-save-load.patch @@ -0,0 +1,97 @@ +Debugger: savestate.save and savestate.load, answering once the save or load is done. + +diff --git a/Core/Debugger/WebSocket/GameSubscriber.cpp b/Core/Debugger/WebSocket/GameSubscriber.cpp +index b738bd1..54af537 100644 +--- a/Core/Debugger/WebSocket/GameSubscriber.cpp ++++ b/Core/Debugger/WebSocket/GameSubscriber.cpp +@@ -15,17 +15,25 @@ + // Official git repository and contact information can be found at + // https://github.com/hrydgard/ppsspp and http://www.ppsspp.org/. + ++#include ++#include ++#include ++ ++#include "Common/File/Path.h" + #include "Common/System/System.h" + #include "Core/Config.h" + #include "Core/Debugger/WebSocket/GameSubscriber.h" + #include "Core/Debugger/WebSocket/WebSocketUtils.h" + #include "Core/ELF/ParamSFO.h" ++#include "Core/SaveState.h" + #include "Core/System.h" + + DebuggerSubscriber *WebSocketGameInit(DebuggerEventHandlerMap &map) { + map["game.reset"] = &WebSocketGameReset; + map["game.status"] = &WebSocketGameStatus; + map["version"] = &WebSocketVersion; ++ map["savestate.save"] = &WebSocketSaveStateSave; ++ map["savestate.load"] = &WebSocketSaveStateLoad; + + return nullptr; + } +@@ -105,3 +113,54 @@ void WebSocketVersion(DebuggerRequest &req) { + json.writeString("name", "PPSSPP"); + json.writeString("version", PPSSPP_GIT_VERSION); + } ++ ++// SaveState only queues the work for the emulator thread, so wait for its callback: the reply then ++// means the file is written or the state is in place. The wait is short because this handler holds ++// the lifecycle lock, which a load that reinitializes memory also needs. ++static void RunSaveState(DebuggerRequest &req, bool save) { ++ if (PSP_GetBootState() != BootState::Complete) ++ return req.Fail("Game not running"); ++ std::string path; ++ if (!req.ParamString("path", &path)) ++ return; ++ ++ auto done = std::make_shared>(); ++ std::future result = done->get_future(); ++ auto callback = [done](SaveState::Status status, std::string_view message) { ++ if (status != SaveState::Status::FAILURE) ++ done->set_value(""); ++ else ++ done->set_value(message.empty() ? "Savestate failed" : std::string(message)); ++ }; ++ if (save) ++ SaveState::Save(Path(path), -1, callback); ++ else ++ SaveState::Load(Path(path), -1, callback); ++ ++ if (result.wait_for(std::chrono::seconds(10)) != std::future_status::ready) ++ return req.Fail("Timed out waiting for the savestate"); ++ const std::string error = result.get(); ++ if (!error.empty()) ++ return req.Fail(error); ++ req.Respond(); ++} ++ ++// Write a savestate (savestate.save) ++// ++// Parameters: ++// - path: string, the file to write, as the emulator sees it. ++// ++// Response (same event name) with no extra data once the file is written, or error. ++void WebSocketSaveStateSave(DebuggerRequest &req) { ++ RunSaveState(req, true); ++} ++ ++// Load a savestate (savestate.load) ++// ++// Parameters: ++// - path: string, the file to load, as the emulator sees it. ++// ++// Response (same event name) with no extra data once the state is loaded, or error. ++void WebSocketSaveStateLoad(DebuggerRequest &req) { ++ RunSaveState(req, false); ++} +diff --git a/Core/Debugger/WebSocket/GameSubscriber.h b/Core/Debugger/WebSocket/GameSubscriber.h +index af43c69..39134e5 100644 +--- a/Core/Debugger/WebSocket/GameSubscriber.h ++++ b/Core/Debugger/WebSocket/GameSubscriber.h +@@ -24,3 +24,5 @@ DebuggerSubscriber *WebSocketGameInit(DebuggerEventHandlerMap &map); + void WebSocketGameReset(DebuggerRequest &req); + void WebSocketGameStatus(DebuggerRequest &req); + void WebSocketVersion(DebuggerRequest &req); ++void WebSocketSaveStateSave(DebuggerRequest &req); ++void WebSocketSaveStateLoad(DebuggerRequest &req); diff --git a/ppsspp/patches/0002-arm-jit-vfpu-memcheck-offset.patch b/ppsspp/patches/0002-arm-jit-vfpu-memcheck-offset.patch new file mode 100644 index 00000000..93c470b8 --- /dev/null +++ b/ppsspp/patches/0002-arm-jit-vfpu-memcheck-offset.patch @@ -0,0 +1,31 @@ +ARM JITs: a memcheck on a VFPU load or store with a negative offset watched the wrong address. + +Masking after sign extension cleared the upper half, so lv.q/sv.q with offset -16 checked base + 0xFFF0. +The x86 and IR backends already mask before extending. + +diff --git a/Core/MIPS/ARM/ArmJit.cpp b/Core/MIPS/ARM/ArmJit.cpp +index 2940957..0fb5a5c 100644 +--- a/Core/MIPS/ARM/ArmJit.cpp ++++ b/Core/MIPS/ARM/ArmJit.cpp +@@ -93,7 +93,7 @@ static u32 JitMemCheck(u32 pc) { + const auto op = Memory::Read_Instruction(pc, true); + s32 offset = SignExtend16ToS32(op & 0xFFFF); + if (MIPSGetInfo(op) & IS_VFPU) +- offset &= 0xFFFC; ++ offset = SignExtend16ToS32(op & 0xFFFC); + u32 addr = currentMIPS->r[MIPS_GET_RS(op)] + offset; + + g_breakpoints.ExecOpMemCheck(addr, pc); +diff --git a/Core/MIPS/ARM64/Arm64Jit.cpp b/Core/MIPS/ARM64/Arm64Jit.cpp +index 4b152d2..3c7e393 100644 +--- a/Core/MIPS/ARM64/Arm64Jit.cpp ++++ b/Core/MIPS/ARM64/Arm64Jit.cpp +@@ -84,7 +84,7 @@ static u32 JitMemCheck(u32 pc) { + const auto op = Memory::Read_Instruction(pc, true); + s32 offset = SignExtend16ToS32(op & 0xFFFF); + if (MIPSGetInfo(op) & IS_VFPU) +- offset &= 0xFFFC; ++ offset = SignExtend16ToS32(op & 0xFFFC); + u32 addr = currentMIPS->r[MIPS_GET_RS(op)] + offset; + + g_breakpoints.ExecOpMemCheck(addr, pc); diff --git a/ppsspp/patches/0003-debugger-log-to-every-connection.patch b/ppsspp/patches/0003-debugger-log-to-every-connection.patch new file mode 100644 index 00000000..86fd45bf --- /dev/null +++ b/ppsspp/patches/0003-debugger-log-to-every-connection.patch @@ -0,0 +1,162 @@ +Debugger: send the log to every connection, backported from upstream 9799085c3a and 4e24c195ac. + +1.20.4 keeps one global log callback, so only the newest debugger connection gets the log, and +closing any connection stops it for all. The ring buffer also fell out of step after 1024 lines. + +diff --git a/Common/Log/LogManager.cpp b/Common/Log/LogManager.cpp +index 3c1a4b3..12ef436 100644 +--- a/Common/Log/LogManager.cpp ++++ b/Common/Log/LogManager.cpp +@@ -347,12 +347,44 @@ void LogManager::LogLine(LogLevel level, Log type, const char *file, int line, c + #endif + + if (outputs_ & LogOutput::ExternalCallback) { +- if (externalCallback_) { +- externalCallback_(message, externalUserData_); ++ // Held across the dispatch on purpose: RemoveExternalLogCallback() takes the same lock, so a ++ // listener can't be torn down (and its userdata freed) while we're in the middle of calling it. ++ std::lock_guard guard(externalLock_); ++ for (const ExternalCallbackEntry &entry : externalCallbacks_) { ++ entry.callback(message, entry.userdata); + } + } + } + ++int LogManager::AddExternalLogCallback(LogCallback callback, void *userdata) { ++ if (!callback) { ++ return -1; ++ } ++ std::lock_guard guard(externalLock_); ++ const int handle = nextExternalHandle_++; ++ externalCallbacks_.push_back(ExternalCallbackEntry{ handle, callback, userdata }); ++ EnableOutput(LogOutput::ExternalCallback); ++ return handle; ++} ++ ++void LogManager::RemoveExternalLogCallback(int handle) { ++ if (handle < 0) { ++ return; ++ } ++ std::lock_guard guard(externalLock_); ++ for (size_t i = 0; i < externalCallbacks_.size(); i++) { ++ if (externalCallbacks_[i].handle == handle) { ++ externalCallbacks_.erase(externalCallbacks_.begin() + i); ++ break; ++ } ++ } ++ // Only when the last one goes away - otherwise removing one connection's callback would stop ++ // delivery to the ones still attached, which is the bug this list exists to avoid. ++ if (externalCallbacks_.empty()) { ++ DisableOutput(LogOutput::ExternalCallback); ++ } ++} ++ + void RingbufferLog::Log(const LogMessage &message) { + messages_[curMessage_] = message; + curMessage_++; +diff --git a/Common/Log/LogManager.h b/Common/Log/LogManager.h +index 59bb1b5..026eb8c 100644 +--- a/Common/Log/LogManager.h ++++ b/Common/Log/LogManager.h +@@ -150,10 +150,15 @@ class LogManager { + void Init(bool *enabledSetting, bool headless = false); + void Shutdown(); + +- void SetExternalLogCallback(LogCallback callback, void *userdata) { +- externalCallback_ = callback; +- externalUserData_ = userdata; +- } ++ // A list rather than a single slot, because the WebSocket debugger registers one callback per ++ // *connection* (LogBroadcaster is a local in the per-connection handler) and more than one ++ // client can be attached at once - the bundled JS debugger in a browser alongside Tools/wsdbg, ++ // say. With a single slot the second connection silently took the log stream away from the ++ // first, and then the first one to disconnect cleared the slot and stopped delivery to the ++ // other as well. ++ // Returns a handle to hand back to RemoveExternalLogCallback(), or -1 if it wasn't added. ++ int AddExternalLogCallback(LogCallback callback, void *userdata); ++ void RemoveExternalLogCallback(int handle); + + void SetFileLogPath(const Path &filename); + const Path &GetLogFilePath() const { return logFilename_; } +@@ -201,9 +206,15 @@ class LogManager { + // Ring buffer + RingbufferLog ringLog_; + +- // Callback +- LogCallback externalCallback_ = nullptr; +- void *externalUserData_ = nullptr; ++ // Callbacks ++ struct ExternalCallbackEntry { ++ int handle; ++ LogCallback callback; ++ void *userdata; ++ }; ++ std::mutex externalLock_; ++ std::vector externalCallbacks_; ++ int nextExternalHandle_ = 1; + }; + + extern LogManager g_logManager; +diff --git a/Core/Debugger/WebSocket/LogBroadcaster.cpp b/Core/Debugger/WebSocket/LogBroadcaster.cpp +index 7b60a0b..61e7dce 100644 +--- a/Core/Debugger/WebSocket/LogBroadcaster.cpp ++++ b/Core/Debugger/WebSocket/LogBroadcaster.cpp +@@ -41,7 +41,15 @@ class DebuggerLogListener { + splitPoint = nextMessage_; + readCount = Count(); + } else { +- splitPoint = read_; ++ // read_ counts messages ever read, so it has to be wrapped to index the ring - the ++ // overflow branch above starts from nextMessage_, which already is an index. Without ++ // the modulo this went wrong the moment a session logged BUFFER_SIZE messages: with ++ // splitPoint >= BUFFER_SIZE the first copy loop below is empty, so the second one ++ // handed back messages_[0..readCount-1] - the oldest entries in the buffer, not the ++ // new ones - and every later poll stayed that far out of step. High-volume sources ++ // (a log-only breakpoint in a hot loop) hit it within seconds, and the result looked ++ // like the tail of the log going missing rather than being wrong. ++ splitPoint = read_ % BUFFER_SIZE; + readCount = count_ - read_; + } + +@@ -80,13 +88,14 @@ static void BroadcastCallback(const LogMessage &message, void *userdata) { + + LogBroadcaster::LogBroadcaster() { + listener_ = new DebuggerLogListener(); +- g_logManager.SetExternalLogCallback(&BroadcastCallback, (void *)listener_); +- g_logManager.EnableOutput(LogOutput::ExternalCallback); ++ // One of these exists per open connection, so it registers alongside any other client's ++ // rather than replacing it - see AddExternalLogCallback(). ++ callbackHandle_ = g_logManager.AddExternalLogCallback(&BroadcastCallback, (void *)listener_); + } + + LogBroadcaster::~LogBroadcaster() { +- g_logManager.DisableOutput(LogOutput::ExternalCallback); +- g_logManager.SetExternalLogCallback(nullptr, nullptr); ++ // Returns only once no log call is inside our callback, so the listener is safe to delete. ++ g_logManager.RemoveExternalLogCallback(callbackHandle_); + delete listener_; + } + +diff --git a/Core/Debugger/WebSocket/LogBroadcaster.h b/Core/Debugger/WebSocket/LogBroadcaster.h +index 13f26d4..543b168 100644 +--- a/Core/Debugger/WebSocket/LogBroadcaster.h ++++ b/Core/Debugger/WebSocket/LogBroadcaster.h +@@ -32,4 +32,5 @@ struct LogBroadcaster { + + private: + DebuggerLogListener *listener_; ++ int callbackHandle_ = -1; + }; +diff --git a/libretro/libretro.cpp b/libretro/libretro.cpp +index 3b2f103..6ee4d32 100644 +--- a/libretro/libretro.cpp ++++ b/libretro/libretro.cpp +@@ -1198,7 +1198,7 @@ void retro_init(void) + log_cb = log.log; + g_logManager.Init(&g_Config.bEnableLogging); + g_logManager.SetOutputsEnabled(LogOutput::ExternalCallback); +- g_logManager.SetExternalLogCallback(&RetroLogCallback, (void *)log_cb); ++ g_logManager.AddExternalLogCallback(&RetroLogCallback, (void *)log_cb); + } + + VsyncSwapIntervalReset(); diff --git a/ppsspp/patches/0004-debugger-stepping-reason.patch b/ppsspp/patches/0004-debugger-stepping-reason.patch new file mode 100644 index 00000000..d720fc53 --- /dev/null +++ b/ppsspp/patches/0004-debugger-stepping-reason.patch @@ -0,0 +1,55 @@ +Debugger: cpu.stepping says why the CPU stopped again, backported from upstream 1ce23df734. + +Core_Break() clears the step command that Core_GetSteppingReason() read the reason from, so +1.20.4 never reports which kind of breakpoint stopped the CPU. + +diff --git a/Core/Core.cpp b/Core/Core.cpp +index 5515637..0de61b7 100644 +--- a/Core/Core.cpp ++++ b/Core/Core.cpp +@@ -59,8 +59,13 @@ struct CPUStepCommand { + void clear() { + type = CPUStepType::None; + stepSize = 0; +- reason = BreakReason::None; +- relatedAddr = 0; ++ // Deliberately NOT resetting reason/relatedAddr here: they describe why we're ++ // currently paused (not whether a step is pending), and for CPUStepType::Into this ++ // clear() runs immediately after finishing the step, before SteppingBroadcaster gets ++ // a chance to read them via Core_GetSteppingReason(). Over/Out/Frame instead call ++ // Core_Resume() before reaching here, so a stale reason left behind is harmless - ++ // it'll be overwritten by the next Core_Break()/Core_RequestCPUStep() before anything ++ // re-enters stepping. + } + }; + +@@ -268,7 +273,8 @@ bool Core_RequestCPUStep(CPUStepType type, int stepSize) { + _dbg_assert_(stepSize != 0); + break; + } +- g_cpuStepCommand = { type, stepSize }; ++ BreakReason reason = type == CPUStepType::Into ? BreakReason::DebugStepInto : BreakReason::DebugStep; ++ g_cpuStepCommand = { type, stepSize, reason, 0 }; + return true; + } + +@@ -512,10 +518,15 @@ int Core_GetSteppingCounter() { + SteppingReason Core_GetSteppingReason() { + SteppingReason r{}; + std::lock_guard lock(g_stepMutex); +- if (!g_cpuStepCommand.empty()) { +- r.reason = g_cpuStepCommand.reason; +- r.relatedAddress = g_cpuStepCommand.relatedAddr; +- } ++ // Deliberately not gated on g_cpuStepCommand.empty(): that's true whenever there's no ++ // pending step *type* to execute, which is also the normal state right after Core_Break() ++ // records a reason (it sets type = CPUStepType::None on purpose - there's no step operation ++ // to perform, just a pause). Gating on empty() here used to throw the reason away in ++ // exactly that case, i.e. for every breakpoint/exception/savestate-load/etc break, which ++ // covers the vast majority of stepping events. .reason is already None whenever there's ++ // genuinely nothing to report. ++ r.reason = g_cpuStepCommand.reason; ++ r.relatedAddress = g_cpuStepCommand.relatedAddr; + return r; + } + diff --git a/ppsspp/patches/0005-debugger-game-speed.patch b/ppsspp/patches/0005-debugger-game-speed.patch new file mode 100644 index 00000000..1026cbb6 --- /dev/null +++ b/ppsspp/patches/0005-debugger-game-speed.patch @@ -0,0 +1,221 @@ +Debugger: game.speed.get and game.speed.set, backported from upstream 44b832f755. + +Stock 1.20.4 only fast-forwards from a host key binding, which a script-driven session cannot press. +The handlers set the core parameters directly, as 1.20.4 debugger handlers do. + +diff --git a/Core/CoreParameter.h b/Core/CoreParameter.h +index dcc8e30..45aa5b3 100644 +--- a/Core/CoreParameter.h ++++ b/Core/CoreParameter.h +@@ -35,6 +35,10 @@ enum class FPSLimit { + CUSTOM1 = 1, + CUSTOM2 = 2, + ANALOG = 3, ++ // Set over the WebSocket debugger (game.speed.set). Deliberately its own mode instead of ++ // reusing CUSTOM1/2: those read their rate from g_Config, which is persisted per game, so a ++ // debugger session would permanently overwrite the user's configured alternative speed. ++ DEBUGGER = 4, + }; + + class FileLoader; +@@ -76,6 +80,10 @@ struct CoreParameter { + bool fastForward = false; + FPSLimit fpsLimit = FPSLimit::NORMAL; + int analogFpsLimit = 0; ++ // Target FPS for FPSLimit::DEBUGGER. Lives here rather than in g_Config on purpose - see the ++ // enum. Resets with the rest of this struct on every boot, so a debugger can't leave a game ++ // permanently slowed down. ++ int debuggerFpsLimit = 0; + + bool updateRecent = true; + +diff --git a/Core/Debugger/WebSocket/GameSubscriber.cpp b/Core/Debugger/WebSocket/GameSubscriber.cpp +index 54af537..361a2d3 100644 +--- a/Core/Debugger/WebSocket/GameSubscriber.cpp ++++ b/Core/Debugger/WebSocket/GameSubscriber.cpp +@@ -25,12 +25,21 @@ + #include "Core/Debugger/WebSocket/GameSubscriber.h" + #include "Core/Debugger/WebSocket/WebSocketUtils.h" + #include "Core/ELF/ParamSFO.h" ++#include "Core/HLE/sceDisplay.h" ++#include "Core/RetroAchievements.h" + #include "Core/SaveState.h" + #include "Core/System.h" + ++// Declared rather than included from Core/HLE/sceNet.h, which reaches winsock and so windows.h ++// through proAdhoc.h - and that redefines the OPTIONAL macro that collides with ++// DebuggerParamType::OPTIONAL. ++bool NetworkAllowSpeedControl(); ++ + DebuggerSubscriber *WebSocketGameInit(DebuggerEventHandlerMap &map) { + map["game.reset"] = &WebSocketGameReset; + map["game.status"] = &WebSocketGameStatus; ++ map["game.speed.get"] = &WebSocketGameSpeedGet; ++ map["game.speed.set"] = &WebSocketGameSpeedSet; + map["version"] = &WebSocketVersion; + map["savestate.save"] = &WebSocketSaveStateSave; + map["savestate.load"] = &WebSocketSaveStateLoad; +@@ -88,6 +97,115 @@ void WebSocketGameStatus(DebuggerRequest &req) { + json.writeBool("paused", GetUIState() == UISTATE_PAUSEMENU); + } + ++// Percentages are relative to 60 FPS, matching how the in-app settings present the alternative ++// speeds (see GameSettingsScreen.cpp) - so a client and the UI agree on what "200%" means. ++static int PercentToFps(int percent) { ++ return (percent * 60) / 100; ++} ++ ++static int FpsToPercent(int fps) { ++ return (fps * 100) / 60; ++} ++ ++// Fails the request if something else owns the speed right now, rather than accepting it and ++// having FrameTimingLimit() quietly ignore it - silently doing nothing is the worst outcome for ++// an automation client. ++static bool CheckSpeedControlAllowed(DebuggerRequest &req) { ++ if (Achievements::HardcoreModeActive()) { ++ req.Fail("Speed control is not allowed in RetroAchievements hardcore mode"); ++ return false; ++ } ++ if (!NetworkAllowSpeedControl()) { ++ req.Fail("Speed control is not allowed while connected to a network game"); ++ return false; ++ } ++ return true; ++} ++ ++static void WriteSpeedState(JsonWriter &json) { ++ json.writeBool("fastForward", PSP_CoreParameter().fastForward); ++ if (PSP_CoreParameter().fpsLimit == FPSLimit::DEBUGGER) ++ json.writeInt("percent", FpsToPercent(PSP_CoreParameter().debuggerFpsLimit)); ++ else ++ json.writeNull("percent"); ++ json.writeInt("limitFps", __DisplayGetFrameTimingLimit()); ++} ++ ++// Request the current emulation speed (game.speed.get) ++// ++// No parameters. ++// ++// Response (same event name): ++// - fastForward: boolean, whether unlimited fast-forward is on. ++// - percent: null, or the debugger's speed override as a percentage of 60 FPS. ++// - limitFps: the frame rate throttling is actually aiming for, after fastForward, percent, the ++// user's own alternative-speed hotkeys and any restrictions have all been applied. 0 means ++// unlimited. This is the ground truth - prefer it over inferring from the other two. ++void WebSocketGameSpeedGet(DebuggerRequest &req) { ++ JsonWriter &json = req.Respond(); ++ WriteSpeedState(json); ++} ++ ++// Change the emulation speed (game.speed.set) ++// ++// Parameters (at least one required): ++// - fastForward: optional boolean, run unlimited. Wins over 'percent' while on, same as the ++// in-app fast-forward key. ++// - percent: optional integer percentage of 60 FPS (100 = normal, 200 = double speed, 50 = half), ++// or null to drop the override and go back to the game's normal rate. Must be at least 1 - ++// use fastForward for unlimited rather than 0, so there's one way to say it. ++// ++// Response (same event name): the same fields as game.speed.get, after applying the change. ++// ++// Fails if RetroAchievements hardcore mode is active, or if connected to a network game without ++// "allow speed control while connected" - in either case the setting would be ignored. ++// ++// Note this is a separate channel from the user's own alternative speeds (the CUSTOM1/CUSTOM2 ++// hotkeys and their settings): it deliberately doesn't touch g_Config, which is persisted per ++// game, so a debugger session can't permanently change what the user configured. ++void WebSocketGameSpeedSet(DebuggerRequest &req) { ++ const bool hasFastForward = req.HasParam("fastForward"); ++ const bool hasPercent = req.HasParam("percent"); ++ if (!hasFastForward && !hasPercent) ++ return req.Fail("Need at least one of 'fastForward' or 'percent'"); ++ ++ bool fastForward = false; ++ if (hasFastForward && !req.ParamBool("fastForward", &fastForward)) ++ return; ++ ++ // An explicit null clears the override; the parameter being absent leaves it alone. ++ const bool clearPercent = hasPercent && !req.HasParam("percent", true); ++ uint32_t percent = 0; ++ if (hasPercent && !clearPercent) { ++ if (!req.ParamU32("percent", &percent)) ++ return; ++ if (percent < 1 || percent > 10000) ++ return req.Fail("Parameter 'percent' must be between 1 and 10000"); ++ if (PercentToFps((int)percent) < 1) ++ return req.Fail("Parameter 'percent' is too small to produce a frame rate"); ++ } ++ ++ if (!CheckSpeedControlAllowed(req)) ++ return; ++ ++ if (hasFastForward) ++ PSP_CoreParameter().fastForward = fastForward; ++ ++ if (clearPercent) { ++ // Only stand down from a limit we set ourselves - the user may have an alternative ++ // speed of their own active, and that isn't ours to cancel. ++ if (PSP_CoreParameter().fpsLimit == FPSLimit::DEBUGGER) ++ PSP_CoreParameter().fpsLimit = FPSLimit::NORMAL; ++ PSP_CoreParameter().debuggerFpsLimit = 0; ++ } else if (hasPercent) { ++ PSP_CoreParameter().debuggerFpsLimit = PercentToFps((int)percent); ++ PSP_CoreParameter().fpsLimit = FPSLimit::DEBUGGER; ++ } ++ ++ JsonWriter &json = req.Respond(); ++ WriteSpeedState(json); ++} ++ + // Notify debugger version info (version) + // + // Parameters: +diff --git a/Core/Debugger/WebSocket/GameSubscriber.h b/Core/Debugger/WebSocket/GameSubscriber.h +index 39134e5..7688811 100644 +--- a/Core/Debugger/WebSocket/GameSubscriber.h ++++ b/Core/Debugger/WebSocket/GameSubscriber.h +@@ -23,6 +23,8 @@ DebuggerSubscriber *WebSocketGameInit(DebuggerEventHandlerMap &map); + + void WebSocketGameReset(DebuggerRequest &req); + void WebSocketGameStatus(DebuggerRequest &req); ++void WebSocketGameSpeedGet(DebuggerRequest &req); ++void WebSocketGameSpeedSet(DebuggerRequest &req); + void WebSocketVersion(DebuggerRequest &req); + void WebSocketSaveStateSave(DebuggerRequest &req); + void WebSocketSaveStateLoad(DebuggerRequest &req); +diff --git a/Core/HLE/sceDisplay.cpp b/Core/HLE/sceDisplay.cpp +index 9cc9bc7..d622fcf 100644 +--- a/Core/HLE/sceDisplay.cpp ++++ b/Core/HLE/sceDisplay.cpp +@@ -383,9 +383,15 @@ static int FrameTimingLimit() { + return fixRate(g_Config.iFpsLimit2); + if (PSP_CoreParameter().fpsLimit == FPSLimit::ANALOG) + return fixRate(PSP_CoreParameter().analogFpsLimit); ++ if (PSP_CoreParameter().fpsLimit == FPSLimit::DEBUGGER) ++ return fixRate(PSP_CoreParameter().debuggerFpsLimit); + return framerate; + } + ++int __DisplayGetFrameTimingLimit() { ++ return FrameTimingLimit(); ++} ++ + static bool FrameTimingThrottled() { + return FrameTimingLimit() != 0; + } +diff --git a/Core/HLE/sceDisplay.h b/Core/HLE/sceDisplay.h +index 2f8d05a..b23204b 100644 +--- a/Core/HLE/sceDisplay.h ++++ b/Core/HLE/sceDisplay.h +@@ -32,6 +32,10 @@ void __DisplaySetFramebuf(u32 topaddr, int linesize, int pixelformat, int sync); + // Call this when resuming to avoid a small speedup burst + void __DisplaySetWasPaused(); + ++// The frame rate throttling is actually aiming for right now, after fast-forward, the alternative ++// speeds, and the netplay/achievement restrictions have all had their say. 0 means unlimited. ++int __DisplayGetFrameTimingLimit(); ++ + void Register_sceDisplay_driver(); + void __DisplayWaitForVblanks(const char* reason, int vblanks, bool callbacks = false); +