Commit Graph
202 Commits
Author SHA1 Message Date
Henrik RydgårdandClaude Opus 5.5 a4e1db4c78 adhoc_multi.py: On CI, show a failing instance's warnings and errors
Pair tests that fail only on a CI runner give nothing to go on but the
output diff. The instance sync's and the relay's warnings say where an
instance stalled.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 09:26:37 +01:00
Henrik RydgårdandClaude Opus 5.5 56148606e5 Headless: Keep ad hoc test instances in step in emulated time
Each instance paced itself against the wall clock, so on a busy machine
the one that got less CPU fell behind, and anything a test times across
instances (a peer leaving, a matching timeout) came out differently with
the load: pair/vanish and pair/matching3 failed on macOS CI.

With --sync-file and --sync-count, instances on one machine share a file
of one slot per instance. CoreTiming ends a slice at every emulated
millisecond, publishes it there, and waits until every other instance
has reached it too, so they're never more than a millisecond apart. It
works for any number of instances. One that stops moving (a debugger
pause, a crash) is waited for 2 s and then left out until it moves
again, and one that blocks the CPU thread in real time on something the
others must do (the relay PTP connect) stands aside meanwhile.

Tools/adhoc_multi.py and adhoc_session.py use it (--no-sync turns it
off). The host's 1 s head start is gone: they wait for the server's
port instead. adhoc_multi writes each instance's output to a file, as a
full pipe would now stop the other instances too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 09:04:00 +01:00
Henrik RydgårdandClaude Opus 5.5 1f42161f0e adhoc_session.py: Wait in emulated time, and start instances at a speed
wait, press --every, stick, type and until count the instances' emulated
time (the slowest one's), so a script means the same at any speed, and
start --speed PERCENT runs the instances sped up.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 00:44:36 +01:00
Henrik RydgårdandClaude Opus 5.5 074b6bf547 test.py: Run the two-console ad hoc tests in relay and P2P mode, and in CI
The pair tests that pass go in tests_good, and each runs twice: through
the built-in server's relay and P2P. pair_known_failures lists the ones
that fail in only one mode. A P2P test that needs a loopback address
the machine lacks (macOS has only 127.0.0.1) is skipped with a warning,
and the run fails at the end with the command that adds it. The macOS
CI runners add 127.0.0.2. Each run gets a random port offset.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 00:44:36 +01:00
Henrik RydgårdandClaude Opus 5.5 0e954b17cb adhoc_session.py: A reference image's alpha channel masks what until compares
For text over a moving background: only the opaque pixels count.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 19:09:35 +01:00
Henrik Rydgård 7705639152 Merge pull request #22849 from hrydgard/adhoc-8-tools
Ad hoc 8/9: Tools for testing multiplayer with several instances
2026-10-30 10:43:49 +01:00
Henrik RydgårdandClaude Opus 4.8 b7852eace2 Tools: adhoc_decrypt.py / adhoc_recover.c for debugging real-PSP ad hoc captures
A real PSP encrypts its ad hoc link, so a sniffed capture of two consoles is
ciphertext. These decrypt and decode it (PDP/PTP/matching framing), to compare
what a game does on hardware against what PPSSPP sends at the sceNetAdhoc HLE
level - a debugging aid for hardware-vs-emulator divergence. Not needed to run
multiplayer (PPSSPP works at HLE and never encrypts) and unrelated to cross-play
(the link is console-authenticated).

The session key is ephemeral and not in the capture, but the data cipher has a
known-plaintext weakness, so a capture with some guessable plaintext is enough
to recover it - no console memory needed, and not a break of the key exchange.
The two fixed KIRK keys used already ship in ext/libkirk. docs/adhoc-packet-
decryption.md covers capturing, decrypting and recovery.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-30 10:24:15 +01:00
Henrik RydgårdandClaude Opus 5.5 6ae4ddc15a adhoc_session.py: Drop the slow network options for now
The relay's network simulation is coming separately.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 10:08:39 +01:00
Henrik RydgårdandClaude Opus 5.5 cbaa09f97b adhoc_session.py: Say that verbose log messages are compiled out
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 10:08:11 +01:00
Henrik RydgårdandClaude Opus 5.5 6b69e6d0b2 adhoc_session.py: Add type, for the system on-screen keyboard
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 10:08:11 +01:00
Henrik RydgårdandClaude Opus 5.5 9c53b53a4d adhoc_session.py: Add stick, to hold the analog stick
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 10:08:11 +01:00
Henrik RydgårdandClaude Opus 5.5 214b14fe63 Add --nickname, and give each adhoc_session.py instance its own
Games that show the system nickname in their lobbies can then tell the
instances apart. Also: press --times N --every S for menus that need
several presses, and start clears the last session's screenshots, which
could otherwise pass for this one's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 10:08:10 +01:00
Henrik RydgårdandClaude Opus 5.5 f0c6a26087 adhoc_session.py: Wait for screens, and collect logs
until waits for a region of the screen to match a reference crop (or
a pixel its color), optionally pressing a button meanwhile, since
loading screens take a different time on every run and a press before
a menu is ready is lost. crop makes the references. log sets a log
channel's level and watch collects the log messages for a while.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 10:07:57 +01:00
Henrik RydgårdandClaude Opus 5.5 8f3c49fb9d Tools/adhoc_session.py: Play a game's multiplayer with several instances
Starts N headless instances of a game against each other, with or
without the relay, and drives them over the debugger: button presses,
side-by-side screenshots, frame rates, and scripts of those. Each
instance gets its own memory stick with a copy of the savedata, so
autosaves can't collide. docs/adhoc-multiplayer-testing.md describes the
setup, by hand too, and the network simulation options.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 10:07:57 +01:00
Henrik RydgårdandClaude Opus 5.5 84c9f2d38d adhoc_multi: Show the output of tests that haven't been recorded yet
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 22:39:07 +01:00
Henrik RydgårdandClaude Opus 5.5 643abd5eb5 adhoc_multi: Use the host's built-in relay with --relay
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 13:56:56 +01:00
Henrik RydgårdandClaude Opus 5.5 6056ec419a Headless: Run several instances that talk to each other over ad hoc
--instance=N sets PPSSPP_ID, so each instance gets its own 127.0.0.N address
and MAC; headless left it at 0, which made its address 127.0.0.0.
--realtime keeps emulated time from running ahead of the wall clock. Only
display flips paced headless, and a test that doesn't draw never flips, so
two instances waiting for each other over the network timed out at once.
--adhoc-server runs the built-in ad hoc server.

Tools/adhoc_multi.py runs a multi-console pspautotest (X_host, X_client,
X_client2, ...) on one instance per role, with the built-in server or, with
--relay, a local aemu_postoffice in relay mode.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 17:13:45 +01:00
Henrik RydgårdandClaude Opus 5.5 1a0b0862a6 langtool: Add import-language, to merge a file of translations into a language
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 08:11:10 +01:00
Henrik RydgårdandClaude Opus 5.5 da411d5837 langtool: Add find-missing-keys, which scans the source for untranslated lookups
Lists the T() calls, settings page texts, config labels and choice lists whose
key isn't in en_US.ini. With --dynamic, it lists the lookups whose key is only
known at runtime instead, which it can't check.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-26 16:44:38 +01:00
Henrik RydgårdandClaude Opus 5.5 4aaebf45cf langtool: add-new-key uses the English string as the placeholder
It wrote Key = Key, which for a key like PacketRelayHint that isn't its own
English text puts the key name itself in the UI of every untranslated
language. Now it takes the value from en_US.ini, falling back to the key.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-25 08:57:51 +01:00
Henrik RydgårdandClaude Opus 5.5 6e3994aac0 CmdLine: Drop the legacy cpu core flags, use --cpu=
-i, -j, -r, -J, --ir and --jit-ir are gone. -r was also --root's short form,
and --root won, so -r took the next argument as the root path and nothing
booted (test.py -r failed every test).

-i, -j and -J now fail with an error pointing at --cpu= instead of quietly
running on the default core. Docs and test.py updated.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-21 16:05:55 +02:00
Henrik RydgårdandClaude Opus 5.5 d5e29d1a89 wsdbg: Exit non-zero when the debugger answers with an error
One-shot mode and --sync treated any reply carrying the request's ticket as
success, so a rejected memory write or resume looked like it worked.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-19 10:05:17 +02:00
Henrik RydgårdandClaude Opus 5.5 ea1ea14042 Pause screen: Ignore the pause key while another screen is on top
- Fixes #22622

Escape is both the UI back key and the default pause key. The pause
screen listens to the control mapper, which UI screens pass keyboard
input to, so Escape in Settings closed Settings and the pause screen
under it at once, dropping straight back into the game. EmuScreen
already checks IsOnTop() for the same reason.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-17 12:36:03 +02:00
Henrik Rydgård 03d66e78e1 Bump rust dependencies 2026-10-16 13:27:26 +02:00
Henrik RydgårdandClaude Opus 5.5 d59aa01341 CPU fuzzer: Add an instruction filter, and a script to run it in parallel
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 21:06:14 +02:00
Henrik RydgårdandClaude Opus 5.5 634b817869 Tools: Script to connect PSPLink to WSL
Passes the PSP through from Windows with usbipd-win (sharing it through a
UAC prompt the first time), keeps it attached across PSPLink resets and
USB port changes, and starts usbhostfs_pc.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 18:10:08 +02:00
Henrik RydgårdandClaude Opus 5.5 cdf238db7c Unit tests: Add CPUEquivalence, a differential fuzzer for the CPU cores
Runs random straight-line ALU, FPU, VFPU and load/store code from a
random state on every core and compares the full end state against the
interpreter, NaN payloads aside. Differing cases are minimized and
printed with their inputs. Options (seed, count, instruction classes,
code override, state dump for comparing hosts) are environment
variables, and Tools/cpu_fuzz_reduce.py finds and reduces a crashing
case. Known differences remain, so only a crash fails it by default.

What it found, and what's still open: docs/cpu-equivalence.md.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 00:10:00 +02:00
Henrik RydgårdandClaude Opus 5.5 f7db56628c ARM disassembler: Rewrite, cover everything the emitter can emit, add a test
The old disarm handled the integer instructions but only a fraction of VFP
and NEON, and got several of those wrong. The new one decodes ARMv7-A ARM
state with VFPv4 and NEON, printing UAL with upper case mnemonics.

The new ArmDisasm test runs every ARM emitter function through it, two
examples each. Tools/arm_disasm_check.py compares the test's output with
llvm-mc; they agree on all of it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 22:23:14 +02:00
Henrik RydgårdandClaude Opus 5.5 4163737bbc ARM64 disassembler: Cover everything the emitter can emit, add a test
Rewrites most of DisArm64.cpp. Previously missing: test-and-branch,
exceptions other than brk, hints and barriers, adc/sbc, conditional
compare, extr, literal loads, exclusives, unscaled and FP register
offset loads, ldpsw, SIMD structure loads/stores, scalar pairwise,
fixed-point conversions, fccmp, most two-reg misc ops, modified
immediates, smov/umov/dup from GPR, and the scalar shift forms.

Fixed: sp printed as x31, unsigned load offsets sign-extended, ldrsb and
friends shown as stores, adrp ignoring the page, double precision
1-source ops shown as single, fcmpe shown as fcmp, smulh/umulh, the
bitmask decode for 64-bit elements, and fpcr shown as fpsr. Common
aliases are used now (cset, mul, tst, cmn, lsl/asr/ubfx, sxtw...).

TestArm64Disasm runs two examples of every emitter function through it.
The expected strings were verified with Tools/arm64_disasm_check.py,
which compares all 833 instruction words against llvm-mc.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 20:59:51 +02:00
Henrik RydgårdandClaude Opus 5.5 2363a10a77 Re-sync gamecontrollerdb.txt with SDL_GameControllerDB, add a sync script
- Fixes #18091

The upstream part hadn't been synced since 2023. Tools/update_gamecontrollerdb.py
replaces it with the current upstream file and keeps PPSSPP's own mappings,
dropping those whose GUID upstream now has (after SDL's old-GUID conversion),
since a later line overrides an earlier one. 193 of 207 were covered,
including the PS3 and 8BitDo Ultimate 2C one-offs.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 13:49:49 +02:00
Henrik RydgårdandClaude Opus 5.5 e0f2060cd5 Tools: Find ARM64 builds in update-dispatcher.py, fix headless_bench.py options
update-dispatcher.py only knew x64 paths, so on Windows-on-ARM it regenerated from a stale x64
build. headless_bench.py handed the options after -- to argparse's REMAINDER, which newer
Pythons treat as more binaries.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-11 10:33:30 +02:00
Henrik RydgårdandClaude Opus 5.5 9dacdf8297 iOS: Add microphone support
sceUsbMic input comes from the input side of the RemoteIO unit that
already does the output. The input callback only copies samples into a
lock-free ring buffer, sized so it never allocates, and the CPU thread
drains it into Microphone::addAudioData when sceUsbMic polls, the way
the SDL backend works. Reconfiguring the unit happens on the main thread.

Whether the game is recording is kept apart from whether the unit's
input is on, so recording resumes after an interruption or a trip to
the background, which shut the unit down. The unit also runs, silently,
when sound is off but the game is recording.

While recording, the session is PlayAndRecord, with Bluetooth through
A2DP so headphones keep full quality and the built-in mic is the input.
Permission is asked for on iOS 17+ through AVAudioApplication.

Also moves the microphone recorder homebrew used to test this from
Tools/psp-microphone to pspautotests, as demos/microphone, and updates
the submodule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 17:55:21 +02:00
Henrik Rydgård 7b716e6f33 Merge pull request #22459 from chriopter/sdl-microphone
SDL: Add microphone capture and device selection
2026-10-10 17:01:04 +02:00
Henrik RydgårdandClaude Opus 5.5 b98a757157 headless_bench.py: Report CPU time on Windows too
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 15:15:04 +02:00
Chriopter 26d50ad33f Tools: Add PSP microphone recording homebrew. 2026-10-09 00:26:33 +02:00
Henrik Rydgård 8e596032f1 Merge pull request #22446 from hrydgard/misc-rendering-fixes
Misc rendering fixes
2026-10-08 11:16:14 +02:00
Henrik RydgårdandClaude Opus 5.5 d717a38a34 Tools: Add image_compare.py, comparing renderers' screenshots past rasterizer differences
A pixel counts as different only if it's outside the range of the other
image's 3x3 neighborhood (widened by a tolerance) and enough of its neighbors
are too, so edges a pixel off, filtering rounding and speckle drop out.
8x8 block averages catch broad shifts separately, and heatmaps show where.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 10:36:19 +02:00
Henrik RydgårdandClaude Opus 5.5 2db66333d4 Tools: Add headless_bench.py and dump_compare.py
headless_bench.py compares the speed of headless binaries on savestates
or frame dumps. It alternates the binaries between rounds and reports
medians of wall time, CPU time, and instructions and cycles where the OS
counts them. Instructions vary by well under 0.1% between identical runs,
where wall time swings by several percent. It checks for mains power
first, and stops on a run that failed or didn't get where it should.

dump_compare.py replays every frame dump in a directory with two binaries
and reports the dumps that render differently, or differently from run
to run.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 14:55:04 -06:00
Henrik RydgårdandClaude Opus 5.5 2fdc96e9df Headless: Always print the debugger port when --debugger=0 picks one
wsdbg --launch learns the port from our output, which without --log was
nothing. Also set the port before reporting the server as running, so
WebServerWaitForStartup() callers can read it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 15:39:08 -06:00
Henrik RydgårdandClaude Opus 5.5 cfde58575a wsdbg: Add :nowait, for requests that answer only once the CPU runs
Under --sync, input.buttons.press while stepping waited out the whole timeout,
and so did a cpu.resume issued to get it going. With :nowait the press is sent
and the next line (a cpu.runUntilTime) runs the CPU. Unanswered tickets are
now tracked in one place, so a reply that arrives during a later wait still
counts for :quit.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 13:30:42 -06:00
Henrik RydgårdandClaude Opus 5.5 a974440c79 Debugger: Time input.buttons.press in emulated vblanks
The release was counted down on the WebSocket thread, one step per poll
of host time however many vblanks had passed, so how long a scripted
press lasted depended on how fast the emulator ran, and scripted runs
went different ways. sceCtrl now releases it after that many vblank
samples, on the emulator thread; the debugger only reports when it's done.

Also: wsdbg's :screenshot works in headless with Vulkan.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 09:58:37 -06:00
Henrik RydgårdandClaude Opus 5.5 2a5febd634 wsdbg: Add :screenshot, and document nested key=value params
:screenshot saves gpu.buffer.screenshot as a PNG without dumping the data
URI into the output. The docs claimed nested parameters need a raw JSON
line, which gets no ticket; a single-quoted JSON value in key=value form
works and keeps it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 11:19:18 -06:00
Henrik Rydgård 8afbcc3747 Bump dependenciess of the Rust tools 2026-09-21 09:18:29 -06:00
Henrik RydgårdandClaude Opus 5 ddc673916d Tools/pkg.py: read PSP update packages, and notes on the format
A standalone Python reader for the .pkg containers Sony shipped PSP game
updates in: it prints a package's header, metadata, both PARAM.SFOs and its
item table, and extracts the payload. Nothing in PPSSPP calls it - it exists to
work the format out and to have a second implementation to check the C++ one
against, the way Tools/ already holds a few other one-off analysis scripts.

docs/pkg_notes.md is what it was written from: the header and item table
layout, the two AES-CTR keys a single package mixes, and the detail that trips
up a first attempt - which key applies is per item, not per package, so a
reader that picks one produces garbage filenames for most of a package while a
few entries decode perfectly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 11:14:46 -06:00
Henrik Rydgård 6a36ed7de9 Translation string for run command 2026-08-31 11:33:51 +02:00
Henrik Rydgård 6ef95ebff7 langtool: New command remove-ampersands 2026-08-31 11:31:35 +02:00
Henrik RydgårdandClaude Opus 5 6175fab373 Try to not spin so hard in the debugger when stepping
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
2026-08-29 00:07:27 +02:00
Henrik RydgårdandClaude Opus 5 c9ff8f55fe langtool: Mark strings that are deliberately the same as the English one
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
2026-08-23 16:18:07 +02:00
Henrik RydgårdandClaude Opus 5 a1d205539c langtool: Let import-single create the key in en_US.ini too
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
2026-08-23 16:18:07 +02:00
Henrik RydgårdandClaude Opus 5 47ab1daac2 langtool: Fix losing translations when two keys share an English string
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
2026-08-23 16:18:07 +02:00