Core_ProcessStepping() returns immediately when the CPU is stopped with nothing
queued, so Core_RunLoopUntil() returns immediately, so whatever drives it comes
straight back. headless does that in a loop with no frame pacing at all, so a
paused emulator sat at 100% of a core: measured 6.02 CPU-seconds over 6 wall
seconds parked at startBreak. A debugger session is stopped most of the time, so
this also dominated any profile taken of one - showing up as synchronization
overhead around Core_RunOnCPUThread, which was just the hottest thing inside the
spin rather than a problem with the queue.
The CPU thread now blocks on a condition variable in that case. Anything that
gives it something to do wakes it - Core_RunOnCPUThread() on push (with the
queue mutex held, so it can't sleep on a task already queued),
Core_RequestCPUStep(), and Core_Resume() - so the 2ms timeout is only a backstop
for state changed without a wake, never how work is normally noticed.
The wait is deliberately short rather than indefinite: callers do real work after
Core_RunLoopUntil() returns, and in the app build that includes rendering the
ImGui debugger from this same thread, so this has to bound how long a paused
frame takes rather than replace the frame loop.
Now 0.05 CPU-seconds over the same 6 seconds. No measurable cost to anything
else: an identical scripted boot runs in 2514ms vs 2476ms before, and 20
consecutive cpu.stepInto still complete promptly. 55 unit tests pass, 314/314
pspautotests with --graphics=software.
Also: wsdbg's README claimed a raw JSON line gets a ticket auto-assigned when it
lacks one. It doesn't - the code deliberately sends raw lines exactly as written,
and omitting the ticket is how you say "not waiting for an answer". Corrected.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GZq8ZtJmFY7bkX5FVkr3P9
Plenty of strings are legitimately identical in the target language - "Vsync",
"Status", "Ad Hoc multiplayer" - but the only test we had for "is this
translated" is "does it differ from English", so those got sent to the AI again
on every single run of finish-language-with-ai, costing tokens and giving it
another chance to translate something that shouldn't be.
They now get a "# same as English" comment and are skipped. finish-language-with-ai
writes it when the AI hands the English string back unchanged (which the
validation added earlier was throwing away as an error), and import-single writes
it when a language's line matches the en_US one. Deleting the comment puts the
key back in play.
Also stop comparing values with their comments still attached while working out
what's untranslated, which was leaking "# AI translated" into the context block
of the prompt.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0164UKoXpz9r155TRfQsoc3H
It skipped the reference file, so adding a new string meant running
add-new-key-value first just to get the key into en_US.ini (and, as a side
effect, the English text into all 46 other files whether they were about to be
translated or not). Now an en_US line in the imported file is treated like any
other language, minus the "AI translated" comment, since that one is the string
the others were translated from rather than a translation.
So the workflow is one command plus validate. Languages left out of the import
simply don't get the key, and fall back to the English string at runtime -
copy-missing-lines is still there if you want the placeholder written out.
Instructions in AGENTS.md and the /add-string command updated to match.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0164UKoXpz9r155TRfQsoc3H
finish-language-with-ai maps the English string the AI translated back to the
key it belongs to, but the map was keyed on the English string, so when two keys
in a section share one - [Graphics] "Texture Filter" and "Texture Filtering",
[Error] "Error reading file" and "The file is not a valid zip file" - only the
last one survived and the other silently never got its translation.
Map to a list of keys instead and update all of them, and stop sending the same
line to the AI twice while we're at it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0164UKoXpz9r155TRfQsoc3H
Placeholders like %1 and %d have to survive translation intact, and they don't
always: the new "validate" command finds 37 strings across four language files
where one got dropped, localized into another script, or split with a space.
It also catches empty translations, line breaks and stray quotes, and exits
non-zero so it can be used as a check in a script.
The same checks now run on everything the AI returns, before it's written to a
file - plus a check that it didn't just echo the English string back at us. A
language that fails is skipped instead of aborting the whole run.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0164UKoXpz9r155TRfQsoc3H
New claude.rs talks to the Anthropic Messages API, and ai.rs picks between the
two providers. The provider is chosen with --provider, or guessed from the model
name, or from whichever of ANTHROPIC_API_KEY/OPENAI_API_KEY is set.
Also update the dependencies (and bump the clap requirement to 4.6).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0164UKoXpz9r155TRfQsoc3H
--launch starts PPSSPP itself, learns the debugger port from its output,
connects once the socket accepts, and kills it on exit. That replaces
some wrapper scripts, which had to background the emulator,
poll a log file for "Listening on port N", sleep a guessed interval before
connecting, and taskkill afterwards. The polling was also racy - a fixed port
plus a leftover process from an earlier run is a good way to drive the wrong
emulator - and nothing cleaned up, so --timeout's wall-clock budget left
processes alive for hours.
To make that dependable, the port is now also written straight to stderr from
Core/WebServer.cpp, outside the log system entirely. A tool that launches
PPSSPP has to learn the port before it can connect to anything, so that line
must not be losable to a log level or a disabled channel. wsdbg watches both
child streams for it, since which one it lands on depends on how a given build
routes logging.
--quiet sends the broadcast.config.set that every script was hand-writing,
disabling the logger and input broadcasts. The log one is expensive - each line
gets encoded as JSON and pushed down the socket.
The rest is one bug, in the docs rather than the code, which cost a lot of time:
the scripting example paired cpu.runUntilTime with ":wait cpu.stepping". --sync
already waits for the cpu.stepping that follows a resume-family command, so the
explicit :wait waits for a second one that never comes and burns the whole
--sync-timeout. With --sync-timeout 400 that turns a 3-second run into a
7-minute one that looks exactly like a slow boot, because the emulator really
has stopped where it was asked to. Example fixed, and both that and muting the
'stepping' category (same failure, different cause) are called out - wsdbg now
warns when a raw broadcast.config.set disables 'stepping'.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GZq8ZtJmFY7bkX5FVkr3P9
Two things.
wsdbg no longer loses the reply to the last request in a script. A line like
"gpu.displaylist.disasm ..." followed by ":quit" exited before the answer
arrived, and the result was indistinguishable from the request silently doing
nothing - which cost real time in this investigation before it was recognised.
Tickets that were sent and not awaited are tracked now, and :quit (or stdin EOF)
drains them, warning if any are still outstanding after ten seconds. Verified
with exactly the script that used to lose it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GZq8ZtJmFY7bkX5FVkr3P9
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
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
--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
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
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.
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.
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)
All ~82 MIPSInt::Int_* functions (Interpreter.cpp/.h,
InterpreterVFPU.cpp/.h) now take an explicit MIPSState *mips instead
of reaching for the global currentMIPS internally, along with their
file-local helpers (DelayBranchTo, SkipLikely, ApplySwizzleS/T,
ApplyPrefixD/ST, RetainInvalidSwizzleST, EatPrefixes). MIPSInterpretFunc,
Interpret(), ExecInstruction()/InterpreterDispatch.cpp (regenerated),
and RunUntilFast() all thread mips through accordingly.
Deliberately left on currentMIPS for now: MIPSVFPUUtils.cpp's
ReadVector/WriteVector/ReadMatrix/WriteMatrix/VFPURewritePrefix -
these are shared with every JIT backend's compile-time VFPU code, so
parameterizing them would balloon this into a JIT-wide refactor. This
is a partial refactor; that's the next boundary to push on.
Several JIT backends (x86 Jit.cpp, ARM/ArmJit.cpp, ARM64/Arm64Jit.cpp,
x86/X64IRJit.cpp, RiscV/RiscVJit.cpp, LoongArch64/LoongArch64Jit.cpp,
ARM64/Arm64IRJit.cpp) bake the raw interpreter function pointer
directly into JIT-generated machine code as their "fall back to the
interpreter for this one op" mechanism, with only a single argument
register set up for the call. Rather than hand-editing register
allocation across four architectures that can't be build-tested here,
added MIPSInterpretTrampoline(MIPSOpcode op) - a 1-arg wrapper around
MIPSInterpret(currentMIPS, op) - and pointed all 7 such call sites at
it instead, leaving that codegen untouched. Two other call sites
(JitLogMiss, JitBranchLog) were plain C++ calls and just got the
extra argument directly.
Verified (Windows x64): PPSSPPWindows/PPSSPPHeadless/UnitTest all
build clean, 49/49 unit tests pass. `test.py -g --graphics=software`:
interpreter 312/314 (cpu/fpu/fpu is the pre-existing, unrelated
interpreter-vs-JIT denormal difference; gpu/rendertarget/copy passes
standalone, so was cross-test state bleed in the batch run, not a
regression), default JIT 314/314, jit-ir 313/314 (gpu/vertices/morph
is an expected difference from the vertex decoder taking a different
mode with this core change, not a bug).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019SKhm9wKEQzRUsx9mTrrtQ
Adds Core/MIPS/InterpreterDispatch.cpp, the checked-in output of
GenerateInterpreterDispatch() (see the previous commit), and hooks it
into RunUntilFast() in MIPSTables.cpp in place of the old
MIPSGetInstruction()-based table walk + indirect call through
instr->interpret. The checked-with-breakpoints/memchecks path
(RunUntilWithChecks) is untouched for now, since it inspects
MIPSInstruction flags directly and correctness there matters most.
Also fixes a real crash in headless.cpp found while testing this:
cmdLineOptions.gpuBackend.value() would throw when unset (e.g. with
--graphics=software), now uses value_or().
Verified with `test.py -g --cpu=interpreter --graphics=software`:
313/314 pass; the one failure (cpu/fpu/fpu) is a pre-existing
interpreter-vs-JIT denormal (flush-to-zero) difference, confirmed to
fail identically with the old table-walking dispatch, so unrelated to
this change.
Adds Tools/update-dispatcher.py to regenerate InterpreterDispatch.cpp
from a built PPSSPPHeadless binary whenever the MIPSTables.cpp tables
change.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019SKhm9wKEQzRUsx9mTrrtQ
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