Adds GameInfoFlags::BUNDLED_UPDATE_INFO, holding the version, title, size
and timestamp of the updater in PSP_GAME/SYSDIR/UPDATE. It comes from the
PARAM.SFO and the directory entry next to the archive, so it's a couple of
small reads on the ISOFileSystem the worker already has open - no
decryption, and DATA.BIN itself is only sniffed for its magic. Only
computed for ISOs; everything else is marked complete with an empty struct.
ISOFileSystem now parses the date out of the ISO9660 directory record,
stored as Unix UTC seconds and reported as the PSP's atime/ctime/mtime.
Those used to always read back as zero, so games calling sceIoGetstat on a
UMD file saw 1900 where hardware gives the mastering date.
Shown on GameScreen as e.g. "Firmware update on disc: 6.60 (2011-10-05),
25.6 MB".
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0149QcTVgZEXKXbgHyvXF4ZY
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
Translating a UI string well means knowing what it does - what widget it is,
what the placeholders hold, how the neighbouring strings in that language are
phrased. langtool's AI commands can't know any of that, which is why their
prompt has a hand-maintained glossary that grows every time someone spots a bad
translation. An agent working in the repo can just go look.
So: AGENTS.md now describes doing the translating that way and letting langtool
do the file surgery (add-new-key-value, import-single, validate) instead of
hand-editing 47 files, and .claude/commands/add-string.md wraps it as a slash
command. Both say to skip a language rather than guess at it - the English
fallback is fine, a confident wrong translation nobody can proofread is not.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0164UKoXpz9r155TRfQsoc3H
57 strings across nine language files:
* 33 in km_KH where a space snuck in between the % and the digit ("% 1"), plus
one where "%0.2f Hz" ended up as "0.2% f Hz".
* fa_IR "Quick chat %1" had the %1 replaced by a Persian numeral, and ko_KR
"Submitted %1 for %2" lost its %2.
* tr_TR "Earned" had the key translated instead of the value, so the whole
sentence with its four placeholders was replaced by the word "Earned".
The new Turkish string needs a look from someone who speaks Turkish.
* 20 strings across five files that were wrapped in quotes. PPSSPP's ini parser
strips those on load, so nothing changes on screen, but they tripped the
validator.
Turkish writes percentages as "% 0", so the "% <digit>" fix was only applied
where it makes the placeholders match the English string again.
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
0ed6b97921 added inline no-op stubs to WebSocket.h under __LIBRETRO__, but the
older non-inline ones in libretro.cpp stayed, so every libretro build has had two
definitions of each. gcc/clang inline the trivial bodies away and emit no symbol,
so it only shows up with MSVC, as LNK2005 against Core.o/Breakpoints.o.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FzzCUp8y1ahgVueb1Cq92Y
Builds ppsspp_libretro.dll with make + cl.exe from MSYS2, for x64 and x86,
mirroring the two Windows jobs in .gitlab-ci.yml. That toolchain differs from
both the VS solution and our gcc/clang libretro builds, so it catches errors
neither of them does - multiply-defined symbols in particular, since MSVC emits
inline functions that gcc/clang inline away.
Visual Studio is located here and passed in as VsInstallRoot rather than left to
the Makefile's "cmd //c bash VSWhere.sh", which fails silently if COMSPEC or
ProgramFiles(x86) don't survive into the MSYS2 shell.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FzzCUp8y1ahgVueb1Cq92Y
log.channel.set is meant for temporary, session-only diagnostic
tweaks (e.g. quieting a noisy channel while investigating something
over the WebSocket debugger). It was going through the same
SetLogLevel/SetEnabled calls the UI settings use, so a normal app
exit would persist whatever channels/levels the debugger last left
set, silently overwriting the user's real saved log preferences for
future runs - discovered when a later session's default logging
looked "off" for no apparent reason.
LogManager now tracks whether the debugger has touched channel
config this run and skips SaveConfig() entirely if so, leaving
whatever's already on disk untouched.
Written from reading 43 firmware versions, 1.50 through 6.61, while building the
unpacker - most of it isn't obvious from the code, and several parts only show
up if you look at more than one generation of updater.
Covers the three shapes an updater arrives in, the archive header and the record
layout, the two-step block decryption (KIRK CMD7 demangle, then an ordinary PRX
blob), the entry fields, the compression formats, and the three different naming
schemes with the DES-encrypted file lists that back two of them - including
which key set goes with which firmware, though not the key material itself,
which is already in the source.
Ends with the gotchas that each cost a debugging round: walking by the length in
the header rather than the file size, the slack the decrypter needs but the last
record can't provide, not assuming which entry numbers are file lists, and the
path spelling changing between generations.
Everything in here was checked against an actual updater.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GZq8ZtJmFY7bkX5FVkr3P9
A firmware dumped off a console has its modules decrypted, while one unpacked
from an updater still has them as ~PSP blobs. IdentifyFile knew about ELF and
PBP but not that, so the loader refused those outright - even though
__KernelLoadELFFromPtr decrypts ~PSP perfectly well once it gets that far, which
is how every encrypted game EBOOT loads.
Checked against a vshmain.prx unpacked from the 6.61 updater: it now gets as far
as "Decrypting ~PSP file" in the module loader instead of stopping at
"CPU_Init didn't recognize file".
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GZq8ZtJmFY7bkX5FVkr3P9
Two small things I'd noticed as counters in a survey and written off as quirks
of the oldest firmwares. Both were bugs here.
The last entry of every 1.x and 2.x archive was dropped. Decoding a record hands
the decrypter 16 bytes of slack past the block, which was read out of the
archive - and the final record ends flush with the end, so there was nothing
there to read and the entry was refused. It copies what's there and zero-fills
only the remainder now. Worth noting the slack isn't decoration: filling it with
zeros unconditionally, which is what I tried first, breaks every archive that
does have those bytes, so the decrypter reads and uses them.
And 2.x names one entry "ipl:/psp_nandipl.bin", where the check for "is this
already a real path" only knew flash0: and flash1:. The file came out fine, but
it was counted unresolved, and - the part that matters - an entry that isn't a
real path is skipped whenever a prefix filter is set. Rather than collect device
names, the test is now for a "<dev>:/" shape, which 3.x's grouped short names
("com:00123") don't have.
Seven sources, firmware 1.50 through 6.61, now unpack with nothing unresolved
and nothing failed: 1.50 and 1.52 gain their missing last file, 2.81 loses its
unresolved count, and 3.95, 6.00, 6.20 and 6.61 are unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GZq8ZtJmFY7bkX5FVkr3P9
The font extraction this is for happens while a game is running, when disc0: is
already mounted - so opening the image a second time to read one file out of it
is the wrong shape, and doesn't work at all for the cases that aren't an image,
like a folder-based disc. UnpackUpdaterFromMountedDisc() goes through
pspFileSystem instead, with MountedDiscHasUpdater() to ask cheaply first and
ReadMountedDiscUpdaterVersion() for the version out of the PARAM.SFO.
Testing it turned up that the prefix filter didn't work on 3.x archives at all:
their file lists write paths as "flash0/font/x.pgf" where 6.x writes
"flash0:/font/x.pgf", so a filter of "flash0:/font/" matched nothing and the
unpack quietly produced no files. Both forms are normalized to the 6.x one now,
so a caller only has to know one. The unfiltered output was already identical
either way, which is why the earlier disc tests looked fine.
Verified by booting Crisis Core and pulling flash0:/font out of its 3.95 updater
through the mounted disc: 21 files, nothing else, no failures. Booting homebrew
with no disc0: at all reports no updater rather than failing.
pspautotests 314/314 with --graphics=software, UnitTest 55/55, and the three
file-based sources (6.61 PBP, 3.95 and 6.20 discs) still unpack unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GZq8ZtJmFY7bkX5FVkr3P9
Most UMDs carry a firmware updater in PSP_GAME/SYSDIR/UPDATE, so the fonts can
come from a game the user already has instead of a separate download. Its
DATA.BIN turns out to be exactly the same archive as a downloaded updater's
DATA.PSAR, just without the PBP around it.
UnpackUpdater() replaces UnpackUpdaterPBP() and takes any of the three shapes: a
downloaded EBOOT.PBP, a bare DATA.BIN/PSAR, or a disc image, which it opens with
the block device and ISO filesystem we already have and looks in SYSDIR/UPDATE.
ReadUpdaterVersion() answers the version from the PARAM.SFO next to the archive
without decrypting anything, which is cheap enough to check every disc with.
Testing across eras turned up three things the 6.61 updater alone never showed:
Old archives name entries differently. 3.x groups them by model - "com:00123",
"01g:00005" - with "<group>:00000" as that group's file list, keyed on just the
number, separated by '|' rather than ',' and with paths written "flash0/font/x"
rather than "flash0:/font/x". 1.x skips the indirection and stores real paths.
Both are handled now.
Which numbers are file lists isn't fixed either. 6.61 uses 1-11, but 6.00 has
real files at 00010-00012, which were being taken for corrupt lists and dropped.
A list always decrypts, since the PRX layer under it validates a hash, so a
failure there now just means "this is a file" - which recovered 3 files each on
6.00 and 6.20.
And the walk ran one record past the end. The archive header says how long the
records really are, and both archives have a few bytes of padding after that.
Read from the discs of Coded Arms (1.50), Ace Combat X (2.81), Crisis Core
(3.95), Assassin's Creed Bloodlines (6.00) and BlazBlue (6.20), plus the
downloaded 6.61. Every one gives up its fonts - 17 of them on 1.50, 19 on 2.81,
21 from 3.95 on. All but two are clean: 1.50 has one .rco whose block won't
decrypt, and 2.81 has one name no list claims.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GZq8ZtJmFY7bkX5FVkr3P9
An updater carries one file list per hardware revision, and which one you
resolve names against decides both what a file is called and whether it's part
of that model's firmware at all. That was hardcoded to "first list that names
it", which is right for extracting everything but wrong for reproducing what a
particular console would have installed.
PSARUnpackOptions::model takes a PSPModelGeneration now, and the lists are kept
per model rather than merged. Any (the default) keeps the old behaviour;
anything else uses only that model's list and skips what it doesn't name.
--unpack-updater-model on headless takes "01g".."12g" or "any".
On the 6.61 updater: any gives 411 files, 03g gives 330 with 81 belonging to
other models, 01g gives 313 with 98. The difference is what it should be - 03g
has the _03g.prx variants and arib.pgf, 01g has neither.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GZq8ZtJmFY7bkX5FVkr3P9
Every entry in an updater is named with a five-digit token. The paths live in
entries 00001-00012 of the same archive, one per PSP model, each a list of
"shortname,realpath" lines - so with those decrypted the walk can name
everything, and the prefix filter this was written for becomes usable.
The tables looked like they needed an unknown cipher, but the tables in the
reference implementations are DES's: a 56-entry PC-1, a 48-entry PC-2, 8x64
4-bit S-boxes and a 32-entry P, with the constants of a textbook IP/FP in the
bit-shuffling. It's DES-CBC decrypt with the key assembled from two words and
the IV alongside it, both per firmware series, and then an ordinary PRX blob
underneath. So this is a plain DES, one bit per byte, since the tables are a few
tens of KB in total and readable permutations matter more than speed here.
Confirmed by decrypting a table and finding a known PRX tag at 0xD0, which a
wrong key would not have produced.
On the 6.61 updater all 411 files now come out under their real paths -
flash0/font (21 files, ltn0-15, jpn0, kr0, arib, gb3s1518, imagefont),
flash0/kd (195), flash0/vsh/module (100), flash0/vsh/resource (67), and the
rest - with nothing unresolved. Checked the prefix filter separately with
"flash0:/font/": 21 files written and nothing else.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GZq8ZtJmFY7bkX5FVkr3P9
An updater's DATA.PSAR is a flat sequence of records - each is 0x150 bytes of
PRX-style encryption header, a 0x110 byte entry describing one file, and then
its compressed contents. So to get the files, you don't actually have to run it
and let it self-unpack - we can just do it.
Two steps per record. First "demangle": the 0x130 bytes at +0x20 are AES-CBC
encrypted on top of everything else and hide the PRX tag at +0xD0, so a KIRK
CMD7 pass with keyseed 0x55 comes first. Then the record is an ordinary PRX blob
for the decrypter we already have, once it knows the tag - 0x0E000000, which is
new here. Its key needs the kirk7 scramble applied, unlike every other key in
that table, which are stored already scrambled; hence the flag on TAG_INFO.
UnpackPSAR() takes a prefix filter, since the planned main use for this is pulling
flash0:/font out of an updater the user supplies (or from an ISO) rather than
extracting whole firmwares, although that can also be interesting for running
the VSH.
Tested on a 6.61 updater: 436 entries, all 418 files decrypt and decompress,
nothing fails. The contents are what they should be - 295 ~PSP modules, 61 PRF
files, 18 PGF fonts, and the encrypted XMB indices.
Two things it doesn't do yet. Every entry in that archive is named with a
five-digit token rather than a path; the real names live in tables 00001-00012
inside the archive itself, under their own separate encryption, so files come
out under the short name for now and the prefix filter can't match them.
And only the zlib compression format is currently supported.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GZq8ZtJmFY7bkX5FVkr3P9
Fixes cases where EBOOTs have more than 32 program headers. That limit came from
segmentVAddr being a 32-entry array indexed by program header number
Changes the table to a vector, and holds SEGMENT_NOT_LOADED for
headers that aren't PT_LOAD, so a relocation naming one is rejected and logged
rather than quietly relocating against zero. GetSegmentVaddr() still answers 0
for those, as it did when the table was a zero-initialized array.
--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
NormalizePath's Windows branch ignored GetFullPathName failing. A zero return
matched neither of its two branches, so the 512-character buffer was handed back
with whatever was in it.
The content-URI branch then computed its relative path by substr()ing the
decoded FilePath() of the two URIs - but the containment check above it compared
the URIs themselves, which doesn't imply the same prefix relationship for the
decoded paths. When it doesn't hold, substr() throws out_of_range, uncaught.
Checked explicitly now.
More minor fixes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GZq8ZtJmFY7bkX5FVkr3P9
__StartLogAudio ignored WaveFileWriter::Start()'s return value and set m_logAudio
either way, so a wav file that couldn't be opened - read-only or full audio
directory, most likely - left every subsequent mixed block calling
AddStereoSamples on a closed file. That opens with _assert_msg_(file, ...).
Also other minor fixes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GZq8ZtJmFY7bkX5FVkr3P9