DrawLine weighted the endpoints by (steps - i) / steps with i running 0 to
steps - 1, which samples each pixel's leading edge. The last pixel therefore sat
a full step short of v1 and the end vertex's values were never used at all.
Visible in gpu/texmtx/prims, whose line strip ends exactly on the bottom-left
pixel: with normal-projected texgen that pixel should carry v3's texcoords
(texel 0, 255) and we produced (1, 254). Working the interpolation out by hand
against the hardware reference pins the correct parameter at (i + 0.5) / steps,
the pixel center - the same rule the triangle and sprite paths now use. Sampling
the endpoint itself is wrong too: that gives (0, 0), because t/q lands on exactly
256 and wraps.
Kept in halves so the color interpolation stays integer.
The textual half of gpu/texmtx/prims now matches the hardware reference exactly.
The test still fails on its screenshot comparison, whose MSE is unchanged to six
decimals - that's a different primitive type, not the lines.
317 pspautotests pass, 0 fail.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Vd8ntC2brCUtCrDJMqLbs8
Two leftovers from the sample point having been a sixteenth of a pixel off
center. They're one commit because they can't be separated: each was
compensating for the other, so applying either alone makes
gpu/filtering/precisionnearest3d fail. I tried both orderings.
1. DrawRectangle advanced ST to the first sample with (minX - entireX1 + 1).
The +1 existed to make up for centerOff being 7 instead of 8; now that minX
already sits at the pixel center it double-counts and pushes the texture
coordinate a sixteenth of a texel too far. Only visible when that lands
exactly on a texel boundary, which is what precisionnearest2d's offset-7 case
constructs: the sprite spans x from -7/16, so u at pixel 0's center is 0.9375
and should sample texel 0, but the extra sixteenth made it exactly 1.0.
2. Removing the +1 exposed the other one. ClipToScreenInternal used a plain
(int) cast, which truncates toward zero - so once the region offset makes the
value negative it rounds the opposite way from the positive case, and from
the through-mode path that computes screenpos directly. The two disagreed by
one subpixel for negative coordinates, which is why the 2D and 3D variants of
the same test failed at different offsets, 7 and 8. floorf is what the +0.375
nudge was always meant to pair with, and it makes both paths agree.
Now passing: gpu/filtering/precisionnearest2d, promoted to tests_good since it
passes on Vulkan too. precisionlinear2d and precisionlinear3d also start passing
on software but still fail on the hardware backends, so they stay in tests_next
with a comment saying so, as does gpu/primitives/continue from the last commit.
317 pspautotests pass, 0 fail.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Vd8ntC2brCUtCrDJMqLbs8
TriangleEdge::Start's comment says "Start at pixel centers", but centerOff was
(SCREEN_SCALE_FACTOR / 2) - 1, i.e. 7 of 16 - a sixteenth of a pixel short of the
real center at 8. DrawRectangle and DrawPoint had the same off-by-one.
Found via gpu/texmtx/uvs, which draws a quad whose UVs span 0..2 across 256
pixels, so one texel is half a pixel and the error is exactly visible: the PSP
samples texel 2x+1 and we sampled 2x. The V axis happened to land a quarter texel
above an integer, so it rounded to the right answer and only U was visibly wrong,
which is what made this look asymmetric. Confirmed by logging the interpolated s/t
at the corner pixels: our sample point sat at pixel + 0.4375 rather than + 0.5.
Both hardware backends already pass that test, so this was softgpu-only.
Now passing and promoted to tests_good: gpu/texmtx/uvs and
gpu/filtering/precisionnearest3d, both of which also pass on Vulkan.
gpu/primitives/continue now passes on software too, but still fails on Vulkan, so
it stays in tests_next for now.
316 pspautotests pass, 0 fail.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Vd8ntC2brCUtCrDJMqLbs8
TODO list at the top of SoftGpu.cpp covering the remaining UB and the platform
differences we haven't settled yet, with enough detail to act on each without
re-deriving it. Also drops a duplicated GPUCommon.h include in SoftGpu.h.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Vd8ntC2brCUtCrDJMqLbs8
GCC defaults to -ffp-contract=fast and Clang to on, so both fuse a*b + c into a
single FMA where the hardware has one; MSVC /fp:precise doesn't contract at all.
On aarch64 FMLA is baseline, so the same source gives different depth, fog and
lighting values on Android/Linux than on Windows-on-ARM - exactly the kind of
same-architecture difference we're trying to eliminate. x86-64 only escapes today
because the SSE4.1 baseline has no FMA, which -march=native or x86-64-v3 (as used
by distro and Flatpak packagers) would undo.
Set per-source so it also covers the Math3D.h scalar operator chains inlined into
these TUs, which is where it actually matters. MSVC needs nothing.
Note this only covers the CMake build, which is what the shipping Android build
uses. The legacy android/jni ndk-build and libretro/Makefile.common have no
per-file mechanism, so they'd need it applied globally - left for the wider GPU/
evaluation.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Vd8ntC2brCUtCrDJMqLbs8
The SSE4 and NEON versions summed pairwise, the scalar Dot() sums left to right,
and float addition isn't associative. Which one ran depended on the build - the
SSE path excluded 32-bit x86 entirely, so Win32 and x64 disagreed on the same
machine - and on cpu_info.bSSE4_1, so one binary disagreed with itself across two
x86 CPUs.
Not cosmetic: the result is fogdepth, which Clipper compares against v0's to
decide whether to split a rectangle into two draws, so a ULP changed the number
of primitives emitted. A 4-element dot product is not where the time goes.
Also parenthesized the sum in Dot(Vec4) so the association is explicit in the
source rather than left to the reader knowing the grammar.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Vd8ntC2brCUtCrDJMqLbs8
Unqualified pow picks up the double overload on platforms whose <cmath> only
drops the C version into the global namespace, which computes the whole thing at
double precision and rounds once at the end - a different result decided by the
standard library rather than by us. std::pow always gets the float overload.
That's only half the problem, so there's a TODO next to it: powf isn't correctly
rounded, so it differs between glibc, musl, bionic, Apple's libm and the UCRT,
and all three callers feed the result into a LightCeil - which turns a one-ULP
difference into a full 1/512 step in the light factor instead of letting it wash
out. The PSP almost certainly uses a fixed approximation of its own; settling
that against the pspautotests rendering tests is a job for once the platform
differences are gone and there's a stable base to compare against.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Vd8ntC2brCUtCrDJMqLbs8
OptimizePendingStates sat four lines below the `!tasksSplit_ || waitable_->Empty()`
check that makes touching shared state safe. It memcpys a 71-byte PixelFuncID over
a RasterizerState and swaps drawPixel/samplerID, while worker threads copy those
same entries by value to rasterize from - so a primitive could be drawn with the
new drawPixel against the old pixelID bytes.
Moving it inside the guard costs nothing correctness-wise: skipping a round just
means those draws use the unoptimized function, and the next Drain with an empty
waitable picks up the whole accumulated range.
This does not close the whole race - Add* still ORs into states_[stateIndex_].flags
after pushing, which items dispatched by an earlier Drain can be reading. That one
needs the state entries to become copy-on-write once dispatched, which is a bigger
change.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Vd8ntC2brCUtCrDJMqLbs8
Vec4<float>::Cast<int>() used _mm_cvtps_epi32 under SSE, which rounds using
MXCSR's mode, while NEON's vcvtq_s32_f32 and the scalar (T2)x fallback both
truncate. Same split in Rasterizer's InterpolateI.
That's on depth interpolation, so the differing values are written to the depth
buffer and then compared - a one-LSB difference can flip a later GE_COMP_EQUAL
pass and change a whole surface's visibility, not just a shade. Following MXCSR
also meant anything that left a non-default rounding mode in the render thread
would have changed rasterized output.
Truncation is what two of the three paths already did, so SSE moves to match.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Vd8ntC2brCUtCrDJMqLbs8
The SSE, NEON and scalar versions summed the components in three different
orders, so Length() alone differed by architecture. Worse, the SSE normalize
used _mm_rsqrt_ps with no Newton step - about 12 bits - where NEON used vrsqrte
plus two refinement steps and the generic path used exact sqrtf. Three accuracy
tiers for one function, and the SSE2-vs-SSE4.1 choice was made from cpu_info at
runtime, so one binary gave different answers on two different x86 CPUs.
This isn't shading-only: the results feed environment-map texture coordinates via
GE_PROJMAP_NORMALIZED_NORMAL and Lighting's GenerateLightST, so it moves UVs.
sqrtf and division are correctly rounded per IEEE-754, so a single scalar version
is bit-identical everywhere. The horizontal sums being removed were never much
faster than the three multiplies they replaced. The useSSE4 parameter is kept so
the call sites don't churn, but it no longer selects anything.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Vd8ntC2brCUtCrDJMqLbs8
Jit_GetTexelCoordsQuad converted s*w*256 with CVTPS2DQ, which rounds. The C++
reference casts with (int), and the nearest JIT paths use CVTTPS2DQ - the quad
path just never got converted when the others did. The JIT's sample point sat up
to 1/512 texel further along than the interpreter's, so roughly one pixel in
sixteen picked a different frac_u/frac_v, and at exact texel boundaries a
different texel.
That mismatch is visible wherever the two paths coexist: x86-64 desktop runs the
JIT, 32-bit x86 and UWP have no sampler JIT at all, and even within one x86-64
session the first draws with a new SamplerID run the C++ path while later ones
run the JIT. CVTPS2DQ also honors MXCSR's rounding mode, so anything that left a
non-default mode in the render thread would have changed rasterized output.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Vd8ntC2brCUtCrDJMqLbs8
Expand did `height += ((int)base - (int)newBase) / (stride * bpp)`. The right
operand is unsigned, so when newBase was above base the negative difference
converted to ~4 billion before the division and height wrapped. After that,
HasPendingWrite()'s `start >= base + height * strideBytes` early-out was taken
for every query, so the binner stopped noticing that a draw textures from the
framebuffer it's writing - skipping the flush and leaving maxTasks_ high, which
makes a self-sampling draw depend on which worker thread got there first.
Reachable without exotic state: scissor changes mark BINNER_RANGE dirty without
forcing a flush, so drawing with the scissor top at y=0 and then moving it down
is enough. Handle both directions explicitly instead.
ClearDirty was missing the bounds clamp its twin MarkDirty has. start is masked
to [0, 2047] but bytes isn't bounded - IsVRAMAddress accepts the whole mirrored
8MB window - so a large guest framebuffer near the top of VRAM runs the loop off
the end of vramDirty_[2048] and writes into whatever follows it. Only active with
frameskip enabled, but then it runs every flip.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Vd8ntC2brCUtCrDJMqLbs8
The jitted GE_LOGIC_NAND ANDed into temp1Reg, which is uninitialized at that
point and whose result is never read - leaving just the NOT, i.e. COPY_INVERTED
rather than ~(new & old). Every bit where new is 1 and old is 0 came out
inverted. It differed between x86-64 (which has the pixel JIT) and ARM64/UWP/x86
(which don't), and even within one x86-64 run, since GetSingleFunc falls back to
the C++ path when it has to queue a compile.
Subtractive blending clamped to 0 on SSE and in the JIT (PSUBUSW) but not on
NEON (vqsubq saturates at INT_MIN) or the generic path. Normally invisible
because ToRGB() clamps at the end - except the caller adds the dither value in
between, so an underflowing dithered pixel came out up to 7/255 brighter on x86
than on ARM64. Clamp in all paths.
stencil << 24 is signed overflow for stencil >= 128, which is the common case,
not an edge case. Cast to u32 - SetPixelStencil already does this correctly.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Vd8ntC2brCUtCrDJMqLbs8
Rasterizer.cpp: the secondary color fallback does `prim_color[i] = ` where the
SSE and NEON branches add, so the base/texture color is thrown away and the
triangle renders as pure specular. Introduced by 250abe0d56 (Loongarch64 build
fixes) changing one character; the structurally identical block in DrawRectangle
still has the `+=`. Live on ARM32, LoongArch64, RISC-V64 and anything else that
isn't SSE2 or ARM64.
Lighting.cpp: IsLargerThanHalf's scalar path assigns instead of accumulating in
its loop, so it returns only `v[2] > 1` and ignores the other components, and the
NEON path computes a max where SSE computes a sum. All three disagreed. The
question being asked is "is this color factor non-zero" - the test this replaced
in fcc3b7684e was `!(colorFactor == ones)` - and since LightColorFactor produces
2*c+1, every component is >= 1 and the sum of four is >= 4, which is why the SSE
sum > 4 is the correct one. Made the other two match it.
Getting this wrong doesn't shift a shade, it enables or disables a whole light:
x86-32 and ARM64 were switching lights off that x86-64 left on.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Vd8ntC2brCUtCrDJMqLbs8
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 <[email protected]>
Claude-Session: https://claude.ai/code/session_01GZq8ZtJmFY7bkX5FVkr3P9
GameInfoTex::Clear() only reset dataLoaded when there was data to clear, but
several paths deliberately set it on a file that turned out not to exist (the
ARCHIVE_ZIP case, the "no icon" fallback). Those kept dataLoaded across a
Clear(), so FinishPendingTextureLoads stamped timeLoaded again and the tex read
as permanently Failed().
PurgeType slept 10ms even when it had nothing to retry.
Fix three comments that no longer described the code: Clear() doesn't start a
thread, Priority() no longer calls GetFileLoader(), and the work item's
destructor doesn't touch the flags - Run() has to mark them itself, which is
worth stating since missing it strands them in pendingFlags for good.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01FzzCUp8y1ahgVueb1Cq92Y
The work item wrote title, id, id_version, region, errorString, hasConfig and
gameSizeUncompressed with no lock held, while the main thread reads them under
info->lock. title is the sharp one - an unsynchronized std::string write against
a locked read in GetTitle()/GetDBTitle() is a real data race, not just a stale
read. SetTitle() already existed and was used in exactly one of the six places.
The two expensive calls (HasGameConfig, which hits the file system, and
GetSizeUncompressedInBytes) stay outside the lock - the main thread takes it
every frame, so blocking on I/O under it would show up as UI stutter.
PurgeType read hasFlags/fileType/pendingFlags under mapLock_ only, racing the
worker's MarkReadyNoLock. It also erased entries without dropping their
textures, unlike Clear() - so a work item that finished just before PurgeType
took the lock could be left holding the last reference, and ~GameInfo would
then release GPU textures on a worker thread.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01FzzCUp8y1ahgVueb1Cq92Y
GetInfo() masked out any flag that a *pending* work item was already going to
fetch, FILE_TYPE included. But every work item starts by switching on
info->fileType, so "another item will compute it" isn't good enough - if that
item hadn't reached Identify_File yet, the second one fell through to default:,
marked its flags ready and loaded nothing. The data then looked present forever,
so e.g. a PIC1 requested while an ICON load was in flight could just never show
up. Easy to hit since the screens request different flag combinations for the
same path, and BackgroundAudio calls GetInfo from the audio thread.
Always redo the identification unless FILE_TYPE is already in hasFlags (i.e.
final), and have Run() switch on a local copy so a concurrent item can't shift
it underneath us mid-switch.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01FzzCUp8y1ahgVueb1Cq92Y
- The SND branch for PSP_DISC_DIRECTORY set pic1.dataLoaded instead of
sndDataLoaded, copy-pasted from the PIC1 branch above it. Asking for SND
without PIC1 left pic1 marked as loaded with no data, so SetupTexture
stamped timeLoaded and pic1.Failed() stayed true for good.
- The SIZE branch wrote two locals that were only ever 0 into saveDataSize
and installDataSize, wiping what a previous SAVEDATA_SIZE fetch computed
while hasFlags still claimed it was valid.
- GetDBTitle() returned the filename when a title existed but PARAM_SFO
didn't, and an empty string in the opposite case - the condition was
inverted. Look up the DB when we have an id_version, then fall back the
same way GetTitle() does.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01FzzCUp8y1ahgVueb1Cq92Y
This is a leftover from the old "native" library. Its only users are the Win32 GE
debugger's preview windows, which call glsl_create_source/destroy/bind/unbind and
read four locations off the struct.
Everything else was dead: glsl_create was declared but never defined anywhere,
which made the entire file-loading and auto-reload half of glsl_recompile
unreachable (glsl_create_source always passes empty filenames), along with the
mtime fields, AutoCharArrayBuf and the VFS/stat includes. glsl_attrib_loc,
glsl_uniform_loc and glsl_get_program had no callers, and the active_programs set
was written and never read. The unused convenience locations cost a
glGetUniformLocation round trip each at link time.
The bug: the vertex shader was leaked when its own compile failed - the fragment
path right below it already deleted it correctly. Failed links leaked the program
object too.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Vd8ntC2brCUtCrDJMqLbs8
The Intel workaround sscanf'd "Build %d.%d.%d.%d" against glGetString(GL_VERSION),
which reads like "4.5.0 - Build 26.20.100.7870" - sscanf literals have to match
from the start, so it never returned 4 and HasIntelDualSrcBug was never consulted.
It's been inert since it was written, and the drivers it targeted are long gone.
Removing it orphaned the two helpers, so those go too.
Separately, when gl3stubInit() fails we left ver[0] at 3 while clearing GLES3.
Extension enumeration keys off the version, not the flag, so it went on to call
glGetStringi - one of the very entry points whose absence makes gl3stubInit()
fail. Drop back to 2.0 on that path, like the branch above it already does, and
null-check what glGetStringi hands back.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Vd8ntC2brCUtCrDJMqLbs8
* TransitionDepthStencilImageAuto set dstAccessMask to TRANSFER_READ_BIT for
TRANSFER_DST_OPTIMAL. The color path and this function's own source-side switch
both use TRANSFER_WRITE_BIT - it's a copy-paste from the TRANSFER_SRC case two
lines up. Every depth copy and blit went through it.
* VulkanMayBeAvailable's per-device loop did anyGood = !blacklisted, overwriting
the verdict from earlier devices, so a blacklisted GPU enumerated after a good
one hid the Vulkan backend entirely. Hybrid-GPU machines are exactly what the
blacklist targets.
* The instance extension scan stopped as soon as it found the platform surface
extension, so a driver reporting that before VK_KHR_surface made us give up
with "Platform surface extension not found". Enumeration order isn't specified.
* CreateDevice only logged when vkCreateDevice failed, then carried on to report
success, call VulkanSetAvailable(true) and build a VMA allocator on a null
device behind an assert that's live in release builds.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Vd8ntC2brCUtCrDJMqLbs8
The previous commit moved everything out of the list before running callbacks, to
avoid appending to a vector being iterated. That regressed device teardown: a
callback can queue more deletes (~VKFramebuffer does, via ~VKRFramebuffer, which
queues image views, image allocations and framebuffers), and those land back on a
list that used to be picked up by the object loops later in the same pass.
That's harmless for the per-frame lists, since callbacks queue onto the global
list and a later frame drains it. But PerformPendingDeletes() drains the global
list itself, and DestroyDevice() calls it immediately before vmaDestroyAllocator
and vkDestroyDevice - so the re-queued objects were never destroyed at all.
Loop instead. In the per-frame case that's one extra empty lap.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Vd8ntC2brCUtCrDJMqLbs8
pipelineLayouts_ was mutated from the main thread (CreatePipelineLayout, and the
deferred callback queued by DestroyPipelineLayout) while the render thread walked
it every frame in FlushDescriptors. Exiting a game in Vulkan mode hits this
reliably: ~GPU_Vulkan stops the render thread and destroys the draw engine's
layout, but the destruction is deferred onto the delete list and doesn't actually
run until a BeginFrame two frames later, with the render thread running again.
Guard the list, and the lifetime of the layouts in it, with a mutex.
The global delete list had the same problem - VulkanDescSetPool::Recreate queues
the old pool from FlushDescSets on the render thread, which happens for real once
a game goes past the initial 1024 descriptors, while the main thread moves the
list into the current frame's list in EndFrame(). Lock the queueing functions and
Take's source list.
While in there:
* Take() didn't move queryPools_, so query pools queued for deletion sat on the
global list until device teardown instead of being deleted a few frames later.
* PerformDeletes now drains into a local list before destroying anything. A
callback is allowed to queue further deletes (~VKFramebuffer's does, via
~VKRFramebuffer), which used to append to the very vector being iterated.
They now get the normal deferral instead of running in the same pass.
* Missing semicolon in BeginFrame that only compiles because VLOG is empty.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Vd8ntC2brCUtCrDJMqLbs8
Unlike the directional moves, this doesn't look at where anything ended up on
screen - it walks the hierarchy in the order views were added, flattening nested
groups in place. That's what makes it predictable in the layouts where "what's
to the right of this" has no good answer.
A view is a stop if it's focusable and enabled, the same test the directional
moves apply, so the two agree on what's reachable. Hidden subtrees are skipped
whole, which is what keeps a TabHolder's inactive tabs - V_GONE rather than
removed - out of the order without any special casing. Containers are gated on
visibility only, not enabled, matching Key/Touch/Axis: disabling a container
doesn't stop its children being interactive anywhere else either.
Ctrl+Tab stays with ChoiceStrip, which uses it to switch tabs.
focusMoves now holds FocusMove rather than raw keycodes, so the direction is
decided in one place while the modifiers are still around, and a held key
repeats in the direction it was originally pressed with - the synthesized repeat
has no modifiers of its own. That also retires the keycode switch in
UpdateViewHierarchy and IsScrollKey, which had no other callers.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01SfY7iFJEjmRXf1XGrTs4MF
NativeKey builds a copy of the key with the Ctrl/Shift/Alt/Meta flags attached,
but has been queueing the original ever since a47edbf6ef moved the dispatch from
a direct g_screenManager->key(modKey) call to the event queue - so modKey has
just been dead since then, and nothing downstream ever sees a modifier.
That's every shortcut matched on one: Ctrl+Tab tab switching in ChoiceStrip,
Ctrl+F in the game list, and Ctrl+C/V/Z in text fields.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01SfY7iFJEjmRXf1XGrTs4MF
These two raised the memory exception and then went ahead and did the access
anyway, unlike every other load/store here. A quadword access that isn't
16-byte aligned isn't valid, so there's nothing to carry out - and on 64-bit,
where GetPointerUnchecked is base + address with no masking, an address that
failed the validity check meant dereferencing whatever that landed on.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01SfY7iFJEjmRXf1XGrTs4MF
vrot clears the D prefix for the cosine lane, since the prefix doesn't apply
there, but shifted the saturation mask by cosineLane rather than cosineLane * 2.
That field is two bits per element - ApplyPrefixD reads it as (data >> (i * 2))
& 3, and every other site in the file shifts accordingly - so for lanes 1 and up
it cleared the wrong lane's saturation and left the cosine lane's in place. The
mask field next to it is one bit per element and was already right.
Only reachable through the interpreter, but that includes the JITs, which fall
back here for any prefixed vrot.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01SfY7iFJEjmRXf1XGrTs4MF
ins derived its width as (_SIZE + 1) - pos, which is zero or negative when the
encoded msb is below pos: the following shift is then 32 or more, undefined, and
on x86 produces an all-ones mask that writes bits the JITs don't touch. Build
the mask from msb and shift it down instead, which is what the JITs do and can't
shift out of range. Hardware calls that encoding unpredictable, so consistency
is all that's wanted here.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01SfY7iFJEjmRXf1XGrTs4MF
The alignment checks added in d8edeb7649 return out of the instruction handler
without advancing PC, so with IgnoreBadMemAccess - which is the default, and
which makes Core_MemoryException log and return - the run loop comes straight
back to the same instruction and never gets past it. cpu/crash/crash_read_u32
under -i logged the same SIGSEGV 585096 times in 30 seconds before being killed;
the JIT runs it to completion.
Continue instead, the way Memory::Read_U32 did before those checks existed and
the way the JIT's safe-memory path still does: loads produce zero, stores are
dropped, PC advances. When the exception is set to break rather than ignore,
Core_Break has already stopped the core by the time we get here, so nothing
changes for that case.
lv.q/sv.q are left alone - they already fall through and do the access.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01SfY7iFJEjmRXf1XGrTs4MF
Every JIT backend puts the host FPU into the mode fcr31 asks for (bits 0-1 and
24) before running emulated code, and takes it back out before calling any host
code. The plain interpreter did none of that, so all its float math rounded to
nearest with denormals intact no matter what the game had set - cpu/fpu/fpu
fails under -i and passes under the JIT on exactly this.
Move the helpers the IR interpreter already had for this out of IRInterpreter
and into MIPS.cpp as ApplyHostRoundingMode/RestoreHostRoundingMode, and use them
around the interpreter's run loop and single step, restoring around syscalls and
replacement functions, which are host code. ctc1 re-applies immediately, since
the interpreter has no block boundary to defer it to.
round.w.s changes with it: it was floorf(x + 0.5f), which is half-away-from-zero
rather than the half-to-even every JIT produces, and the add would now pick up
the guest's rounding mode on top of that. round_ieee_754 is both correct and
mode-independent, and is what cvt.w.s already used for the same rounding.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01SfY7iFJEjmRXf1XGrTs4MF
The disasm window cached the flattened symbol list and only rebuilt it when one
of three menu items said so. Nothing marked it dirty when a game booted or
exited, and a new SymbolMap is allocated per boot, so the list kept showing the
previous game's functions.
Give SymbolMap a version counter that every mutator bumps, and let the window
compare against it instead. The counter is process-wide rather than per-map, so
a fresh map can't hand out a version a cached copy already holds.
Also re-find the selected symbol by address after a rebuild (the index means
something else afterwards), and drop the unused symbol cache members in
ImMemWindow.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01SfY7iFJEjmRXf1XGrTs4MF
C++ homebrew has an unreadable symbol table -
everything is _ZN10PxRenderer7DrawImmE... - which makes the disassembly and
symbol list nearly useless. Add an Itanium C++ ABI demangler and run ELF
symbols through it on load, in both ElfReader::LoadSymbols (unstripped EXECs,
which is what a CMake pspdev EBOOT actually contains) and the companion-ELF
path.
The demangling standard is called Itanium for historical reasons - it
was defined for Itanium but ended up being almost universally
applicable.
Written from scratch rather than using __cxa_demangle, which doesn't exist on
MSVC/UWP, or vendoring LLVM's demangler, whose license doesn't fit. Anything
unrecognized (arbitrary constant expressions, decltype) aborts the parse and
the caller gets the original mangled name back, so a caller never sees a
half-parsed result. Recursion is depth-capped since the input comes from a
file we didn't write.
Checked against c++filt as an oracle: of 1089 mangled symbols in a real C++
homebrew EBOOT, one differs; of 55189 from libstdc++/libLLVM/cc1plus, 22
differ and 413 are declined. Fuzzed with 220k mutated and random inputs under
ASan/UBSan.
Also adds a right-click menu to the ImDebugger symbol list.
Note that SymbolMap stores names in char[128], so the longest STL names get
truncated in the UI. Still far more readable than the mangled form.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01X3DbkJ8ShYiXU7q5Tv1LZu
When launching a file from outside the main screen (file association, shortcut,
drag-and-drop), the info hasn't been computed yet, so the file type and ID checks
that decide what to do with the file were reading empty data. Add
GameInfo::WaitUntilReady() - a condition variable signalled from
MarkReadyNoLock(), which every exit path of the work item goes through - and use
it there.
Also demote a noisy PRX decryption log line to DEBUG.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EMLTdwyyzU6Mze3w8VC2JL
Opening an official updater (a PSP/GAME/UPDATE EBOOT.PBP, identified by the
MSTKUPDATE disc ID) from the main screen now brings up a confirmation dialog
that unpacks the firmware into the NAND directory, where the emulated
flash0/flash1 live. Running the updater itself doesn't work, so there was
nothing useful to do with one before.
Unpacks the file list for the model we claim to be (iPSPModel), on a worker
thread, with a progress bar - for which PSARUnpackOptions gets an optional
progress callback.
AGENTS.md: translate UI strings last, in a separate commit
The English string is what all ~47 languages get derived from, so rewording it
after the sweep means redoing the sweep. Check the wording first.
Co-Authored-By: Claude Opus 5 <[email protected]>
sceIoDevctl 0x01E18030 asks whether the disc's region matches the console's.
Unusually it answers through the return value rather than an output buffer -
1 matches, 0 doesn't - so leaving it unimplemented read as a mismatch, and the
VSH opened on "This disc cannot be started. The region code is not correct.",
asking 12 times. PPSSPP has no region-locked discs; anything it can load is
something it should run, so this always matches. The call now happens once.
Behind it is a second thing, not fixed here: the VSH believes a disc is inserted
at all because nothing in PPSSPP models an empty drive. sceUmd reports
PSP_UMD_PRESENT | PSP_UMD_READY unconditionally, and devctl 0x01F20001 always
answers "game disc". JPCSP answers "no disc" when no ISO is loaded, which is why
it never reaches the region question. Giving those two a notion of "no disc"
would be the real fix - low risk for games, which always have one, but it is on a
path every game uses, so it is written up in docs/VSHBootInvestigation.md rather
than done as a drive-by.
With this the shell reaches the interactive XMB: the error is dismissable with
circle and the menu behind it works. The per-frame display list stops settling
into one repeated frame and alternates between 45 and 48 stall points, which is
the headless-visible sign of a live menu rather than a static dialog.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01GZq8ZtJmFY7bkX5FVkr3P9
pspDecryptPRX() tried types 0, 1, 2, 5 and 6. flash0:/vsh/etc/index_XXg.dat -
the index of what the XMB shows, fetched through sceResmgr_9DC14891 - needs
type 9, so it failed and the shell had no menu to build.
Type 9 is type 6 with three differences, all following from a type 9 file
carrying a real ECDSA signature at 0x104..0x12C where a type 6 file has nothing:
- The "must be empty" header check stops at 0x104 instead of 0x10C. The index's
signature starts there, so 8 of its bytes were failing type 6's check - the
original failure.
- The signature is left out of the hashed header rather than fed into it. JPCSP
zeroes buf2[0x34..0x5C), which is that same range once its header
rearrangement is undone, so PRXType9 just leaves the field zero.
- ecdsa_hash in the KIRK CMD1 header stays 0. Type 6/7 set it, but the branch
type 9 takes writes only the mode word, and setting it made KIRK reject the
block.
Tried last in the chain: its header check is a subset of type 6's, so a genuine
type 6 PRX would pass it and then fail on the hash, and trying it earlier would
shadow the real answer. False positives are not really possible either way - the
SHA1 check inside has to match before anything is decrypted.
Verified end to end: 496 bytes in, 159 out (the comp_size in the header),
starting "release:". sceResmgr checks that prefix and says so in its log line,
since a wrong-but-plausible decrypt would otherwise look like success here and
fail much later as an unreadable index.
The VSH now draws something different - the per-frame display list settles at 24
stall points rather than 38 - but what it shows is not visually confirmed;
framebuffer readback doesn't work under headless on either backend.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01GZq8ZtJmFY7bkX5FVkr3P9
Found the cause of the red error screen the VSH ends on. Every resource load in
the boot succeeds - fonts, all the plugin RCOs, topmenu_icon.rco - and then:
sceIoOpen(flash0:/vsh/etc/index_02g.dat) -> fd 8
sceIoRead(8, 092a2d40, 496)
sceIoClose(8)
unresolved import sceResmgr/9dc14891, called from 'vsh_module'
sceKernelExitDeleteThread(1)
index_02g.dat is the index of what the XMB displays, and it is encrypted (it
starts "PSPsysGP"). sceResmgr_9DC14891 decrypts it. There was no sceResmgr module
at all, so the call trapped, the index stayed encrypted, and the ScePafJob thread
building the top menu exited - a shell with everything loaded and nothing to show.
This adds the module and the three tags it needs (0x0B2B90F0/91F0/92F0, keys and
code 0x5C) to PrxDecrypter.
It is not the whole fix yet: pspDecryptPRX() tries decryption types 0, 1, 2, 5
and 6, and this needs type 9, which JPCSP passes explicitly. So the call is now
reached and fails cleanly with a logged error instead of trapping, but does not
yet decrypt. Type 9 is a variant of type 2 and is the next job; the notes in
docs/VSHBootInvestigation.md say where it is in JPCSP and how to check a port
(159 bytes out, starting "release:").
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01GZq8ZtJmFY7bkX5FVkr3P9
Mostly small stubs:
- sceImpose: GetParam/SetParam/Changes/SetStatus, plus the 6.60 alias of
sceImposeGetBatteryIconStatus. Also fixes that function's first output - it
is a plain "is it charging" boolean, not a BATTICON_ value. We wrote
PSP_IMPOSE_BATTICON_NONE (0x80000000) there, which games ignore but which the
VSH reads as "no battery" and draws the empty-battery indicator for. These
are the bulk of the traffic: the VSH calls sceImposeChanges once a frame, so
this alone removes ~10000 trapped calls from a boot.
- SysMemForKernel: sceKernelSetRebootKernel, sceKernelSetUmdCacheOn.
- scePower_driver: scePowerSetWakeupCondition.
- sceHprm_driver, sceUsb: one NID-named call each, as in JPCSP.
Three groups are deliberately left unresolved, with comments explaining why,
because resolving them lets real flash0 drivers walk into hardware we do not
emulate and the boot dies where it used to reach the shell:
- ThreadManForKernel mutex/fpl NIDs: the NAND and ID storage drivers use these
to init, then poll the NAND controller at 0xbd101300 forever.
- InterruptManagerForKernel intr registration: 31 calls, then a stall in GE
list execution with no plugin module ever started.
73 unresolved import hits over 37 distinct module/NID pairs remain in a VSH
boot, mostly sceSysEventForKernel, sceSuspendForKernel and the various
*_driver modules that need real hardware behind them.
The sceImpose savestate section goes to v2 for the two new state variables.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01GZq8ZtJmFY7bkX5FVkr3P9
ModuleMgrForKernel/0xD5DDAB1F is how the VSH loads its own plugins. The XMB's
interface lives in flash0:/vsh/module/*_plugin.prx and vshmain pulls those in
through this kernel call rather than the user-mode sceKernelLoadModule, so the
existing note that vshmain never imports sceKernelLoadModule was true but
incomplete - it imports this instead, and it was unresolved.
The consequence was quiet: vshmain got no module id back and then called
sceKernelStartModule with id 0, which failed with UNKNOWN_MODULE. None of the
plugins that populate the XMB ever ran. The scene still had its containers,
which is why every frame set up render state per node and drew nothing inside
them - the "6x render-state-setup, 0 draws" symptom this investigation has been
chasing.
Also implements 0xD86DD11B sceKernelSearchModuleByName, the other unresolved
ModuleMgrForKernel import.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01GZq8ZtJmFY7bkX5FVkr3P9
For some reason, a pointer used to allocate the heap for scePaf is not
initialized. This hacks aroung that.
Additionally zero out the specific 4-byte "category 1 alarm count" address in vsh_module.
This gets us much further.
Extends the SysconSerialMMIO stub added for VSH boot into a real command/
response protocol matching uofw's Syscon_cmd() reference exactly (packet
framing, checksum, GPIO4 "response ready" handshake via a new GpioMMIO
cross-module hook), with handling for NOP/read-write clock/read-write
alarm commands.
Still hitting a SIGSEGV though.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01GZq8ZtJmFY7bkX5FVkr3P9
(cherry picked from commit 11887bf9e1fecd1eac56ec705c01b6fcfac09b2e)
Extends LoadAndStartVshKernelModules() to load the 11 real kd/*.prx
kernel drivers for --vsh (dmacman, systimer, memlmd_01g,
loadexec_01g, lowio, idstorage, syscon, rtc, wlan, wlanfirm_01g, utility),
ahead of the existing 4 VSH-specific modules.
Only active when g_runningVSH, no effect on normal game boot.
Improve implementations of sceKernelSm1ReferOperations and sceKernelIsIntrContext.
Add some more MMIO stubs (GPIO, SYSCON).
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01GZq8ZtJmFY7bkX5FVkr3P9
(cherry picked from commit 7f3168b7df85e47438900016c9ee7d7ef01a0a28)
Adds SceKernelLoadExecVSHParam and fills in the rest of LoadExecForKernel's
NIDs: real implementations for sceKernelExitVSHVSH/Kernel
(mirrors sceKernelExitGame) and sceKernelLoadExecBufferVSHUsbWlan (loads
an exec from an in-RAM buffer instead of a file, plus UNIMPL stubs.
sceKernelLoadExecBufferVSHUsbWlan needed __KernelLoadExec split into a
file-reading front end and a shared __KernelLoadExecFromPtr back end
that both it and the new buffer-based path call into.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01PSNaZnHCjmryS3ziVN9gZU
(cherry picked from commit 4adbedec9e2e3221113c9e5ad00b248bba1153e1)
Load and start VSH's kernel modules before booting vshmain.prx
A few flash0 modules (vshbridge.prx, paf.prx, common_gui.prx,
common_util.prx) should run for real once we know we're
actually booting the VSH rather than a game, since our fakes are unlikely
to be good substitutes for the genuine thing.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01PSNaZnHCjmryS3ziVN9gZU
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) <[email protected]>
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) <[email protected]>
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) <[email protected]>
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) <[email protected]>
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) <[email protected]>
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) <[email protected]>
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) <[email protected]>
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) <[email protected]>
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) <[email protected]>
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) <[email protected]>
Claude-Session: https://claude.ai/code/session_01FzzCUp8y1ahgVueb1Cq92Y