* fix(app): book inline steps on the vblank grid, not on swap returns (#1141)
Inline mode with the swap interval at 1 fed the pacer the real time between steps.
A step starts when its swap returned plus whatever the loop did since, which is
0.3-1 ms of jitter on a quiet host. With the 60.05 Hz core's backlog phase creeping
across a period boundary every ~18 s, that jitter flipped takes near each boundary:
bursts of repeats and dropped frames that tracked frame cost and host state
(Cybermorph 16 -> 22-25 repeats a minute once #1117 made its frames cheaper,
51-62 on a loaded host).
SwapGrid (pacing.h) tracks the display's vblank grid from swaps that blocked, as
LateLatch does for the threaded loop. It hands stepEmulation a correction, so the
backlog gains the time between the swaps' vblanks. The tracked phase follows the
real vblanks, so the sum stays real time: no speed change, a 59.94 Hz panel
announced as 60 Hz included. When not locked (vsync off or released, non-blocking
swaps, VRR) the correction is 0, as before. The threaded path is untouched.
Tests: pacing_test gains swap-return jitter in its simulated loop (real time vs
grid, 60 / 59.94 / 120 Hz, PAL) and unit tests for SwapGrid's lock, unlock,
telescoping corrections and relock.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* fix(app): keep the inline grid log inside the inline branch (no_threaded_limits)
The log line added a 53rd 'am I threaded?' check to app.cpp; it now sits in
the inline branch that already holds the grid update.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Claude Opus 5.5 <[email protected]>
Version v3.11.0 in Makefile, .info and version_fallback.h; release notes and
WHATSNEW. Savestates stay v17 (state.h unchanged since v3.10.0).
Co-Authored-By: Claude Opus 5.5 <[email protected]>
The hosted ARM64 runner enumerates "Microsoft Basic Render Driver"
through IDXGIFactory1 without DXGI_ADAPTER_FLAG_SOFTWARE, so the probe
accepted it as hardware and the retry ran on WARP again. Also treat
Microsoft's vendor id 0x1414 and a description containing "Basic Render"
as software.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
Left to itself, Mesa's d3d12 driver takes the Microsoft Basic Render
Driver (WARP) when that is the only D3D12 adapter: on the hosted ARM64
runner the retry came up as "D3D12 (Microsoft Basic Render Driver)" and
the app exited with status 87 before its first screenshot, where the
same runner renders fine on llvmpipe. WARP is software D3D12 under a GL
translation, so it is the slower software path anyway.
The launcher now enumerates DXGI adapters (dxgi.dll and d3d12.dll loaded
by hand, no link dependency), skips DXGI_ADAPTER_FLAG_SOFTWARE ones and
asks D3D12CreateDevice at feature level 11_0. First hit: GALLIUM_DRIVER=
d3d12 plus MESA_D3D12_DEFAULT_ADAPTER_NAME pinned to that adapter. No
hit: GALLIUM_DRIVER=llvmpipe and LIBGL_ALWAYS_SOFTWARE=true as #1138 had.
A GALLIUM_DRIVER already in the environment wins. Both outcomes are
logged on stderr and the GL test workflow asserts one of the two lines.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
The branches filter limited the qualification to PRs against develop, so a
PR stacked on another PR's branch (this one on #1138) got no run at all.
The path filter is what selects the workflow; the base branch is not.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
The automatic retry forced GALLIUM_DRIVER=llvmpipe and
LIBGL_ALWAYS_SOFTWARE=true into the child's environment, so every
machine that landed in the fallback (a stock Windows on ARM install, a
VM, a Remote Desktop session, the basic display adapter: anything where
WGL hands out the GDI Generic 1.1 driver) rasterized the UI and the
shader presets on the CPU, in competition with the emulation thread.
The MSYS2 Mesa we stage is built with the d3d12 Gallium driver as well:
OpenGL on top of the Direct3D 12 driver that every stock Windows 10/11
install has, the same layer Microsoft ships as the OpenGL Compatibility
Pack. Mesa's WGL frontend takes d3d12 when a D3D12 adapter exists and
llvmpipe otherwise, so the child now leaves the choice to Mesa and only
sets SDL_OPENGL_LIBRARY and the retry guard. GALLIUM_DRIVER and
LIBGL_ALWAYS_SOFTWARE pass through, so a user or the GL test workflow
can still pin a driver. The existing "[gl] renderer=" log line names
the one that came up.
The GL test workflow accepts either renderer after the automatic retry
(hosted runners have no D3D12 adapter and will show llvmpipe); its
explicit software-path qualification still pins llvmpipe. Docs and the
test-build note say what the fallback now is. The software-opengl/
folder keeps its name.
Stacked on #1138. Refs #1104.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
GPUExec dispatched every emulated instruction through executeOpcode(): a
call, a return and the register spills either side of them, per op. The
function could never be inlined into the loop: it holds label addresses
(computed goto), and it is recursive -- jump/jr run their delay slot
through it.
GPUExec now dispatches through its own computed-goto table to labels
inside GPUExec, where the (GPU_OP_HOT) handlers are inlined; the loop's
post-op bookkeeping follows unchanged. executeOpcode() stays exactly as
it was for the delay slot. One X-macro, GPU_OPCODE_LIST, generates both
tables and both label blocks, so the two copies cannot drift (it also
replaces executeOpcode's hand-written 64-entry table). Non-GNU compilers
keep the executeOpcode() call.
Emulated output unchanged: frame_hash_ab CSVs byte-identical to develop
over 300 Doom gameplay frames and 400 yarc frames; gpu_idle_probe_falsify
passes (the idle probe keys on index 53 after dispatch, as before).
Measured on top of #1137, Doom gameplay from one savestate:
- cachegrind (ir_ab.sh --ref, 300 frames): Ir -4.07%, D1 miss -4.13%,
I1 miss +2.80% (GPUExec is larger), LL +0.16%.
- Mac Studio M4 Max wall, test_benchmark 900 frames, A/B/B/A x3:
avg 3.60 -> 3.38 ms (-6.1%), p99 4.3 -> 4.0 ms, every B run faster
than every A run; yarc 3.39 -> 3.34 ms (-1.6%).
Co-authored-by: Claude Fable 5.1 <[email protected]>
* fix(gpu): movei fast path, write the upper bound without adding to gpu_pc
Review of #1137: `gpu_pc + 4 <= GPU_WORK_RAM_BASE + 0x1000` wraps for a pc
within 4 bytes of 0xFFFFFFFF (a wild jump target), so such a pc passed the
range check and indexed gpu_ram_8 far out of bounds. Compare gpu_pc
against GPU_WORK_RAM_BASE + 0x0FFC instead: the same set of in-RAM
addresses, no arithmetic on the pc. No behaviour change for any valid pc
(frame hashes identical on Doom gameplay).
Co-Authored-By: Claude Fable 5.1 <[email protected]>
* test(gpu): movei immediates at both ends of local RAM (#569)
Two placements of movei at the end of the 4 KB pin the fast-path bound
from both sides: the immediate in the last two words (pc == base +
0xFFC after the opcode, the last position the fast path may take) and
the immediate one word past the end (general path; a bound off by one
the other way reads gpu_ram_8[0x1000..0x1003], which the sanitizer CI
job turns into a failure). Asked for in the review of #1139.
A pc of 0xFFFFFFFC itself cannot be exercised: the opcode fetched there
goes through JaguarReadWord, which masks to 24 bits and lands in
unmapped space, so no movei can be made to execute at that address.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
---------
Co-authored-by: Claude Fable 5.1 <[email protected]>
* feat(app): schedule the emulation thread as a real-time thread (#967)
The frame loop is a periodic deadline job; ask the OS to schedule it as
one when the thread starts (app/src/thread_realtime.h): macOS
THREAD_TIME_CONSTRAINT_POLICY (period one frame, computation 30%,
constraint 60%, preemptible; runs ahead of every QoS class and on
performance cores, so it replaces the user-interactive QoS raise, which
stays as the fallback), Windows THREAD_PRIORITY_TIME_CRITICAL plus the
MMCSS "Games" task, Linux SCHED_RR at a low real-time priority where
RLIMIT_RTPRIO or rtkit allows it.
Why: on an M2 Max at a 1-minute load of 250-950 the same Doom gameplay
frame cost 5.4 ms with a performance core of its own and 18-20 ms
without one (58-86 involuntary context switches a frame; the game ran
at 54.6 of 60.05 fps). Never fatal: every refusal leaves the thread as
it was, and the log line "[emu] emulation thread on; scheduling ..."
says what it got. --emu-priority normal skips it for one run (A/B).
emu_thread_unit: testRealtimeNote (asked for by default, off on request,
note always set before start() returns).
Co-Authored-By: Claude Fable 5.1 <[email protected]>
* Mark realtime variable as maybe unused
Co-authored-by: Copilot Autofix powered by AI <[email protected]>
* Change library loading method for avrt.dll
Updated LoadLibraryW to LoadLibraryExW for better library search.
Co-authored-by: Copilot Autofix powered by AI <[email protected]>
---------
Co-authored-by: Claude Fable 5.1 <[email protected]>
Co-authored-by: Copilot Autofix powered by AI <[email protected]>
A sampling profile of a GPU-bound frame (Doom gameplay, the GPU renderer
running its full 26.6 MHz budget at 442k ops/frame) put 63% of core time
in the RISC interpreter and 30% of that in GPUExec's own loop body, and
showed 32 of the 64 opcode handlers as separate symbols: every load,
store, movei, add/sub and shift was a call, a return and a reload of the
operand globals per emulated instruction, because at -O3 the compiler
declined the INLINE hint for them. movei fetched its 32-bit immediate
through two GPUReadWord calls (range-check chains the compiler cannot see
through), ~7% of the frame on its own.
- GPU_OP_HOT: __attribute__((always_inline)) (/__forceinline on MSVC) on
the 32 small, non-recursive handlers. jump/jr stay out of line (they run
their delay slot through executeOpcode() itself) and so do div, mmult,
normi and pack (large, rare).
- movei: when both immediate words lie in local RAM, read them from
gpu_ram_8 the way the exec loop reads the opcode; an immediate
straddling the end of local RAM takes the old path.
Emulated output unchanged: frame_hash_ab CSVs byte-identical to develop
over 300 Doom gameplay frames (ir_ab's accuracy gate agrees).
Measured, Doom gameplay from the same savestate:
- cachegrind (ir_ab.sh --ref, 300 frames): Ir -2.00%, D1 miss -0.89%,
I1 miss +0.22%, LL -0.06%.
- Mac Studio M4 Max wall clock, test_benchmark 900 frames, A/B/B/A x3:
develop avg 4.11 ms / p50 4.11 -> branch avg 3.85 / p50 3.86 (-6.5%).
- make test on the Studio: 56103/56103 pass.
Co-authored-by: Claude Fable 5.1 <[email protected]>
* test(tools): ir_ab.sh IR_STATE, a savestate loaded before the measured frames
Every ir_ab workload so far was a boot sequence or a demo. A perf change
aimed at a game's render loop needs the Ir count of GAMEPLAY, which is a
savestate away: IR_STATE names a raw core savestate (the app's .state
files, or test_benchmark --save-state output); it is copied into the work
dir on the host so the container run sees it, and frame_hash_ab gets
--load-state for both arms. Pair it with IR_ROM of the same game.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
* Fix comment typo in ir_ab.sh
Co-authored-by: Copilot Autofix powered by AI <[email protected]>
---------
Co-authored-by: Claude Fable 5.1 <[email protected]>
Co-authored-by: Copilot Autofix powered by AI <[email protected]>
* build(app): CMake presets, and warn when no librashader runtime will ship
- `app/CMakePresets.json` adds three presets: release, debug and nightly (the nightly channel, where the emulation thread defaults on). Each builds in `app/build-<preset>/`. Run them from `app/`: `cmake --preset release && cmake --build --preset release -j`.
- VJ_APP_FETCH_LIBRASHADER can stay OFF in the cache from an earlier offline or packaging configure, and the default never comes back. Configure now warns about that where a prebuilt exists (Windows, Apple silicon). Otherwise a local build ships no runtime and the Shader section reports the library missing. Configure also names the prebuilt runtime it picked.
- Ignore `app/CMakeUserPresets.json`.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* build(app): the release and debug presets set the dev channel
A cached VJ_BUILD_CHANNEL=nightly from an earlier -D survived the presets, which set no channel.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Claude Opus 5.5 <[email protected]>
The 4.0 flip from the phase 5 plan: emuThreadDefaultFor() is true for
release, nightly and dev (an unknown channel string stays Off), and
VJ_EMU_THREAD_DEFAULT_VALUE is 1 for every channel. The channel still
names the build in About and the log. Unchanged: a stored ui.emu_thread
wins, --emu-thread off runs one start inline, a failed thread start falls
back inline, headless runs stay inline unless --emu-thread on.
Called by the maintainer with G1-G3 met and G4/G5 still open; the phase 5
plan and the design doc's section 8 record that. The default smoke tests
now expect "default on" in every channel; the unit test pins all three
channels On and unknown ones Off.
Refs #1039, #967.
Co-authored-by: Claude Fable 5.1 <[email protected]>
- Halt, mode A vs mode B: 0 underruns in each.
- Phase 3 Studio gate: two quiet-host passes (halted p99 about 18 ms). One run under load failed, and one was skipped.
Refs #1039
Co-authored-by: Claude Opus 5.5 <[email protected]>
* feat(packaging): prepare Steam depots and Mac store metadata
* feat(macos): add sandboxed Mac App Store build and signing
* fix(macos): persist sandbox access when re-adding a library
* feat(app): prepare Microsoft Store MSIX packages and listing draft
* test(windows): qualify installed MSIX and capture Store screenshots
* fix(windows): resolve qualification graphics from MSYS2 output
* test(windows): retain installed app logs and reuse qualified builds
* fix(windows): read installed process exit status reliably
* test(windows): keep MSIX CI focused on installed runtime qualification
* fix(windows): harden Store SDK discovery and workflow triggers
- Fill the phase 5 plan's evidence table:
- G1 and G2 from #1130.
- G3: the mode B bar and its transitions (#1128).
- A same-host A/B of mode A against the phase 1 build. It finds no mode A regression; the extra Cybermorph repeats appear inline too.
- The inline halt arm, which --perf-halt cannot drive.
- G3 halt A vs B, the phase 3 gate, G4 and G5 are still pending.
- Correct G3's gate label: emu_thread_gate_check.sh is the phase 3 Studio gate.
- README: a "Reporting a threading problem" section. It names the About line, the Frame pacing "Now:" line, the display, an --emu-thread off comparison and the log prefixes to include.
Refs #1039
Co-authored-by: Claude Opus 5.5 <[email protected]>
Gates G1-G5 before releases default the emulation thread On: threaded
twins for the 20 app tests without one, every CI platform, the Studio
60 Hz measurement, Windows/Linux/120 Hz reports and a two-week nightly
window. Three PRs: 5a twins, 5b evidence record, 5c the flip (last).
The inline loop is retired only after a default-on release ships.
Co-authored-by: Claude Opus 5.5 <[email protected]>
With the emulation thread on, Settings > Video > Frame pacing offers
Jaguar rate (default, mode A) or Match display (mode B). In mode B each
emulated frame starts on a display tick, the audio rate follows the
display, and the UI swaps as soon as the tick's frame is ready.
- video.pacing setting, --pacing jaguar|display (one run, not saved),
Video tab and TV rows with live state, reset class.
- LateLatch measures the real vblank period (59.94 Hz panels that
report 60 lock correctly) and gives a last-chance wait for heavy
frames.
- The UI evaluates DisplayLock after each swap, publishes the tick grid
after draining input, and re-evaluates on display/fullscreen events.
- The emulation loop runs frames on ticks, with a backstop 2 ms past a
late tick and mode A when the grid is stale or fast-forwarding.
- latchWait ends on the tick's frame publish.
- One notice per lasting fallback reason; link play forces mode A.
- Perf trace columns mode, tick_seq, ticks_dropped, lock_reason.
Co-authored-by: Claude Opus 5.5 <[email protected]>
Phase 4a of the emulation thread plan (docs/app-emulation-thread-plan-
phase4.md). Nothing calls these yet; 4b wires them in.
- DisplayLock (new, header-only): one verdict per swap. It locks when
k = round(display / core) puts display / k within 0.3% of the core's
rate, measured from the vblank period: NTSC on 60 / 59.94 / 120 /
119.88 Hz, PAL on 50 / 100 Hz; 144 and 75 Hz fall back for both.
Each fallback has its own reason (setting, no vsync, latch unlocked,
ratio, fast-forward, link play). The display conditions must hold
120 swaps in a row to enter mode B, and B ends on the first swap they
fail; fast-forward and link play switch at once.
- PacingClock tick path: one frame every k ticks. More than one frame
behind drops the owed ticks and runs one catch-up frame. After a drop
it runs one extra frame per k ticks until the audio queue is back at
its target: the frame that ends a stall and the catch-up both queue
audio, leaving the queue near half the target, which the trim alone
takes ~6 s to restore. Keyed on the drop, not a queue level: at a
20 ms target one frame's audio is most of the target, and a level rule
fired in steady state. A catch-up frame counts as its tick's extra,
so no tick runs 3 frames. The first tick call enters B; the first
nextDue() after B resets the mode-A grid, so nothing owed crosses a
switch.
- AudioOut::setBaseRatio: the stream plays at (display / k) / core and
update(true) trims +/-0.5% around it. It writes the SDL stream's
ratio itself, so leaving B (base 1) really puts the stream back at 1
for mode A's update(false), which never touches it; clear() and a
fresh open() return to the base, not to 1.
Tests: display_lock_unit (lock table, each reason, hysteresis);
pacing_clock_unit gains mode B through one loop shared with mode A: a
60.05 Hz core skips 3 frames a minute on 60 Hz and 7 on 59.94 Hz in
mode A, none in mode B; k=1/k=2; a stall; refill after a 200 ms stall;
steady state never refilling at audio.target_ms 20 / 64 / 200 ms with
jittery pulls; A -> B -> A switches. audio_out_unit asserts the stream's own ratio.
Mutating each rule fails at least one check.
The plan doc gains the refill rule.
Refs #1038
Co-authored-by: Claude Opus 5.5 <[email protected]>
testCallGivesUp asserted call() returned within 300 ms of the predicate
turning true; a starved macOS runner (every 1 ms sleep took 8 ms) took
319 ms and failed PR #1125's Packages / macOS universal job. call() has
no timeout, so the only bug the ceiling can catch is a predicate that is
never consulted, which hangs. Use the file's 2 s no-hang bound there and
in testAdvanceGivesUp (500 ms, same class); the lower bounds, which prove
the wait lasted until the predicate turned true, are unchanged.
Co-authored-by: Claude Opus 5.5 <[email protected]>
#1122 changed two files under docs/ and still ran the full C/C++ CI
matrix and the regression tests. Both workflows' pull_request triggers
now ignore docs/** and **.md, as app.yml already does with its own
paths list. No check on develop is required, so a skipped workflow does
not hold a PR. The cd-boot-matrix format check that reads a doc lives in
pages.yml, which keeps its own filter. Release PRs always carry code;
workflow_dispatch still runs either workflow on any branch.
Co-authored-by: Claude Opus 5.5 <[email protected]>
test_jlink_negotiate failed "setup: confirmed before testing the drop" on
both Linux jobs of #1122's first CI run (a docs-only PR) and passed on the
rerun. Two things in the test made that hard to read:
- Its ports (45000-48002) and test_jlink_discover's (43000-46999) sit
inside Linux's ephemeral range (32768-60999), where a runner's own
outgoing connections can hold them, and the two bands overlapped each
other although the comment said they were distinct. They move to
30000-31302 and 31400-32699: below every OS's ephemeral floor and clear
of test_jlink_tcp's 17000-24999 and the app netplay tests' 20000-29999
(the rule from #356).
- The fake peer ignored a failed bind()/listen(), so a taken port showed
up later as "not confirmed". It now fails by name with strerror, and a
failed "confirmed" check prints whether the TCP link came up and what
the last confirm sendto returned.
Whether a port collision caused the #1122 failure is not established;
the next failure will say which leg broke.
Co-authored-by: Claude Opus 5.5 <[email protected]>
Plan for #1038 in two PRs: 4a the pure parts (DisplayLock verdict,
PacingClock tick path, AudioOut base ratio, fake-vsync tests) and 4b
the wiring (Video > Frame pacing setting, swap ticks, fallback notice,
link play forcing mode A, latch wake on frame publish, Studio
measurement).
Decisions agreed 2026-10-30: lock when |display/k - core| / core is
under 0.3% of the measured vblank period; 120 Hz validated by fake
ticks plus Studio 60 Hz runs; mode A while linked; wake on publish;
default stays Jaguar rate.
The design doc's mode B section named 144 Hz as a lock case. It is
not (144 / 60.05 = 2.40); amended with the lock table's rule.
Co-authored-by: Claude Opus 5.5 <[email protected]>
* perf(dsp): idle skip admits Iron Soldier 2's wait loop (#952)
Iron Soldier 2's DSP spends nearly all of its time in a 12-word loop
at $F1B0A6 that waits for the I2S interrupt handler to move a buffer
pointer in the alternate register bank:
nop ; addq #1,r19 ; movei #$F1B730,r14 ; movefa r28,r28
sub r31,r28 ; jr NN,+2 ; sharq #2,r28 ; addq #31,r28
moveq #0,r30 ; cmpq #8,r28 ; jr C,head ; nop
The idle skip rejected it three ways over; each is now admitted, no
wider than needed:
- Reach. The probe only ran for a closing jr at most 7 words back.
It now covers the jr's full reach (15 words).
- An internal forward jr (`jr NN,+2`). A body may hold ONE jr whose
target is a decoded instruction after its delay slot and at most the
closing jr, with a delay slot that is neither movei nor jr, and no
exit jr alongside it. That gives exactly two routes: not taken
(1 taken jr, F = idleBodyCount + 1 opcodes) or taken (2 taken jrs,
F - K, K = instructions skipped). Both probe iterations must show the
same pair, and it must be one of these. Any detour has to fall
through the closing jr and come back, costing at least 4 more than
the legal route with the same taken-jr count (leaving can only add
taken jrs), so it can never match. The dataflow walk still checks
the skippable instructions and forgets what they wrote at the join.
- A counter on the flag-setting addq (`addq #1,r19`). A counter may
now use addq/subq onto itself when its flags are dead: overwritten
in full (Z, N and C, per the dsp_opcode_* bodies) on every route
before anything reads a flag. Flags no instruction reads cannot
carry the counter's growth into a branch.
Probing every jr up to 15 words back means loops that never qualify
reach the probe more often (Doom +0.25% host instructions per frame),
so the probe now remembers the reject-memo entry it hit or added last
and returns at once, with no probe in flight, when the same loop comes
round again -- exactly what the memo scan would conclude. Doom's cost
is then +0.11%.
dsp_idle_probe_falsify gains six programs: E/E2 (Iron Soldier 2's
loop word for word, internal jr taken and not taken) fire and still
leave when the polled register moves; F (counter Z read by the closing
jr), G (counter carry read by addc), H (taken internal jr, fall-through,
undecoded store and a jump back, matching F opcodes with two taken
jrs) and I (exit and internal jr together) must not. With the
taken-jr pairing, the dead-flag rule or the exit/internal ban removed,
H, F+G and I fire respectively.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* fix(dsp): dead-flag scan stops before the closing jr's delay slot (#952)
The closing jr has no entry of its own in idleBodyIdx, but its delay
slot is the last entry, so dsp_idle_flags_dead() walked into that slot
without seeing the branch read the flags first. A flag killer there
(`addq #1,r19 ; jr NE,head ; cmpq #0,r0`) made the counter's Z look
dead, and the skip extrapolated straight past the loop's exit. The
scan now stops short of the delay slot.
dsp_idle_probe_falsify gains program J, that loop at its own address
(F's reject-memo entry would otherwise mask it); it fires without this
fix and is rejected with it.
Reported by the Codex review on #1121.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Claude Opus 5.5 <[email protected]>
The GPU and DSP idle-loop fast-forward was off whenever blit
memoization was enabled (or in verify mode). Only blitMemoRecording
needs to gate it: while a blit is being recorded, the JaguarRead
family notes every read, and skipped loads would change what the memo
records. Recording is set only around BlitMemoLaunch's synchronous
engine run (a GPU store to B_CMD can launch one with GPUExec on the
stack, but no GPU or DSP instruction executes until it returns), so
the check is belt-and-braces.
blitMemoMode on its own leaves the read hook off, and the write hook
(main-RAM page generations) is never reached by an admitted loop body:
the DSP admits no store, and the GPU admits only long stores into GPU
local RAM, which GPUWriteLong serves without any memo hook. The
skipped iterations leave the memo exactly as interpreting them would.
The [perf] suppression warning, the option sublabel's "Inactive"
note, the blit_memo option text and the settings guide no longer list
blit memoization as an idle-skip suppressor.
Co-authored-by: Claude Opus 5.5 <[email protected]>
Cybermorph's DSP spends ~91% of its instructions in a two-bit register
poll (btst ; jr NE,out ; nop ; btst ; jr EQ,head) that idle skip
rejected only for its second branch. Admit a jr whose target lies past
the closing jr's delay slot, and back it with a taken-jr count: each
probe iteration must take exactly one jr, the closing one, which closes
the detour route the opcode-count check alone cannot see.
Cybermorph RISC 2x: p50 5.49 -> 2.34 ms, p99 14.34 -> 11.13 ms on an M4
Max; host instructions per frame -45%. Skip on/off byte-identical across
all 160 corpus carts; four titles newly skip. dsp_idle_probe_falsify
gains Cybermorph's shape (must fire and still exit) and an exit-detour
exploit (must not fire; fires with the check removed).
Co-authored-by: Claude Opus 5.5 <[email protected]>
HC ($F00004) free-runs 0..HP once per half-line on hardware (JTRM), so
its low bits at a CPU read are effectively random. Alien vs Predator
uses them as its marines' fire roll -- `move.w $F00004,d0 / and #7 /
cmp #2` at 68K $015ADA -- while its Predators roll the game's software
RNG. The core returned a per-read counter reset to 0 / (HP+1)/2 every
half-line, so a once-per-AI-tick read always saw $0000 or $05A6: low
bits 0 or 6, never 2. Marines walked up to an Alien player and never
fired (30 of 30 rolls measured from the corridor savestate).
The readback is now the elapsed fraction of the current half-line:
time to the next half-line event at slice start, less how far the 68K
has run into the slice (JaguarSliceElapsedUs, stall and clock scale
included), scaled to HP+1 ticks. Byte reads of $F00004/5 use it too.
tomRam8[HC] is unchanged (the OP's second-half-line test reads bit 10),
and the read-phase counter is gone; nothing new is serialized.
Measured after: the same marine rolls all eight HC&7 values (2 on 12
of 111), crouches, fires, and the Alien's health drops.
Co-authored-by: Claude Opus 5.5 <[email protected]>
On macOS SDL emulates vsync with a CVDisplayLink: SDL_GL_SwapWindow waits on a
condition variable that the link's callback signals each refresh. The link is
made from the active displays when the GL context is created. With the display
asleep there are none, the link never ticks (not even after the display wakes)
and the first swap blocks forever, the emulation with it when it runs inline.
A display that falls asleep after launch does not block: the link survives.
SwapGuard (pure) decides whether the swap interval may be 1: never for a run
whose context was made with the display asleep, and (defensively) not while it
sleeps later. App::guardSwap() asks the platform layer (CGDisplayIsAsleep /
an empty CGGetActiveDisplayList) every 250 ms and before the swap's timestamps,
and every UI swap goes through it. Interval 0 is the existing vsync-off path,
so the emulation keeps its own clock while nothing can be seen.
Co-authored-by: Claude Opus 5.5 <[email protected]>