Commit Graph
8 Commits
Author SHA1 Message Date
Henrik RydgårdandClaude Opus 5 a8933099b7 Make the deferred-request acknowledgement opt-in, and distinct
The acknowledgement added in "Reply to every debugger request" broke two cases
Nemoumbra pointed out, both of which come down to it reusing the request's own
event name.

A ticketless request is the bad one. {"event":"cpu.resume"} with no ticket drew
an immediate {"event":"cpu.resume"} - byte-identical to the broadcast that fires
when the game actually resumes. A client waiting for that broadcast concluded
the game was running while it was still stopped. Before, it correctly got
nothing until the resume really happened.

input.buttons.press is broken even with a ticket: it answers with the request's
own event name *and* ticket once the button has been held for the requested
frames, so the acknowledgement was indistinguishable from the real completion
and a client resolved on the first of the two. The claim in that commit that
the two are easy to tell apart was simply wrong for this handler.

So the acknowledgement is now off by default - the wire behaviour for every
existing client is exactly what it was - and a client that wants it asks, with
client.config.set {"acknowledgeDeferred": true}. It then arrives as its own
event rather than an echo:

  -> {"event":"cpu.resume","ticket":7}
  <- {"event":"deferred","for":"cpu.resume","ticket":7}
  <- {"event":"cpu.resume"}

which is unambiguous in both cases above. That still gets the original goal -
correlating any request to a reply without hardcoding which events answer
immediately, including ones added later - just without imposing it on clients
that never asked.

Also documents the ticket convention this rests on: send one when you care
about the answer, leave it off to say you aren't waiting. wsdbg followed that
convention badly, silently inserting a ticket into a raw JSON line that
deliberately omitted one; it now sends raw lines exactly as written and simply
doesn't wait on those. It opts into acknowledgements at connect, so --sync
keeps working.

pspautotests 314/314, UnitTest 55/55.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GZq8ZtJmFY7bkX5FVkr3P9
2026-08-18 09:32:04 +02:00
Henrik RydgårdandClaude Opus 5 d4e2382e1a wsdbg: one-shot mode waits for the reply, not a fixed sleep
One-shot mode drained the socket for --wait seconds (default 2) and exited,
whatever arrived. So every invocation cost two seconds regardless of how fast
the answer came, and a slower one got cut off with no indication that it had
been. Scripts calling wsdbg in a loop paid that per call.

Now that every request is answered, the reply can be matched by ticket the same
way the REPL does: return as soon as it arrives, and treat --wait (now 10s) as
an upper bound rather than a delay. A cpu.status that used to take 2.0s takes
0.03s. Not getting an answer within the bound is reported and exits non-zero
instead of passing silently.

--wait-all keeps the old behaviour, for when the point is to watch broadcasts
(log lines, gpu.stats.feed) rather than ask a question. --raw without a ticket
of its own falls back to it too, since there's nothing to match.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GZq8ZtJmFY7bkX5FVkr3P9
2026-08-18 09:32:04 +02:00
Henrik RydgårdandClaude Opus 5 28b3dd9da1 wsdbg: match responses by ticket always, and reject bad input loudly
--sync could only match a response to a request that carried a ticket, and
wsdbg only assigned tickets to its key=value shorthand. A raw JSON line - the
only way to send nested parameters, so unavoidable for broadcast.config.set and
similar - got none, so --sync had nothing to match on and skipped waiting for
that line entirely. The next line then went out immediately and its response
could be read as this one's, quietly desynchronising the rest of the script.
Nothing reported an error; the output just stopped meaning what it appeared to.

Raw lines now get a ticket assigned when they don't carry one, and are checked
before being sent: not valid JSON, not an object, no string 'event', or a
'ticket' that isn't an integer are all refused with a specific message rather
than shipped off to fail somewhere downstream. A refused line fails the run.

The waiting logic also drops its special case for events that "never respond".
That's no longer true - every request is acknowledged now - so the ticket is
always the thing to wait for. RESUME_FAMILY events additionally wait for the
following cpu.stepping, which only counts once the acknowledgement has been
seen, so a stepping event still in flight from something earlier can't be
mistaken for this command's. An error reply ends the wait immediately instead
of hanging until the timeout for a cpu.stepping that will never come.
cpu.runUntilTime joins that family, so --sync alone now blocks until it really
arrives - no :wait needed.

A --sync timeout is reported with the event and ticket it gave up on, and makes
the run exit non-zero.

Verified against CrossCraft: a script of nothing but commands (no :sleep, no
:wait) runs to exactly 1200000us, chains a relative 300000us to land on
1500000us, and returns immediately on a rejected target instead of stalling.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GZq8ZtJmFY7bkX5FVkr3P9
2026-08-18 09:32:04 +02:00
Henrik RydgårdandClaude Opus 5 a402632445 wsdbg: make a piped script able to wait, without extra processes
The REPL already reads a piped script over one connection, but there was no way
to wait *inside* it. Anything that needed a pause - let the game run a few
seconds, wait for a breakpoint that isn't the direct answer to the previous
line - had to be split across several wsdbg invocations, each paying for a
process, a TCP connection and a handshake. A polling loop built that way took
minutes per run and was the main reason driving headless felt slow.

Adds four directives that run inside the session:

  :sleep <seconds>          pause, still draining and printing messages
  :wait <event> [timeout]   block until that event arrives (e.g. cpu.stepping)
  :echo <text>              mark up the output
  # comment                 ignored

and --compact, which prints one line per message (`<- event {json}`) instead of
pretty-printed JSON and drops the banner and prompt, so a shell can grep the
output instead of reassembling it. wsdbg now also exits non-zero if a :wait
timed out, so a script can be checked without parsing output at all.

:sleep deliberately keeps reading the socket rather than blocking on a timer -
otherwise broadcasts stop printing and the connection backs up behind them.

Together with cpu.runUntilTime this collapses a repro that needed a shell
driver, a polling loop and a JSON-reassembling filter into one file:

  {"event":"broadcast.config.set","disallowed":{"logger":true,"input":true}}
  cpu.runUntilTime us=1500000
  :wait cpu.stepping 60
  cpu.status
  cpu.stepInto

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GZq8ZtJmFY7bkX5FVkr3P9
2026-08-18 09:32:04 +02:00
Henrik Rydgård 1e35f00965 wsdbg: add :snapshot/:snapshots/:diff for quick memory comparisons
New REPL-only commands, kept entirely client-side (no new PPSSPP-side
event) since a snapshot is a debugging-session-scoped concept with no
real emulator-side meaning - memory.read's existing base64 response is
already the only primitive actually needed:

- :snapshot <name> <address> <size> - blocks on a memory.read (unlike the
  rest of the REPL, which is fire-and-forget or just waits without
  looking at the payload) and stores the decoded bytes locally under
  <name>. address/size are passed through to the server exactly as
  typed, same as any other event param.
- :snapshots - list what's been captured so far.
- :diff <name1> <name2> - byte-compare two snapshots, printing each
  differing run as "+offset (N bytes): old_hex -> new_hex".

Replaces the throwaway PowerShell/Bash diffing scripts written by hand at
least twice during the VSH boot investigation (see
docs/VSHBootInvestigation.md - the sceBSMan before/after test, and the GE
display list re-checks) with one correct, reusable implementation. In
memory only for now (lives as long as the wsdbg process does, which is
fine for the actual usage pattern - one piped batch of commands per
invocation, same as everything else in this tool); can add disk
persistence later if a real need for cross-invocation snapshots comes up.

Verified live against PPSSPPHeadless: snapshot/list/diff (both a
real detected difference and an identical-buffer comparison) all produce
correct output.
2026-08-14 11:04:32 +02:00
Henrik Rydgård 25b9b60694 wsdbg: tokenize REPL lines with quote-awareness instead of splitting on any whitespace
handle_repl_line() used line.split_whitespace(), so any parameter value
containing a space (most commonly a logFormat string with multiple
{expression} placeholders, e.g. logFormat="v0={v0} a0={a0}") got silently
split into multiple bogus key=value tokens - build_event_json then either
failed outright ("not in key=value form") or, worse, sent a malformed
request with the value truncated at the first space, with no indication
to the user that their command wasn't parsed as intended. Cost real
debugging time this session before being traced to this (see
docs/VSHBootInvestigation.md).

Added split_shell_words(): a minimal shell-like tokenizer that treats
'single' or "double" quotes as protecting spaces (and the other quote
character) from being treated as a separator, stripping the quotes from
the resulting token; backslash escapes the next character, including
inside quotes. Not a full shell-parsing crate, just enough for this
tool's key=value parameters.

Verified live against PPSSPPHeadless: logFormat="job hit pc={pc} ra={ra}"
now arrives as a single correctly-quoted JSON string value instead of
being split apart.
2026-08-14 11:04:32 +02:00
Henrik Rydgård 8160a7e3eb wsdbg: add --sync to wait for responses instead of guessing sleeps
Scripted sequences piped into the REPL previously had no way to know
when a command's response had arrived, so every multi-step script in
this repo's investigation docs needed "sleep N" between commands,
guessed per case and often wrong in either direction.

--sync makes each line block until its response arrives before the
next line is read: the matching ticketed response for most events,
or (since cpu.resume/stepInto/stepOver/stepOut/runUntil/nextHLE are
all documented as having no immediate response at all - only the
eventual unticketed cpu.stepping broadcast) that broadcast for the
resume/step family specifically. Everything still prints as it
arrives; only the timing of the next send changes. Bounded by
--sync-timeout (default 30s) so a breakpoint that never trips can't
hang a script forever.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GZq8ZtJmFY7bkX5FVkr3P9
(cherry picked from commit 9ee89fd16ba4557a245ca2a96c69a9c9478cf59e)
2026-08-14 11:04:32 +02:00
Henrik RydgårdandClaude Opus 5 34b23b611e Add wsdbg, a small CLI client for the WebSocket debugger
A Rust REPL/one-shot tool for talking directly to PPSSPP's WebSocket
debugger interface (Tools/wsdbg), for scripting and manual poking during
debugging/reverse-engineering sessions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XDNwPPuidmNxQGRJxBuRL6
2026-07-26 21:01:04 +02:00