Commit Graph

230 Commits

Author SHA1 Message Date
Kevin 975f6646ef host_keymap: a present-but-empty / "None" [KeyMap] line is an explicit unbind
host_keymap_load zeroed an action's binds when its line was present and then
apply_defaults() put the built-in key straight back, so "Turbo = None" (or
an empty value) silently re-armed Tab. Track explicit_unbound per action:
set when the line is present but yields no binds, and honoured by
apply_defaults through want_default(). A missing line still receives the
default, so existing config.ini files are unaffected.

Pairs with the launcher's Backspace-to-clear (recomp-ui
feat/additional-ui-functionality), which writes "None" for that reason.
README documents the rule; test_rewind_toggle_combo.py guards it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 07:33:08 -04:00
Kevin 3b8b98fa29 runtime: fast-forward toggle shortcut + document the implicit Select chord
Turbo (Tab / [hotkeys] fast_forward_pad) is hold-to-run only. Add a
press-to-latch twin that feeds the same manual fast-forward block:

- host_keymap: HOST_KEYMAP_TURBO_TOGGLE, [KeyMap] TurboToggle, default F9.
- main.cpp: g_manual_turbo_latched flipped by the key edge or by a fourth
  host shortcut, PSX_ASSIST_BIND_FAST_FORWARD_TOGGLE ("Fast-forward
  toggle", [hotkeys] fast_forward_toggle_pad, unbound by default), polled
  next to Rewind / Save states through hotkey_pad_binding_down(). OSD shows
  "Fast forward: Nx (locked)" / "Fast forward: off". Threaded through every
  settings hop (settings.toml -> globals -> launcher seed -> LauncherSettings
  -> back), mirroring fast_forward_pad.
- config_loader: [hotkeys] fast_forward_toggle_pad read/write.
- README: describe the toggle and state that a single-button / trigger host
  shortcut is an implicit chord with Select (hotkey_pad_binding_down), which
  the launcher now labels as "select + ..." (recomp-ui companion change).
- test_rewind_toggle_combo.py guards the new sites.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 21:00:59 -04:00
Matthew Stanley 240cff542e Merge remote-tracking branch 'origin/pr/312' into integrate/303-312
# Conflicts:
#	tools/compile_overlays.py
2026-09-03 11:41:22 -07:00
Matt Stanley 1d414fc7b1 Merge pull request #314 from mstan/chore/repo-cleanup
chore: repo hygiene — purge dead scratch tooling, relocate root docs/fixtures/BIOS profiles
2026-09-03 11:30:32 -07:00
Matt Stanley 71f3c8c360 Merge pull request #304 from Alexbeav/pr/gpu-dma2-correctness-ff7
dma: preserve live linked-list reads with word-cost timing
2026-09-03 11:24:02 -07:00
Matthew Stanley e6ce5006b4 docs: move root and stale docs into docs/ and docs/internal/
The repository root carried seven markdown files that were not onboarding
material, plus two that had been superseded by the code they described. This
sorts them: reader-facing docs to docs/, working notes to docs/internal/, and
the two superseded ones merged into the docs that replaced them.

Moved to docs/:            WIDESCREEN.md, ENHANCEMENTS.md, TCP_COMMANDS.md
Moved to docs/internal/:   PRINCIPLES.md, ISSUES.md, DEBUG.md, GTE_LM_FIX.md,
                           and from docs/: bios-hardening.md, overlay-status.md,
                           overlay-plan.md, overlay-recompilation-design.md,
                           overlay-discovery.md, TOMBA2_EXC_CONSULT_RESPONSE.md,
                           CASE_A_AOT_GAP.md, audit_inventory.md
Moved to docs/internal/upstream/: UPSTREAM_2026-08-20.md

The root keeps only what belongs there: README.md, CONTRIBUTING.md, LICENSE,
CLAUDE.md, THIRD_PARTY_ATTRIBUTION.md, RELEASE_NOTES.md, the dotfiles and
psxrecomp_cli.py.

MERGES

SPLITGEN_MIGRATION.md folded into docs/BUILDING.md as "Split generated C". Its
migration instructions were stale in a way that mattered: it told every game
repo to hand-edit its CMakeLists and add a file(GLOB ...) over
<serial>_full*.c, but runtime/runtime.cmake now does that itself. GEN_FULL_GLOB
is a multi-value argument of psxrecomp_add_game_runtime(); the loop at
runtime.cmake:1985 makes each pattern absolute against CMAKE_CURRENT_SOURCE_DIR,
expands it with file(GLOB), and concatenates the hits into
GAME_GENERATED_FULL_C. So a title names the pattern and gets however many shards
a regeneration produced. The rewritten section describes that, the two-step
fallback when the globs match nothing (GEN_FULL_FALLBACK, else <serial>_full.c
derived from the dispatch marker by substituting _dispatch.c), and the one
caveat the code actually has: that file(GLOB) is plain, not CONFIGURE_DEPENDS,
so a regeneration which changes the shard COUNT needs a re-configure to be seen.

psxrecomp-toml-readme.md folded into docs/GAME_PROJECT_SETUP.md as "EXE-only
autofill (psxrecomp-toml)", placed after "Disc autofill (probe_disc.py)" and
before "Project Studio" so the two autofill paths sit together. Its documented
flags were checked against the usage text in recompiler/src/main_toml.cpp:28-42
and against the argument parser at :150-165 -- all seven options match with no
drift, so the flag list is reproduced as-is. Added a pointer that probe_disc.py
is preferable when a cue exists, since that tool does the same EXE-header work
plus disc identity and netplay gates.

FUNCTIONAL FIXES

tools/gen_tcp_commands.py wrote to REPO/"TCP_COMMANDS.md". After the move that
path does not exist, so the generator would have created a new stray root file
and --check would have failed against it forever. Retargeted to
REPO/"docs"/"TCP_COMMANDS.md", along with the seven docstring and message
strings that name the path.

Running it surfaced two further problems, both fixed here:

  * The doc was genuinely stale -- pre-existing drift, not introduced by this
    commit (the moved file is byte-identical to origin/master, and no
    debug-server command table is touched here). Four commands registered since
    the last regeneration were missing from the index -- cdrom_timing_dump,
    savestate_status, ws_ui_groups, xprobe_watch -- and scanline had gained a
    backend. Regenerated: 307 -> 311 commands, and the change is confined to the
    AUTOGEN block (the hand-written head and tail are byte-identical to master).
    tools/gen_tcp_commands.py --check now exits 0.
  * DOC.write_text() has no newline argument, so Python translated every newline
    to os.linesep. On Windows that rewrote all 585 lines of the file to CRLF and
    turned a four-line index update into a whole-file diff -- measured: 589 CRLF
    / 0 LF after a run, against 0 CRLF / 585 LF in the repo. Now written through
    an explicit newline="" handle, verified to leave the file at 0 CRLF.

runtime/include/debug_server.h:9 and runtime/src/debug_server.c:8 cited "TCP.md
and DEBUG.md". TCP.md has never existed in this repo under that name; both now
name docs/TCP_COMMANDS.md and docs/internal/DEBUG.md.

LINK AND REFERENCE FIXES

Every inbound reference to a moved file was located by grepping each basename
across the whole tracked tree, not just the markdown. Markdown hrefs in
README.md, CONTRIBUTING.md, docs/ARCHITECTURE.md, docs/COMPILING_OVERLAYS.md,
docs/AOT_OVERLAY_PLAN.md, docs/RELOCATION_MANIFEST_FORMAT.md,
docs/config_schema.md, docs/OVERLAY_XREF.md, docs/TCP_COMMANDS.md and several
docs/internal/ files now point at the new locations; where two files became
siblings under docs/internal/ the path was shortened rather than lengthened
(bios-hardening.md's reference to overlay-discovery.md, for instance).
docs/COMPILING_OVERLAYS.md's "in this folder" phrasing was wrong once two of the
three files it listed moved, so it now names internal/ explicitly.

24 source-comment mentions of ENHANCEMENTS.md sections G1.x across 19 files
(recompiler, runtime, cmake) were retargeted to docs/ENHANCEMENTS.md with the
section numbers untouched. docs/SHADOW_ENHANCEMENTS.md is a DIFFERENT file and
was deliberately excluded.

20 rationale strings citing docs/CASE_A_AOT_GAP.md across the four
recompiler/seeds/*.json files were updated. Done as a byte-level substring
replacement rather than a JSON round-trip: two of those files are stored with
CRLF endings and two with LF, and re-serializing them reformatted 5,700 lines.
The byte-level version changes exactly 20 lines and each file still parses.

References that look similar but point elsewhere were deliberately left alone,
and are worth naming so a future pass does not "fix" them:

  * "recomp-template PRINCIPLES.md" / "NES recomp template PRINCIPLES.md" in
    CLAUDE.md:43, docs/SHADOW_ENHANCEMENTS.md:28,
    docs/internal/HLE_SCHEDULER_CARVEOUT_PLAN.md:5,
    runtime/src/debug_server.c:4934 and tools/parity_diff.py:8 are a different
    repository's file. Only the five that mean this repo's PRINCIPLES.md were
    updated.
  * Every remaining bare ISSUES.md mention is a GAME repo's tracker
    (MegaManX6Recomp 2/7/8/9, TombaRecomp 6), not this repo's.
  * docs/disc-speed.md:4,54,56 and docs/internal/bios-hardening.md:5 name
    overlay-discovery as a BRANCH, not a file, so no path applies.

INDEXES

README.md's "New here?" table gains rows for docs/WIDESCREEN.md,
docs/ENHANCEMENTS.md and docs/TCP_COMMANDS.md. docs/internal/README.md gains
sections indexing all fourteen documents that moved in, plus an "Upstream
contributor reviews" section describing upstream/ and the new sweep file, plus a
"Removed" bullet for CYCLE_TIMING_ARCH.md (deleted in the previous commit)
pointing at FAITHFUL_TIMING_PLAN.md, CYCLE_MODEL_BEETLE.md and
PRECISE_IRQ_SLICE.md -- following the SLJIT_PERSIST_CACHE.md precedent already
in that file.

docs/ENHANCEMENTS.md:7 claimed framework bugs live in "game repos' ISSUES.md
until a framework tracker exists". A framework tracker does exist; it now says
so and names the Beads epic.

VERIFICATION

A link checker was written to scan every tracked *.md for relative links,
resolving each against the tree being scanned. Baseline at origin/master: 129
files, 137 relative links, 3 broken. After both commits: 127 files, 159 relative
links, 3 broken -- the SAME three, all pre-existing and untouched here:

    docs/DATA_SHARDS.md -> INSTANT_LOADS_PARKED.md   (never existed)
    docs/DATA_SHARDS.md -> INSTANT_LOADS_SPIKE2.md   (never existed)
    docs/NETPLAY.md     -> ../lib/recomp-net/README.md  (inside a submodule)

Zero new broken links. The checker's own coverage was checked against a raw grep
for markdown link openers: the 3-link difference is C++ lambdas inside code
blocks, correctly not treated as links.

Also verified: py_compile over all 113 tracked tools/*.py plus psxrecomp_cli.py;
all four seed JSONs still parse; the test-registration guard still reports 123
test files all registered; recompiler/tests/test_coverage_vault.py still passes;
and no file in this commit had its line endings flipped (checked mechanically
across all 50 modified files, after two files were caught and redone).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 07:39:07 -07:00
Matthew Stanley f55c731c5f chore: remove dead scratch tooling and stale root files
Removes 204 tools/ scripts (203 .py/.sh plus tools/_missing_funcs.json) that
no tracked file references. Verified mechanically, not by eye: for each of the
204 stems, a word-boundary grep across every tracked text file in the repo,
excluding files that are themselves being deleted, returns zero hits. So
nothing kept imports, execs, or documents any of them. Most are the
underscore-prefixed one-shot probes the .gitignore convention already treats
as scratch (tools/_*.py is ignored; these predate that rule and were force-
added), plus a long tail of session-specific dump/decode/poll scripts with
hardcoded local paths and addresses.

Also removed at the root:

  * card2.mcd.bak.before_beetle_copy -- a stray backup of a memcard.
  * .mcp.json -- local MCP server wiring, not repo configuration.
  * SCPH1001.toml -- a root duplicate of bios/SCPH1001.toml, still on the
    pre-refactor [[recompiler.bios_vectors]] schema while the bios/ copy has
    moved to [recompiler.address_model]. Every reference in the tree already
    names bios/SCPH1001.toml, so the root copy was a stale trap: editing it
    would have had no effect on any build.
  * docs/CYCLE_TIMING_ARCH.md -- superseded by
    docs/internal/FAITHFUL_TIMING_PLAN.md, CYCLE_MODEL_BEETLE.md and
    PRECISE_IRQ_SLICE.md; nothing linked to it.

Memcard fixtures move out of the repo root into
runtime/tests/fixtures/memcards/ with a README recording what each of the
four images actually contains (directory entries read out of the images, not
guessed). Nothing in the build read them from a fixed path -- the four
hardcoded consumers (tools/check_card.py, inspect_card.py, peek_cards.py,
verify_card_buffer.py) are all in the deletion above.

.gitignore is corrected to match: runtime memcards a session creates stay
ignored (*.mcr anywhere, /*.mcd at the root, which is where the runtime drops
card1.mcd/card2.mcd when no path is configured), while the tracked fixtures
are re-included. The old !dummy.*.mcr negation existed only to keep the two
root oracle cards tracked and is replaced by
!runtime/tests/fixtures/memcards/*. Verified with git check-ignore --no-index
on each moved file, on the pre-existing runtime/tests/test_card_synth.mcd and
runtime/tests/test_dir/card1.mcd fixtures (all not-ignored), and on
hypothetical root card1.mcd / card2.mcd / saves/card1.mcr (all ignored).

beetle_libretro.cpp's warn-only memcard sanity check keeps its behaviour; its
WARNING now names where populated fixtures can be copied from. The cards are
opened relative to the process's working directory because the libretro
GET_SAVE_DIRECTORY environment callback answers "." (same file, line 419), so
the message says to copy them into the CWD rather than implying a repo path.

BIOS profiles SCPH101.toml and SCPH5552.toml move from the root into bios/,
joining OpenBIOS.toml and SCPH1001.toml so that bios/*.toml is the whole set.
Behaviour-preserving: config paths inside a profile resolve against
find_project_root() (config_loader.cpp:821), which walks up from the profile's
directory for .gitignore/.git/CMakeLists.txt -- bios/ has none of those, so it
lands on the same repo root as before, and bios/SCPH1001.toml already relied
on exactly that. tools/build_cli.py's staging map is updated to the new
sources; it already copied them to framework/bios/, so the packaged layout is
unchanged. recompiler/src/main_cli.cpp's bios/-first lookup keeps its root
fallback (harmless, and it is what makes older layouts still resolve).

Fixes a real latent break while in bios/SCPH5552.toml: seeds pointed at
"seeds/phase2_ghidra_seeds_SCPH5552.json", but there is no seeds/ directory at
the repo root -- the file lives at
recompiler/seeds/phase2_ghidra_seeds_SCPH5552.json, which is what SCPH101.toml
already named. Porting these two profiles to the address_model schema is
deliberately NOT done here; it is a separate tracked follow-up.

Test files that no build system knew about:

  * tools/test_coverage_vault.py PASSES (8/8). Moved to
    recompiler/tests/test_coverage_vault.py and registered as the ctest
    coverage_vault, which puts it inside runtime/check_test_registration.cmake's
    glob so it can never silently rot again. Its ROOT is adjusted from
    parents[1] to parents[2] for the new depth, matching every other python
    test in that directory. Re-run from the new location and from an unrelated
    CWD: 8/8. The registration guard was run standalone before and after, and
    with the add_test() deliberately broken as a negative control -- it does
    name recompiler/tests/test_coverage_vault.py as an orphan when
    unregistered, so the registration is load-bearing rather than decorative.
  * tools/test_compile_overlays_additive.py FAILS, 10 of 14 tests erroring
    against an API that has moved on, so it is removed rather than wired up.
    It calls four module attributes that no longer exist in
    tools/compile_overlays.py -- load_additive_captures,
    validated_cached_bundle_ids, cached_bundle_pairs, compile_and_publish_dll
    (zero occurrences each) -- and two signatures have changed under it:
    parse_overlay_func_ids() no longer takes require_stored_crc, and
    load_region_coverage() now takes 2-3 positional arguments where the test
    passes 5. Reviving it means rewriting it against the current additive
    capture API, which is a change to overlay-cache test coverage and needs its
    own commit and its own evidence, not a rename.

Verification: py_compile over all 113 remaining tracked tools/*.py plus
psxrecomp_cli.py passes; bash -n over every remaining tracked tools/*.sh
passes; importing sdk_progress, pad_delivery and cursor_writers from tools/
succeeds.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 07:39:06 -07:00
Matt Stanley 533816ecfb Merge pull request #307 from kerokline/feat/fast-forward-pad
runtime: controller host shortcut for fast-forward ([hotkeys] fast_forward_pad)
2026-09-03 07:30:35 -07:00
Matt Stanley a7459cddbe Make packaged overlay toolchain self-contained (#311) 2026-09-02 22:13:06 -07:00
Matt Stanley 6cb7a6b8c2 Stage overlay dispatch preamble with toolchain (#310)
Co-authored-by: Codex <codex@openai.com>
2026-09-02 22:08:45 -07:00
Matt Stanley a923d21a23 Merge pull request #298 from mstan/fix/lock-mode-launcher-reclamp
controller: re-clamp lock_mode across the launcher round-trip (defense-in-depth; root cause in recomp-ui)
2026-09-02 20:01:48 -07:00
Matthew Stanley 37b32bdd0c release: refuse to derive a release tag from an unbuilt runtime include tree
compile_overlays.codegen_hash() returns 0 when runtime/include/
overlay_codegen_hash.h is absent. That fallback is correct for its purpose and
should stay: overlay_api.h has the same __has_include fallback, so a runtime
built without the header and a compiler run without the header agree on
cg<N>_00000000_..., and the cache still loads.

It is never correct for a RELEASE. The header is generated by the runtime
BUILD, in runtime.cmake's "Hashing recompiler codegen" step, so a packager that
reads a tree which has not been built yet silently produces a tag with a zeroed
codegen hash: a plausible-looking string naming a namespace the shipped binary
does not read.

MEASURED 2026-09-02 while building the Tomba 2 Linux cache. Deriving the tag
before the runtime build produced

    cg10_00000000_gc530dff78_f0

where the correct tag for that same tree, after the build, is

    cg10_ecd487f7_gc530dff78_f0

Note what it looks like from the outside: a well-formed tag, the right number
of fields, the right config hash, the right flavor. A packager would happily
stage a whole cache under it and report a shard count. The runtime would ignore
every one of them. Nothing in a build, a configure, a link or a log would say
so -- which is the exact failure shape this bead exists to remove, one field
over from the field that started it.

So `release_stage.py cg-tag` now fails when the codegen hash reads as 0, and
says which header is missing and that the runtime target has to be built first
(the same build that publishes the flavor file the tag also needs). There is no
override: for a release the condition has no correct outcome.

test_release_stage_cache.py gains CodegenHashGuardTest -- refused on an unbuilt
tree, accepted on a built one. 18 cases, all passing.

Bead: beads-eio.3.102

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-02 19:00:03 -07:00
Matthew Stanley d655e47168 release: never stage a producer-internal cache file, even one ending in .so
Found by looking at a REAL cache tree instead of a synthetic one. A live Tomba 2
Linux cache build, sampled mid-run 2026-09-02, holds this beside its 184
finished shards:

  184  *.so.pair-lock                  producer concurrency locks
  185  *.c                             generated C the shard was compiled from
    1  .overlay-candidate-capacity.lock
    1  .overlay-candidate-capacity.lock.invocation
    2  .00096000_407B1780.so.tmp.ryt4jqca.so
       .00096000_407B1780.so.tmp.ryt4jqca.ranges

The last pair is the problem. compile_overlays writes each shard to a hidden
temp name and renames it into place, so a cache directory that is being written
-- or that was written by an INTERRUPTED run and still holds leftovers --
contains files that end in .so and .ranges and are not shards.

A suffix allowlist alone therefore ships them. So did the shell packagers'
`find ... -name '*.so' -o -name '*.ranges'`, which is a latent defect in all
three forks that nobody had hit yet. It is not loud when it happens: an extra
dlopen-able file with a hidden name lands in the release, the loader tries it,
and the outcome depends on how far the interrupted compile got.

FIX: the forbidden-artifact denylist is checked BEFORE the suffix allowlist, and
gains two rules -- a leading dot, and `.tmp.` anywhere in the name. Every
producer-internal file observed in a real cache is hidden and no shippable
artifact ever is, so the leading dot is the reliable discriminator; the `.tmp.`
rule is belt and braces for a future producer that stops hiding them.

Exclusions are now reported as a grouped tally rather than one line each. A real
cache holds one .pair-lock and one .c PER SHARD, so per-file notes would bury
the shard count under ~370 lines in every release log -- the same "the important
line scrolled past" failure this bead is about, one level down.

test_release_stage_cache.py gains test_real_producer_artifacts_never_ship, which
models that exact file set verbatim, including the temp pair, and asserts the
staged directory contains exactly the 11 shippable files and nothing hidden.
16 cases, all passing.

Also corrects the ApeEscapeRecomp line count in the header comment: 416 on
origin/master, not 403. 403 is what its parked working tree reports, and reading
a working tree instead of origin/master is the mistake that produced the wrong
first version of this bead.

Bead: beads-eio.3.102

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-02 18:33:46 -07:00
Matthew Stanley 94e3ef610d release: ONE staging implementation for both platforms (2 titles had no Linux release path)
tools/release_overlay_stage.ps1 exists because a packager that re-derived the
overlay cache tag in PowerShell went stale the moment the framework appended an
_f<flavor> field to it: the packager's filter matched nothing, a perfectly
regenerated cache staged ZERO shards, and the release shipped with no native
overlays (beads-eio.2.6). The fix was correct -- expose cache_tag() and route
the packager through it -- and it fixed Windows only.

Linux was never consolidated at all. Three title repositories each carry a
forked tools/package_appimage.sh, and each hand-builds the same tag string,
inside a python heredoc that has just imported the module that owns it:

    spec = importlib.util.spec_from_file_location('co', mod_path)
    m = importlib.util.module_from_spec(spec); spec.loader.exec_module(m)
    print(<the tag format, reproduced by hand> % (m.codegen_ver(inc),
          m.codegen_hash(inc), m.overlay_config_hash(exe, gt)))

MEASURED 2026-09-02, reading each repository's origin/master (not a working
tree -- the Ape checkout is parked on codex/ape-issue3-diagnostic-prerelease,
behind master, and reading it gives the wrong answer, including a wrong line
count):

  repo             lines  calls cache_tag()  composes _f  stages toolchain
  ApeEscapeRecomp    416         no              yes            no
  Tomba2Recomp       436         no              NO             no
  MegaManX6Recomp    408         no              NO             no

ApeEscapeRecomp 4a17272 (2026-08-28, "release: stage flavored Linux overlay
cache") hand-appended the field to ONE of the three forks. It did not adopt
cache_tag(); it wrote cache_tag="${cg_tag}_f${overlay_flavor}" and switched the
filters to it. So Ape is correct today and one character from the same failure
on the next tag-shape change, and the other two are broken right now:
find -path "*/$cg_tag/*" requires a separator immediately after the tag, the
real directory is named ..._f0, so the pattern cannot match. shards evaluates
to 0 and the packager exits 1.

That is not hypothetical. Tomba 2 v0.0.9, released 2026-09-02, has exactly two
assets: Tomba2Recomp-v0.0.9-windows-x64.zip and its .sha256. There is no
AppImage in that release at all, which is what a packager that exits 1 looks
like from the outside.

Two implementations, one per platform, have to be kept in step by review. This
bug is the proof that review does not hold: the identical defect had ALREADY
been fixed on the other platform, and the reason cache_tag() exists is that
fix. So there is now ONE implementation.

  tools/release_stage.py            the implementation (new)
  tools/release_overlay_stage.ps1   thin wrapper, surface UNCHANGED
  tools/release_overlay_stage.sh    thin wrapper, new (Linux had none)

Both wrappers are argument marshalling. Neither contains a tag format string, a
shard filter, an extension list, an arch-abi segment or a catalog rule. Every
sh function forwards "$@" verbatim, so a new option needs no edit there at all.
No title's package_release.ps1 needs a change: every parameter, switch and
return value of Get-OverlayCgTag / Add-OverlayCache / Add-OverlayToolchain /
Add-ModCatalog / Get-PinnedArchive is preserved, verified by dot-sourcing the
module in PowerShell 5.1 and exercising all five.

WHAT THE SHARED IMPLEMENTATION DOES DIFFERENTLY

* The tag is cache_tag(). Not "a call that produces the same string" -- the
  call. release_stage.py does not know the tag's shape and must never learn it.
  It also takes the arch-abi and the shared-library suffix from
  compile_overlays.cache_arch_abi()/overlay_ext() rather than naming them, so
  ".so under linux-x64" and ".dll under win-x64" are one decision, made in the
  module the loader is kept in lockstep with, not two.

* Shards are selected by the tag DIRECTORY'S IDENTITY, not by a path substring.
  -path "*/<tag>/*" is simultaneously too loose (it accepts a foreign arch-abi)
  and too strict (it rejected the real _f0 directory and staged nothing). The
  staged layout is then asserted -- exactly one tag directory, and it must be
  this build's -- because the loader scans cache/<id>/<tier>/<arch-abi>/<tag>/
  literally, and a shard anywhere else is worth exactly as much as no shard,
  which no count can distinguish.

* Selection is an ALLOWLIST of {<ext>, .ranges, .resident}, so a new artifact
  kind in a producer's cache is excluded by default rather than shipped by
  default, and the staged tree is re-scanned afterwards to prove that
  .abi_<tag>.ok, *.c and *.pair-lock did not ship. The sweep memo is the one
  that matters: it tells a player's first launch that an ABI sweep it never ran
  already passed, so a stale shard gets loaded instead of rejected.

* --allow-no-cache is gone from the Linux surface entirely. It is the
  relaxation that let a packager staging nothing read as a successful build.
  What remains is --ship-without-overlay-cache-because '<reason>', which is
  unwieldy on purpose, prints the reason, and is not the documented path in any
  recipe. PowerShell's -AllowNoCache is kept (unmigrated title packagers still
  pass it) and maps onto that flag, so even the escape hatch is one
  implementation.

* The mod catalog is verified against the manifest the BUILD published.
  runtime.cmake's _psxrt_stage_mod_catalog already writes
  psx_mod_catalog_<target>.txt -- the sorted, deduplicated id list that build
  staged -- which is a strictly better authority than re-globbing source trees,
  and it handles Tomba 2's Italian catalog overriding framework psx.* packages
  at the same ids without a per-variant number.

LINUX GETS AN OVERLAY TOOLCHAIN, AND THE RUNTIME CAN NOW SEE IT

No AppImage packager has ever staged one (grep -c overlay_toolchain = 0 in all
three forks), so on Linux the shipped AOT cache was all a player ever got: the
capture-and-compile fail-safe could not fire, and an overlay outside the cache
was interpreted forever.

Staging one alone would not have worked. runtime/src/main.cpp gated autocompile
on overlay_toolchain/python/python.exe, and built the command from
psxrecomp-game.exe and tcc/tcc.exe -- hardcoded Windows spellings, so on Linux
the gate could never be true no matter what a packager staged. A bundled Linux
toolchain would have been dead weight the runtime never looked at. Those three
names are now per-platform; the layout is identical, only the spellings differ,
and they are kept in lockstep with release_stage.py's TOOLCHAIN_PY_REL /
TOOLCHAIN_RECOMPILER.

The Linux interpreter pin is a python-build-standalone CPython 3.13.1 --
deliberately the same version as the Windows embeddable-zip pin -- fetched and
SHA256-verified on every use by the same code path as the Windows one, so the
platforms cannot drift on retry, cache or verification behaviour either. The
_stripped asset (21 MB download, 78 MB unpacked) is used; compile_overlays.py
needs only the stdlib, verified by import. tinycc publishes no prebuilt Linux
binary, so there is no pinned tcc there and the bundle drives a gcc from PATH;
main.cpp already says so on stdout rather than silently doing nothing. The
SHA256 was measured by downloading the asset and matching the publisher's own
.sha256 sidecar, not copied from a listing.

THE FLAVOR IS NO LONGER GUESSED

PSX_OVERLAY_FLAVOR is the high half of overlay_abi() and the _f<n> field of the
tag. It is a property of the BINARY, decided in runtime.cmake (0 base, 2 for a
PGXP target), and it is NOT platform-dependent -- Windows and Linux builds of
the same target share it, so nobody should ever add a platform branch for it.
Every packager simply assumed 0, and that assumption is invisible when wrong: a
PGXP runtime reads ..._f2/ while the packager stages ..._f0/, the package ships
a cache the binary ignores completely, and nothing fails.

runtime.cmake now publishes psxrecomp_overlay_flavor-<target>.txt next to the
exe-name publication it already writes, for the same stated reason: so nothing
downstream re-derives it. release_stage.py cg-tag takes either --flavor or
--flavor-from-build, and there is no default -- a packager either states the
value or reads what the build published.

TESTS

runtime/tests/test_packagers_never_format_cache_tag.py asserts the rule the two
fixes above were supposed to establish: the tag format appears only in
tools/compile_overlays.py and runtime/src/overlay_loader.c (measured: 5
occurrences, all inside those two files -- note overlay_loader.c has four, at
1745, 1773, 1924 and 1930, the last two being the actual read path) and nowhere
else, and a release packager that touches the overlay cache must reference the
shared staging surface. It carries a positive control -- each owner must
actually contain the pattern -- because the investigation of this very bug once
reported "no cache present" from a probe that could not have found a cache at
all. It is also the SHARED check a title runs against its own tree
(--root <repo>), so the rule about not duplicating logic is not itself
duplicated per repository.

Run against Tomba2Recomp origin/master it reports, pre-fix:

  tools\package_appimage.sh:292 formats the overlay cache tag itself
  tools\package_appimage.sh is a release packager but does not reference the
  framework shared staging surface

runtime/tests/test_release_stage_cache.py pins the behaviour, hermetically, on
synthetic cache trees -- no recompiler, no captures, no build, which is the
point: this is the assertion that could have run in CI on the day the tag grew
a field. 15 cases, including that a tag one field short selects nothing AND
raises rather than warns, that a foreign tag namespace / arch-abi / the removed
sljit tier are not shipped, that the three forbidden artifact kinds never ship,
and that developer state (mods/installed, mods/state.toml) never ships.

Both are registered in runtime/CMakeLists.txt, so the configure-time
test-registration guard covers them and ctest runs them.

NOT DONE, deliberately: tools/package_release.ps1,
tools/package_release_macos.sh and tools/package_setup_host.sh each carry their
own hand-written mod-catalog staging. That is the same duplication defect one
layer over and they should route through stage-mods too, but it is a separate
change needing separate proof (macOS cannot be built or verified here), so the
new test scopes its packager assertion to packagers that touch the overlay
cache rather than pretending to cover them.

Bead: beads-eio.3.102

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-02 18:28:24 -07:00
Kevin 2ae781095a runtime: controller host shortcut for fast-forward ([hotkeys] fast_forward_pad)
Turbo was keyboard-only ([KeyMap] Turbo, Tab). Add a third host shortcut the
launcher can bind to a pad button or chord, next to Rewind and Save states:

- main.cpp: PSX_ASSIST_BIND_FAST_FORWARD + "Fast-forward" label; default
  chord select+L1 (PSX_HOTKEY_PAD_SELECT_L1 = 1528); the manual fast-forward
  block now runs while either the keyboard key or the pad binding is held,
  through the same hotkey_pad_binding_down() matcher the other two use.
  Threaded through every settings hop (settings.toml -> globals -> launcher
  seed -> LauncherSettings -> back), mirroring save_state_menu_pad.
- config_loader: [hotkeys] fast_forward_pad read/write, same pad_bind_value_ok
  bound as the siblings.
- test_rewind_toggle_combo.py guards the new sites; README documents the chord.

Launcher side: recomp-ui draws every advertised host shortcut instead of the
first two (companion PR).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 20:12:21 -04:00
Matthew Stanley 57648745a3 mods: the framework stages the title's catalog too (5 titles could not package)
4cc04be3 ("rebump") renamed the staged mod catalog from <exe>/mods/packages to
<exe>/mods/bundled so that a rebuild could wipe build output without deleting
the player's own installed mods. runtime.cmake followed the rename. The shared
release module followed it (release_overlay_stage.ps1's Add-ModCatalog reads
mods/bundled). docs/MOD_PACKAGES.md already described the destination as "the
framework's mods/builtin/packages PLUS the title's mods/preloaded/packages".

No title CMakeLists followed, because the second half of that sentence was
implemented five times, once per repository, as a hand-written

    add_custom_command(TARGET psx-runtime POST_BUILD
        COMMAND ${CMAKE_COMMAND} -E copy_directory
            "${<TITLE>_PRELOADED_MODS}" "$<TARGET_FILE_DIR:psx-runtime>/mods")

in ApeEscapeRecomp, Tomba2Recomp and MegaManX4/5/6Recomp. That names the
destination as a STRING. There is no symbol, no header, no link dependency --
so renaming a staged output directory breaks nothing a configure, compile or
link step can see, and the diffstat of the commit that does it looks cosmetic.
The five titles went on staging into a directory neither the launcher nor any
packager reads, and discovery was deferred to whichever title next ran a
release packager, in a different repository, on a later day.

MEASURED, on real build output. Framework origin/master 22fbbfca plus
Tomba2Recomp origin/master cdf00af, both unmodified, both built:

  build-dir mods/
    bundled/psx.enhancement.cd-speed          <- framework, correct
    bundled/psx.enhancement.fast-loading
    bundled/psx.enhancement.pgxp
    bundled/psx.presentation.bezel
    packages/tomba2.debug.debug-menu          <- title, stranded
    packages/tomba2.enhancement.skip-fmvs
    packages/tomba2.enhancement.widescreen
    packages/tomba2.experimental.interpolated-frame-rate

and Add-ModCatalog run against that tree, from this commit's parent:

  Mod catalog is missing package(s) the sources define:
  tomba2.debug.debug-menu, tomba2.enhancement.skip-fmvs,
  tomba2.enhancement.widescreen, tomba2.experimental.interpolated-frame-rate.
  They exist in the source tree but did not reach ...\mods\bundled, so the
  release would ship a Mods page the dev build does not have.

Shipped releases are NOT affected. Tomba 2 v0.0.9 pins 04d9184b, which is an
ancestor of 4cc04be3 and contains zero occurrences of migrate_legacy_root and
bundled_root; its artifact is a self-consistent mods/packages layout. This is a
forward break only.

THE FIX PUTS THE LAYOUT IN ONE PLACE.

psxrecomp_add_runtime_target() gains a one-value keyword PRELOADED_MODS_DIR.
A title declares WHERE its packages live and never HOW they are laid out:

    set(APE_PRELOADED_MODS "${CMAKE_CURRENT_SOURCE_DIR}/mods/preloaded")
    psxrecomp_add_runtime_target(psx-runtime
        ...
        PRELOADED_MODS_DIR "${APE_PRELOADED_MODS}")

A keyword argument rather than a positional one or a variable read out of the
caller's scope, for three reasons. It is what the other fourteen one-value
arguments already are, so it needs no new convention and reads the same as the
LAUNCHER_BOXART line next to it. A pre-set variable would be invisible at the
call site and would silently do nothing if set after the call -- the same
"fails quietly in the caller's repository" shape as the bug being fixed. And it
is PER TARGET, which a global variable cannot be: Tomba 2 has two runtime
targets needing two different catalogs (mods/preloaded, mods/preloaded_ita).
psxrecomp_v4_add_runtime_target and psxrecomp_add_game_runtime both forward
their remaining arguments, so it reaches through both wrappers unchanged.

_psxrt_stage_mod_catalog() now emits, in one POST_BUILD command sequence per
runtime target: wipe mods/bundled; remove exactly the ids it is about to stage
from any pre-existing mods/packages; copy the framework builtins; copy the
title's packages; copy <dir>/README.md to mods/README.md; verify. Consequences
worth stating:

  * mods/README.md previously reached mods/ only as a side effect of copying
    the whole mods/preloaded tree. Now that only packages/ is staged it is
    copied on purpose, or it would silently disappear. Checked per title: all
    five carry mods/preloaded/README.md and none carries a root mods/README.md,
    so mods/preloaded/README.md is the only source there ever was.
  * a title may still OVERRIDE a framework builtin at the same id and version,
    because the framework copy runs first and the title copy overwrites it.
    Tomba 2's Italian catalog relies on exactly this, shipping localized psx.*
    manifests. The id LIST is deduplicated for the assertion and the reported
    count while the copies keep both, or the build claims to stage 11 packages
    for a catalog that holds 7.
  * COSIM targets stage nothing. They have no launcher and no Mods page, and
    they land in the same output directory as the real runtime, so letting one
    stage had it wipe and re-stage the runtime's catalog with only the
    framework half whenever -DPSX_BUILD_COSIM=ON built it last.

TWO GUARDS, because the failure mode being closed is silence.

Configure time: a project with packages under mods/preloaded/packages that does
not declare PRELOADED_MODS_DIR is a FATAL_ERROR naming the packages and the
argument to add. That is exactly the missed-title state and it is detectable
before any build runs. An empty packages/ is fine (the scaffold creates one),
and PRELOADED_MODS_DIR NONE is the explicit opt-out.

Build time: runtime/psx_check_mod_catalog.cmake fails the build if a declared
package did not reach mods/bundled, or if any package this build stages turned
up under mods/packages -- which after this change can only mean a CMakeLists
still stages its own catalog by hand. It runs as the LAST POST_BUILD step, via
cmake_language(DEFER), because POST_BUILD commands execute in registration
order and the framework block registers BEFORE anything a title adds after its
psxrecomp_add_runtime_target() call. Verified by inspecting build.ninja: the
deferred command lands after a title-registered POST_BUILD, not before it.

The deferred pass takes NO arguments and reads its work list out of global
properties. cmake_language(DEFER CALL <fn> "${var}") does not carry a
function-local variable through -- measured with cmake 4.2.2, the callee
receives an empty argument and an add_custom_command built from it fails at
directory end with "CMakeLists.txt:DEFERRED". Recorded so the next reader does
not re-derive it.

Ids the build does NOT stage are deliberately left alone in mods/packages. On a
self-compiling setup release that directory can hold packages the player
installed under the pre-split layout, and mod_packages.cpp's
migrate_legacy_root() relocates those into mods/installed on the next launch.
Removing only the ids this build stages is also what lets a build directory
made before the split be migrated silently, so the guard's "an id turned up in
mods/packages" report has no false positives.

The same script is registered as the ctest psx_staged_mod_catalog_test in every
project that stages a catalog, with REQUIRE_STAGED=0 so a tree where the
runtime was never built reports a skip rather than a spurious failure. Where
two runtime targets share an output directory it accepts either target's
complete catalog, since after a full build mods/bundled holds whichever linked
last -- which is correct for the packager, as it builds exactly one target per
variant, and each target's own POST_BUILD guard already verified its catalog at
stage time.

TESTS. runtime/tests/test_mod_catalog_layout.py exercises the guard against
on-disk fixtures: a reproduction of the broken state as it actually appeared
(bundled/ holding the four psx.* ids, packages/ holding the four game ids), a
declared package simply absent, a package directory with no manifest.toml, a
FOREIGN legacy package that must NOT fail the build, the require-staged split
between the two callers, and the shared-output-directory sibling case. It also
pins two source invariants in runtime.cmake so the layout cannot drift back
into per-title copies. Registered in runtime/CMakeLists.txt; the
test-registration guard reports 123 test file(s), all registered.

Framework ctest: 59/59 pass, 2 pre-existing DISABLED.

Also updated so the next title does not repeat this: the project scaffold
template now passes PRELOADED_MODS_DIR (it staged no catalog at all before, so
a scaffolded project with mods/preloaded/packages shipped none), and
docs/MOD_PACKAGES.md gains a "How bundled/ gets staged" section written at
titles.

Bead beads-eio.3.101. The five title-side patches must land AFTER this commit:
they reference PRELOADED_MODS_DIR, which does not exist before it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-02 16:37:19 -07:00
Alexandros Mandravillis 9dfb4174ad runtime: version GPU DMA snapshot format 2026-09-03 01:55:30 +03:00
Alex Vanderveen bc88c06c5f runtime: restore DMA2 word-cost timing 2026-09-03 01:23:01 +03:00
Alexandros Mandravillis 99f775c839 runtime: close GPU DMA review gaps 2026-09-03 01:20:43 +03:00
Alexandros Mandravillis f424a8144d runtime: preserve live GPU DMA payload timing 2026-09-03 01:20:41 +03:00
Alex Vanderveen 85126d8d1b Revert "dma: charge DMA2 linked-list walks what the validated model charged" 2026-09-02 18:05:17 -04:00
Alex Vanderveen 39bcdf0d56 dma: charge DMA2 linked-list walks what the validated model charged
Slicing the GPU linked-list DMA across guest cycles (8af48ae9, PR #277) also
changed what a walk costs: 8 clocks for every node header plus 5 setup clocks
per non-empty node, where the pre-slicing path charged one clock per word via
schedule_delayed_complete(2, actual_words, DMA_GPU_CYCLES_PER_WORD).

An ordering table is mostly EMPTY nodes -- Final Fantasy VII's runs to
ot_max 4094 -- and each empty slot still paid the full 8-cycle header for a
packet that does not exist. Measured on FF7 (SCUS-94163):

  4,807 nodes / 12,757 words   52,384 cycles   vs   17,564   (3.0x)
 44,010 nodes / 117,507 words  499,187 cycles   vs  161,517   (3.1x)

An NTSC frame is 564,480 cycles, so a single walk reached 88% of a frame.
FF7's libgpu polls CHCR bit 24, finds the channel still busy after 790-890
reads, and force-clears the start bit at 0x800484F0 -- the documented
stuck-channel path. cancel_async_transfer() then abandons the walk partway
through, and the rest of the frame's geometry is never sent to GP0. In a
battle 12.6% of ordering tables were aborted this way; models rendered with
missing or shattered limbs.

The guest is behaving correctly: it inspected the busy bit before clearing it.
The slicing is also correct and worth keeping -- a guest really can change a
packet after starting DMA and before DMA reaches it, and only a sliced walker
models that. Only the cost was wrong.

Header is one clock, setup is free, so a walk costs exactly (nodes + words).
When setup is free the payload boundary is the same service as the header, so
the header phase falls through instead of returning; cycles_to_event() never
reports zero, and returning would spend an unaccounted scheduler cycle per
non-empty node. The packet is still read at its own boundary, so #277's
mid-walk mutation behaviour is unchanged and its test still passes.

(nodes + words) is not a new assertion -- it is the cost this runtime shipped
and was validated against. A non-zero per-node overhead may well be real on
hardware, but it needs a measurement behind it, not a substituted constant.

Observability, because none of the above was visible:

  dma_state gains a gpu_ot block -- starts, completes, cancels, starts_dropped,
  nodes/words/cycles of the last walk and the max, plus guest reads of CHCR(2)
  and a ring of the last 8 aborts carrying the guest PC, CHCR value, how far
  the walk got, and how many times the guest polled before giving up. The
  silent early return in start_async_gpu_linked_list() is now counted as
  starts_dropped rather than losing an ordering table with nothing said.

  PSX_GPU_LL_SYNC=1 drains a walk at start. Diagnostic A/B lever only: it
  completes immediately as well, so it is not a faithful restoration of the
  pre-slicing timing and must not be used as a fix.

The timing test now asserts the cost model rather than describing it: a table
of empty nodes must cost exactly one cycle per node. That is the check that
would have caught this.

Verified on FF7 SCUS-94163 from a save state: cycles_max 58,933 -> 16,972,
a 144-node/490-word walk costs exactly 490 cycles, and battle geometry renders
correctly with the walk still sliced.

Not fixed here: the same commit also broke gpu_frame_dump's func attribution.
GP0 writes now issue from dma_advance() on the cycle scheduler, so every
primitive is stamped with whatever the scheduler interrupted -- all 1,803
primitives in a captured battle frame attributed to BIOS 0x1FC02B50 instead of
the issuing game function. That disables gpu_frame_diff's "a function stopped
drawing" verdict and gpu_frame_layers entirely. Stamping the DMA initiator was
tried and does not work: g_debug_current_func_addr reads 0 at kick time. The
ra column still carries real game addresses as a fallback. Left as a separate
issue rather than shipped as a fix that does not fix it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L9v7e6AVZe3eRP8VXey9Pz
2026-09-02 18:01:15 -04:00
Matthew Stanley c5bac02214 controller: re-clamp lock_mode across the launcher round-trip
Ape Escape declares `default_mode = "analog"` + `lock_mode = true`
(ApeEscapeRecomp/game.toml:208-209) and shipped with the left stick acting as
camera and the right stick doing nothing -- i.e. the pad booted DIGITAL on a
dual-analog title, and because lock_mode hides the pad-mode selector there was
no UI with which to fix it.

The root cause is in recomp-ui and is fixed in the companion PR (see below).
This is the host side of the same defect: three separate places in main() where
a locked title's declared pad mode could be lost or where a keyboard seat's
configured mode was destroyed on its way past the launcher.

WHAT WENT WRONG, END TO END
---------------------------
1. recomp-ui launcher_model.c honours the lock at line 545-546
   (`if (!pad_mode_selectable) pad_mode[p] = locked_pad_mode`) and then
   clobbers it 15 lines later with an ungated keyboard coercion. Player 1's
   device defaults to "keyboard" in a release build (main.cpp:11164), so
   pad_mode[0] came back as 2 (D-Pad) from every fresh install.
2. `launcher_model_set_pad_mode` and the default-for-source helper both
   early-return when the mode is not selectable -- correctly, since the mode
   is locked -- so once the value was 2 nothing could move it back. Attaching
   a real DualShock did not help.
3. Here, the launcher-exit readback took `player_mode[i] = ls.pad_mode[i]`
   verbatim and then persisted it into settings.toml, so DIGITAL beat the
   lock and kept beating it on every later launch.
4. `sio_set_pad_analog(slot, 0, ...)` then took the 4-byte pad_response_len
   branch in runtime/src/sio.c: the pad reports no analog bytes, so the right
   stick is dead and the left stick is folded onto the D-pad. Exactly the
   reported symptom.

The clamp at main.cpp:11869 already existed and already covered two holes
(launcher-less builds, and a settings.toml written before the game declared
lock_mode). It runs BEFORE the launcher, which is why the launcher round-trip
was a third hole it could not see.

WHAT THIS CHANGES
-----------------
runtime/include/launcher_device.h gains
`resolve_player_mode_after_launcher(launcher_mode, lock_mode, locked_mode,
mod_override_mode)`: one pure function that fixes the precedence of the three
sources of truth for a seat's pad mode -- an active mod controller-mode
override, then game.toml lock_mode, then whatever the launcher returned. Both
launcher-exit readback loops call it, and both call it before their persistence
write, so an unsupported mode can no longer be latched into settings.toml.

The mod-override arm is not decoration. A bare `if (ctrl_lock_mode)
player_mode[i] = ctrl_locked_mode[i];` -- which is the obvious fix and was the
first version of this change -- breaks mod controller-mode overrides on locked
titles after a lobby soft return:

  * `g_mod_controller_mode_override` is applied at main.cpp:13084-13085,
  * the `session_reboot:` label is BELOW it at 13159,
  * `goto session_reboot` (14914) therefore never re-runs the override block,
  * but the soft-return readback loop does run.

Before this change an override survived a rematch only because it round-tripped
through `ls.pad_mode[i]`, seeded at the soft-return entry from the post-override
`player_mode[i]`. An unconditional clamp cuts that path and silently drops the
override. `psx_mod_set_controller_mode_override` is public plugin API
(runtime/include/mod_plugins.h:229) with no shipped caller today, so this is
latent rather than live -- fixed anyway, because the next caller would have had
no way to tell why their override stopped applying to a locked title after a
rematch.

Three destructive coercions are also removed, all of the same shape -- a
keyboard seat having its configured mode overwritten with DIGITAL:

  * the two launcher-entry seeds (`ls.pad_mode[i] = (src == 1) ? DIGITAL :
    player_mode[i]`) told the launcher a lie about what the seat is configured
    for, and the launcher handed the lie straight back to be persisted;
  * the launcher-exit readback did the same in the other direction.

None of them was load-bearing: `effective_player_mode()` (main.cpp:4359-4362)
already returns DIGITAL for any seat whose kind is keyboard, whatever the
seat's stored mode says, and every consumer of a seat mode goes through it or
through `effective_player_mode_for_sio()` -- `refresh_player_devices`,
`capture_pad_slot`, and the netplay sample path were all checked. So a keyboard
seat still presents as a plain SCPH-1080 while the keyboard is driving it; the
difference is that its stored mode is no longer corrupted for the pad that
arrives later.

Note the unlocked case was never as bad, and this is worth writing down because
it is what made the bug look title-specific: for a selectable title,
`launcher_model_set_source` -> `apply_default_pad_mode_for_source` re-defaults a
gamepad seat to Analog, so picking a device in the launcher repairs the value.
That is precisely the repair a locked title cannot perform, because both of
those functions refuse to touch a locked mode.

TESTS
-----
There was zero coverage for any of this. (ApeEscapeRecomp's own
`ape_stick_response_test` covers the stick RESPONSE CURVE, not mode or axis
routing, and lives in the title repo -- it cannot see this.)

  * runtime/tests/test_launcher_pad_mode_resolve.cpp -- behaviour of the
    resolver: a locked title yields its declared mode for every launcher value
    including out-of-range ones (so persistence can never receive an
    unsupported mode), an active override outranks the lock, and the "no
    override" sentinel stays negative to match the direct apply site.
    Registered as launcher_pad_mode_resolve_test.
  * runtime/tests/test_launcher_pad_mode_wiring.py -- how it is WIRED: exactly
    two readback sites call the resolver, the resolution precedes each
    persistence write, the pre-launcher clamp still runs first, and
    `player_mode[i] = PSXRecompV4::PAD_MODE_DIGITAL;` appears nowhere. Verified
    to go red when the coercion is re-introduced at either site, not just when
    the helper is deleted. Registered as launcher_pad_mode_wiring alongside the
    other source-invariant guards in recompiler/CMakeLists.txt.

Both call sites were confirmed to be live code, not preprocessed-out, by
injecting a bad symbol name and reading the compiler errors back: main.cpp:12701
compiles under `-DRECOMP_LAUNCHER` and main.cpp:14665 additionally needs
`PSX_HAS_LOBBY_CLIENT` (i.e. `-DPSX_NETPLAY=ON`), so the second site is only
exercised in a netplay build and was verified in one.

Verified: launcher_pad_mode_resolve_test, launcher_device_roundtrip_test,
host_keymap_test, keyboard_pad_chord_test, sio_card_protocol_test all pass
(sio_dualshock_rumble_test is registered DISABLED); launcher_pad_mode_wiring and
every other python guard that reads main.cpp pass, except two that were already
red before this branch -- runtime_perf_diag_guards (registered DISABLED) and
launcher_vulkan_option, which dies in `Path.read_text()` with no encoding on a
cp1252 console. That last one is a real latent break in 13 guard scripts and
wants its own change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-02 14:11:25 -07:00
Alex Vanderveen 4cc04be3e6 rebump 2026-09-02 13:26:06 -04:00
Matthew Stanley fbf0427ba6 Fix display cadence refresh on monitor moves 2026-08-31 19:28:10 -07:00
Matthew Stanley 9abb83de8f Merge PR 283 after PR 282 revert for regression validation
# Conflicts:
#	recompiler/CMakeLists.txt
2026-08-31 13:50:04 -07:00
Matthew Stanley a4721966bc Revert "Merge pull request #282 from Alexbeav/pr/cdrom-seek-semantics"
This reverts commit 6003e63fbc, reversing
changes made to 59b9658c21.
2026-08-31 13:43:27 -07:00
Matt Stanley be19a02902 Merge pull request #285 from Alexbeav/pr/zero-bios-registry
Make zero-BIOS registries portable to MSVC
2026-08-31 12:06:01 -07:00
Alexandros Mandravillis eb12cb2411 Fix zero-BIOS MSVC registry build 2026-08-31 09:16:14 +03:00
Alexandros Mandravillis e7c29ac286 Cover both recomp UI fallbacks 2026-08-31 09:15:51 +03:00
Alexandros Mandravillis fe6a06d298 Preserve explicit recomp UI root 2026-08-31 09:15:51 +03:00
Alexandros Mandravillis cc1967cf5a Keep 15-bit scanout independent 2026-08-31 09:15:39 +03:00
Alexandros Mandravillis 4676707ae0 Preserve interlaced PAL scanout height 2026-08-31 09:15:39 +03:00
Alexandros Mandravillis 0ff78cc4ca Blank display ranges outside active video 2026-08-31 09:15:39 +03:00
Alexandros Mandravillis 87ecd78165 Keep scanout fixture title neutral 2026-08-31 09:15:39 +03:00
Alexandros Mandravillis 12f2060edb Present full depth24 scanout canvas 2026-08-31 09:15:39 +03:00
Alexandros Mandravillis a851e26d48 Preserve visible depth24 scanout rows 2026-08-31 09:15:39 +03:00
Alexandros Mandravillis c159c8603b Handle invalid display ranges 2026-08-31 09:15:38 +03:00
Alexandros Mandravillis 025193e2e8 Center depth24 scanout from display range 2026-08-31 09:15:38 +03:00
Matthew Stanley 94b64b0b4b Merge master into CD-ROM seek semantics PR 2026-08-30 20:53:43 -07:00
Matt Stanley 59b9658c21 Merge pull request #279 from Alexbeav/codex/cdrom-timed-reinsert-upstream
cdrom: model timed tray cycle for reinsert
2026-08-30 20:52:24 -07:00
Matt Stanley ac9be0b6c9 Merge pull request #277 from Alexbeav/pr/gpu-dma2-slicing
runtime: slice GPU linked-list DMA
2026-08-30 20:52:21 -07:00
Matt Stanley 0ed754a3ce Merge pull request #273 from kerokline/fix/present-skip-unpaced-vsync-cadence
runtime: pace frames the GL renderer elides a swap for
2026-08-30 20:52:18 -07:00
Alexandros Mandravillis d43442bfe7 Clarify CD-ROM test fixture usage 2026-08-31 02:20:05 +03:00
Alexandros Mandravillis 12a3eb7d11 Complete CD-ROM reset and snapshot guards 2026-08-31 02:20:05 +03:00
Alexandros Mandravillis 25e7624a0d Harden CD-ROM seek state coverage 2026-08-31 02:20:05 +03:00
Alexandros Mandravillis 3b837f4e2d Fix CD-ROM seek and read handoff 2026-08-31 02:20:04 +03:00
Alexandros Mandravillis fb02451aab cdrom: model timed tray cycle for reinsert 2026-08-30 23:24:47 +03:00
Alexandros Mandravillis 8af48ae986 runtime: slice GPU linked-list DMA 2026-08-30 23:17:24 +03:00
Kevin a91884a4bb runtime: pace frames the GL renderer elides a swap for
The GL renderer may skip SDL_GL_SwapWindow when the displayed rect is
unchanged, so a double-buffered 30 Hz game does not swap an identical
front buffer on the intervening vblank. The frontend calls present once
per guest vblank, so eliding a swap also elides whatever that swap was
blocking on.

That is only safe while the wall-clock pacer owns cadence, because the
pacer runs per present regardless of what the renderer does. When driver
vsync owns cadence instead (~60 Hz panel, present_vsync_owns_cadence()),
the pacer is deliberately not run -- the two are XOR to avoid
double-blocking -- and the swap block becomes the only throttle. An
elided frame then blocks on nothing, and a game presenting every other
vblank advances two guest vblanks per block: exactly 2x real time.

Measured on BreathOfFire3Recomp (SLPS-00990, 60 Hz panel, OpenGL):
guest vblank counter sampled against wall clock ran 120.4 fps / 2.01x
before, 59.8 fps / 0.997x after. Swaps per second are unchanged at 60
either way -- the fix removes a free-running guest frame, not a swap --
and per-frame GPU cost is unchanged (present_gpu_ms_avg 12.2 -> 12.6,
scene_gpu_ms_avg 4.5 -> 4.1).

Gate both skip sites (VRAM and wide-FBO paths) on the cadence owner,
alongside the existing OSD and interpolation-cadence guards. Only the GL
renderer has this optimisation; the software and Vulkan paths always
present and were never affected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 14:20:09 -04:00