Brings in the observability window-trap work (per-game watch list,
window traps that read as results) and the generated-project framework
docs. Conflict was confined to TCP_COMMANDS.md's autogenerated command
index — both branches added commands — resolved by regenerating with
tools/gen_tcp_commands.py against the merged servers (318 commands,
--check passes). Full runtime build + test_cli_project_packaging pass.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016mwbhSkoL66o8BSBuP6DiY
Conflict: recompiler/src/pgxp_hook_emitter.cpp, 13 hunks.
Both sides independently fixed the same defect — a PGXP hook appended on
the same line as a trailing `#endif` is swallowed by the preprocessor as
extra tokens and silently discarded, so the MULT/DIV/MFLO/MFHI hooks
never ran.
Took upstream's resolution:
- Local fix hard-coded a trailing-"#endif" test at function entry and
spliced "\n{}\n" around the emission.
- Upstream factors the test into
emission_ends_on_preprocessor_directive(), which scans the last line
properly (leading whitespace, any directive, not just #endif) and is
EXPORTED. code_generator.cpp:1709 and strict_translator.cpp — both
arriving in this same merge — call it to guard their own same-line
appenders, so the local version would have left those two callers
with an undefined symbol.
The local side also newline-guarded the START of the emission. Dropping
that is safe: every block-cycles emission literal already begins with
'\n' (gte_stall, gte_read, the muldiv set/stall blocks), so a directive
never lands mid-line there.
Verified: recompiler builds; emitter_directive_line_test and
lwlr_load_delay_forward pass; generated code for mult/mflo puts
PGXP_MULDIV, PGXP_ALU and the trailing disassembly comment each on their
own line after #endif; the PR #172 deferred-load/PGXP scoping fix still
holds; spu.c compiles against this game's build and test_spu_pitch_zero
passes all 14 assertions.
Pre-existing on this branch, unrelated to the merge: 7 ctest failures
(dirty_text_admission_guards, aot_overlay_discovery,
mod_owned_display_controls, launcher_vulkan_option,
mod_load_acceleration, overlay_decodable_fallback, vk_present_wait_stage)
— all inspect files this merge does not touch.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
PR #171 fixed the PGXP hooks, which were space-joined onto emissions that
end on a bare #endif (the PSX_ENABLE_BLOCK_CYCLES muldiv stall/latency
blocks) and were therefore swallowed as "extra tokens" and silently
discarded -- 3,187 hooks per title in Ape Escape alone, plus 1,024 in the
BIOS. It fixed the instance. Two same-line appenders of the same class
were left behind:
1. strict_translator.cpp -- the deferred-load PGXP_LOAD hook is still
space-joined onto c_code_deferred. All five c_code_deferred producers
currently end in plain statements, so it is benign TODAY, but the
moment a block-cycles annotation lands on deferred loads it breaks
exactly the way #171 just did, and just as silently.
2. code_generator.cpp -- the trailing disassembly comment is appended to
the emission unconditionally. Reachable whenever append_pgxp_hooks
returned early and left the emission ending on a directive, e.g.
`mfhi $zero` / `mflo $zero` (rd == 0 takes an early return). The
comment is then dropped and -Wendif-labels fires.
Both now route through one predicate,
emission_ends_on_preprocessor_directive(), so the hazard is closed by
construction rather than one site at a time. Site 1 takes an unconditional
newline (always valid where the space was); site 2 only breaks the line
when the emission actually ends on a directive, so ordinary instructions
keep their inline comment and the generated diff stays minimal.
Measured effect on emitted code, against merged master:
- SCPH1001 BIOS: byte-identical
- OpenBIOS: 2 sites, both the deferred PGXP_LOAD moving to its own line,
identical token sequence
- Ape / Tomba 1 / Tomba 2 / MMX6: every function token-identical
(3095/6891/2726/4023 functions each, none added, none removed, zero
body changes)
Adds emitter_directive_line_test (10 checks, registered with add_test) so
the predicate cannot regress: it pins both real emission shapes that end on
#endif and, just as importantly, the negative cases -- a false positive
would inject stray newlines into every instruction.
ctest before and after this change fails the identical 6 pre-existing tests
(aot_overlay_discovery, launcher_vulkan_option, mod_load_acceleration,
release_zip, sdl3_main_single_include, vk_present_wait_stage); no new
failures. full_function_emitter_test and l2_structural_test both pass.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Conflict: recompiler/src/code_generator.cpp, deferred-load emitter.
Both sides fixed the same bug — the psx_ldd_<addr> temp going out of
scope when PGXP wraps the load in its own `{ ... }` block. Took the
upstream resolution, which is a superset:
- Local backport hoisted the declaration to function scope.
- Upstream declares it at the top of the pair block instead (same
scope fix, tighter), AND repoints the PGXP_LOAD hook's value
argument at the temp. The local version left the hook reading
cpu->gpr[N], which still holds the pre-load value because the
writeback is deferred — so PGXP recorded a stale word.
Upstream's LWL/LWR forwarding (set_lwlr_merge_forward and the
full-function emitter's ldd_lwlr_forward set) auto-merged and is kept.
Verified: recompiler builds; test_lwlr_load_delay_forward.py and
test_dirty_text_continuation_guards.py pass; emitted code for a
dependent load pair declares the temp once inside the pair block, has
PGXP_LOAD read the temp, and keeps the writeback in scope. The six
runtime files touched by both sides compile clean against this game's
generated sources.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
append_pgxp_hooks spliced every PGXP_* call onto the tail of the
instruction's emitted code with a plain space. When that emission ends
with a preprocessor directive — the PSX_ENABLE_BLOCK_CYCLES muldiv
stall/latency block ends with a bare `#endif` — the hook lands ON the
directive line, where the preprocessor discards it as extra tokens
(-Wendif-labels warns). Net effect: in a PGXP-flavored build with block
cycles enabled, every MULT/DIV/MFHI/MFLO hook silently vanished, so
hi/lo results never propagated precision.
Join with "\n" at every hook site instead. A newline is always valid
where the space was, so unaffected emissions are byte-equivalent after
recompilation, and directive-terminated emissions now keep their hooks.
Continues backporting onto this branch rather than moving to feat/rbengine,
keeping the known-good race performance while recovering fixes that landed
after the branch point.
pc=0 cluster (the mid-race 'crash' was a silent top-level GUEST_EXIT):
- 6ead85c9 honor IRQ redirects at compiled leaders + pc0 escape journal. THE
root cause: every emitted block-leader IRQ check overwrote whatever resume
PC the exception epilogue published, so a guest RFE to a non-EPC target
(card-ISR handlers, SetCustomExitFromException, thread switch) lost its
continuation and a later jr through a null pointer surfaced as PC=0.
Its own message names this title and the static bake.
- 21a8457e the IRQ-redirect guard must CONSUME the benign same-site resume.
- (2edce1b6 and the async-RFE latch hunks landed in the previous commit.)
- cpu_state.h pc0-journal declarations, which the cherry-picks reference.
Overlay/autocompile correctness:
- d31953fe missing cull-widen ABI symbols; PGXP hooks swallowed by an #endif.
- 45f9d0f8 pin the overlay flavor through the spawn env — the same
--flavor-defaults-to-0 trap that silently fills a cache this PGXP build
cannot load.
- 8b323ffe coverage_vault 8 MB dev-RAM aware bounds.
Deliberately NOT taken: 249057f9 / ada331a4 (exact-sign NCLIP + GTE clamp
rescue) — broadens PGXP arming and adds a per-vertex RTPS push, and is the
leading suspect for the race-FPS regression this branch exists to avoid; and
3b698682 / 95a56e23 (image-edge tail-transfer), which 4532ad2f later gated to
CPS-only — this game emits non-CPS, where the legacy behaviour this branch
already has is the correct one.
Emitters rebuilt and game+BIOS regenerated (56 shards changed). Verified: 8 MB
mod plan applies, 60 Hz NTSC at 1.00x, no PC=0 exit.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UoSNLFj7kvarYHEV9PCZpV
The CFG emitter now points an LWL/LWR merge operand at the deferred
psx_ldd_<addr> temp, but full_function_emitter.cpp (BIOS / resident
functions) had the identical defect and was left untouched.
There, insn_reads_gpr already reports LWL/LWR as reading rt (it is the merge
base), so a `lw rt, ...` followed by `lwl/lwr rt, ...` was recorded as a
dependent pair and the load's writeback deferred. The successor then merged
cpu->gpr[rt] -- still the PRE-load value -- and because LWL/LWR
architecturally writes rt, the flush-vs-discard rule turned the deferred
writeback into `(void)psx_ldd_<addr>`. So the fetched word was dropped
entirely and the merge combined a stale register: silently wrong code, no
diagnostic.
This mirrors the CFG fix: record such pairs in ldd_lwlr_forward, exclude them
from the flush/discard normalisation, rewrite the successor's single
psx_old_rt merge-base initializer to read the temp, and emit a note instead
of a writeback. If strict_translator's emitted shape ever stops matching, the
emitter throws rather than quietly producing a stale merge.
Not currently live: neither generated BIOS contains a `(void)psx_ldd_` site
today, so this is latent-bug hardening. It matters because the interpreter
DOES forward here (dirty_ram_interp.c's s_ld_pend LWL/LWR exemption, added
after it wedged Tomba 2 at boot), so leaving the emitter unfixed keeps native
and interpreted execution in disagreement -- the divergence class that
produced the OpenBIOS memcard corruption.
Also adds recompiler/tests/test_lwlr_load_delay_forward.py, registered in
recompiler/CMakeLists.txt. It synthesises a PS-EXE with the dependent
lw/lwr pair and asserts the merge operand names the temp and that the
deferred load is not discarded. Verified to FAIL on master (merge operand
cpu->gpr[8], plus a discard) and pass with the fix.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The dirty-RAM interpreter reused a suffix-aware validity check when resuming generated code after a patched call site. If an entry prologue had been overwritten but its resume suffix still matched, execution could enter stale AOT code and fall through to another generated piece without redispatch.
Emit a whole-entry predicate for straight-line handoffs, retain suffix checks for explicit guest transfers, and fail closed for older generated dispatchers until regeneration.
Validated with the MGS PAL 6,875-frame route, manual codec-to-heliport gameplay, focused continuation-guard tests, and a current-upstream recompiler build.
The MIPS-I load-delay pair defers a dependent load's register writeback
past its successor by assigning into a psx_ldd_<addr> temp. The temp was
declared inline at the load statement — but with PGXP enabled that
statement has already been wrapped in its own
`{ uint32_t _pgxa = ...; <load> PGXP_LOAD(...); }` block, so the
declaration went out of scope before the writeback, which is emitted
after the successor. gcc rejected the shard ("'psx_ldd_...' undeclared"),
the whole overlay compile run exited non-zero, and after enough
consecutive failures autocompile latched degraded: nothing was promoted
to native and every freshly loaded RAM image ran interpreted for the
rest of the session.
Declare the temp at the top of the pair block that encloses both
statements, and point the PGXP load hook at the temp: with the writeback
deferred, the loaded value lives in the temp while the GPR still holds
the old value the successor must read — the hook previously recorded
that stale pre-load value. Delay semantics are unchanged, and without
PGXP the emission differs only in where the declaration sits.
Found on Ehrgeiz (SLUS-00809): a dependent lw pair in a kernel-window
overlay failed every autocompile run (19/19); load-screen code ran
interpreted at 1.5-9 fps for minutes per load. With the fix the same
captures compile clean and loads hold 60 fps.
A basic block that ends with no control flow, no successors, and no known
function at the next address (typical of a partial overlay capture whose
discovery ended mid-stream) emitted only the interrupt check and then fell
off the end of the generated C function. Under CPS the continuation-entry
prologue has already cleared cpu->pc, so the flat trampoline reads the
return as a normal guest exit and shuts down silently (exit 0, no crash
report) instead of continuing at the fall-through address.
Emit `cpu->pc = next_addr; return;` — the same contract every other CPS
block terminator follows — so the trampoline can route the continuation
through another overlay variant, static code, or the dirty-RAM interp.
Legacy (PSX_CPS=0) codegen is unchanged.
Found on Ehrgeiz (SLUS-00809): a truncated 0x3C-byte capture variant of a
0x144-byte TOP-menu function made every cold boot exit at PC=0 after ~4.6M
dispatches, while savestate resumes masked it. With the fix the same
capture store boots to gameplay.
LWL/LWR take their destination register as both the merge base and the
destination, and they read that base late enough to receive the forwarded
result of an immediately preceding load. They are therefore not subject to the
MIPS-I load delay: after
lw $t0, 12($a3)
lwr $t0, 10($a3)
the LWR merges into the word the LW just fetched, not the stale $t0.
The load-delay pair emitter defers a load's writeback into a psx_ldd_<addr>
temporary, so an LWL/LWR successor was merging into cpu->gpr[rt], which still
held the pre-load value. The merge operand now names the deferred temporary
when the successor is an LWL/LWR writing the same register, and the ordinary
writeback is suppressed there because the merge has already consumed it.
Only the consumer side needs this. LWL/LWR as the producer never defer, so
their writeback is already immediate.
Verified against a full regeneration of a title that exercises the pair: 16
forwarding sites, and every emitted merge operand identical to the reference
output built with this fix in place.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An IRQ taken exactly at a polled call's return boundary swaps sp between
the task stack and the kernel exception/scratchpad stack, so a genuine
return (ra == stop_addr) can arrive with sp != sp_at_call in either
direction. The strict same-frame gate misread that as recursion and
re-dispatched the same delivery until the depth-257 guard tripped.
Accept the return only when ra matches the stop address and exactly one
of the two sp values lies in the scratchpad exception window; recursion
(different ra) and same-stack wild arrivals still take the strict gate,
so the Tomba Bug D protection is unchanged.
Fixes mission-1 freezes in Medal of Honor, MoH Underground, and Driver 2
(depth guard trip at 257 after tens of thousands of deliveries);
validated on those three plus Jersey Devil as control and by an
independent BIOS regen chain.
Each console is a complete single-console runtime in its own process (own
core, own window, own pad); the SIO1 cable is a pair of SPSC rings in a
shared-memory segment, paced by the bounded-skew barrier the fiber dual
mode validated. In-process dual halves the frame rate by construction; the
threads-in-one-process alternative needs ~590 device statics and 66 config
setters made per-machine. Two processes get each console a core for free
and upgrade to two PCs later by swapping the transport.
Design points (psx_link_shm.h):
- LINK-TIME: processes boot at different wall times, so due-cycles run on
local-cycles-minus-epoch, epoch latched when the pair forms.
- Ops stay pure (psx_link.h determinism contract); wall clock only in the
barrier poll (peer-death detection -> unplugged-cable semantics).
- Lookahead defaults to ~one PAL frame: below that, each side's ordinary
pacer idle (clock frozen up to 8 ms/frame) stalls the peer -- measured
0.57x at a quarter frame, 0.30x at the fiber-tuned 8192. Pair speed is
min(A,B) by design; menus/races pair at 1.0x, phase-shifted intro FMV
dips compound and that is correct lockstep behaviour.
- PSX_SIO1_BACKEND=shm PSX_LINK_SHM_ROLE=a|b selects; PSX_LINK_SHM_NAME,
_LOOKAHEAD, _LATENCY tune. [runtime.link] backend="shm" also accepted.
- Savestate during link play serializes an empty wire (in-flight chars
lost; handshakes retransmit). Cross-process co-checkpointing is future
work.
Verified: two instances pair, exchange lines state, hold skew within the
bound, and run at min-of-pair speed with ~3s total barrier wait per 45s of
phase-shifted intro FMV; two UNLINKED instances hold 1.00x simultaneously
(16-core host), isolating all coupling cost to lockstep semantics.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
PS1 segments alias the same physical RAM. Most PS-X EXE headers carry KSEG
addresses, but a header may legally carry KUSEG ones instead, and the guest
then executes with a KUSEG PC. Alien Resurrection (SLUS-00633) is such a
title: its header holds load address 0x00010000 and entry PC 0x000155A4,
and get_registers on a live run reports PC=0x00010170.
The emitted dispatch table is keyed by the recompiler's KSEG-normalized
addresses, and psx_game_find_entry() compared those keys against the raw
incoming address. For a KUSEG PC the comparison can never succeed, because
0x0001xxxx is always below 0x8001xxxx: the binary search walks to the start
of the table and returns no entry. Every dispatch then falls through to the
interpreter.
The failure is silent and easy to misread. psx_game_address_in_text() does
mask, so it still reports the address as game text and the interpreter runs
the real RAM bytes via clean_game_text_miss. The game boots and renders
correctly, just entirely interpreted. Because no entry is ever found, the
text-image guard is not reached either, so its diagnostics stay at zero
(text_native_blocked=0, text_exact_mismatches=0) while dirty_ram_stats
reports over a billion interpreted instructions and native_handoffs=0 --
which reads as "nothing was rejected" rather than "nothing was looked up".
Compare 29-bit physical addresses on both sides of the search, and sort the
table by that same masked key so the binary-search invariant holds by
construction rather than by every entry happening to share a segment.
Adds tests/test_kuseg_dispatch_lookup.py: synthesizes a KUSEG-addressed
PS-EXE, runs the emitter, and asserts the lookup masks both the incoming
address and the stored key, and that the emitted table is ordered by
physical address.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Conflict resolutions:
- boot_state.h: section-tag collision at 0x11 — master shipped BS_SEC_SIO1
there (all v5 states + dual-console switch blobs), so the branch's
BS_SEC_TEXPACK/BS_SEC_MODSET renumber to 0x12/0x13. PR-branch dev states
are the only casualty.
- boot_state.c: adopt the branch's backpatched true section count (placeholder
+ write_section_raw counter), which subsumes master's constant-minus-
exclusions arithmetic; keep master's section-exclude guards on CPU/RAM
writes and the branch's MODSET-first mod-plan guard.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The guest runs as native code, so "faster CPU" cannot mean "execute quicker" --
it means CHARGE LESS for the same work. Every device schedules off
psx_cycle_count and the CRTC fires VBlank on a fixed cycle period, so scaling
the per-instruction charge down by N lets the CPU complete N times the work
inside one frame while timers, SPU, CDROM and the refresh rate keep their
real-world rates. Scaling the VBlank period instead would slow everything
EXCEPT the CPU, which is backwards.
Q16 fixed point with a carried remainder, not a divide: this is the hottest path
in the runtime and integer division of a 1-2 cycle instruction cost would floor
to zero and lose all time. The accumulator keeps the total charge exact instead
of drifting by the truncation on every call. Stock (65536) short-circuits, so a
non-overclocked build pays one predictable compare.
[video] cpu_overclock, percent, 100 = stock. Needed because hueponik's
pal100full8 requires >900%.
STATUS, measured, not assumed: 100% and 300% boot and run. 1000% does NOT reach
a display -- and it is neither hung nor host-bound, frames advance steadily at
~39/s (455 -> 2574 over 54s) while the guest never enables the display. So the
guest's own boot path stalls at that ratio; the mechanism moves time correctly
but something in the boot sequence does not survive the CPU running 10x ahead of
its devices. Not yet isolated, and it is the blocker for pal100full8.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A still image drawn behind the game, filling whatever the letterbox or
pillarbox leaves over -- the trick vertical shmups use on horizontal displays,
where the dead space carries artwork so it reads as designed rather than as a
limitation. MAME calls the same idea bezel artwork.
Drawn across the whole drawable before the game quad, which is simpler and
safer than filling two margin strips: no margin arithmetic, it covers pillarbox
and letterbox alike, and the frame paints over the centre.
The reason this is the right shape and present_edge_fill was not: edge fill
stretches one edge COLUMN of the live frame across the margin. That suits a flat
menu backdrop and turns track geometry into horizontal smears, so it needs to
know whether the current frame is a menu -- a question it answers with
force_4_3, which is only ever set by the widescreen path. A still image has no
such question. It looks the same in a race and in a menu, which is the point.
Loaded after the GL context exists (the first attempt sat next to tex_pack_init,
which runs before the window, so g_gl_active was false and it silently never
loaded). Decoded with stb_image, already linked for the HD texture pack. A
relative path resolves against the disc directory, matching the pack convention,
so artwork can sit beside the disc.
Verified on a 7680-wide maximised window with 4:3 content: margins that were
pure black now carry the image on both sides, with the frame untouched in the
middle.
Known limits: the image is stretched to the drawable, so an aspect that does not
match the window will distort (cover-crop is a small follow-up), and this has
only been exercised in 4:3 pillarbox, not with the widescreen mod active.
For WipEout 3 the team wallpapers by MotorcycleEmptiness
(awesome-wipeout.github.io) suit this exactly; the shipped default is a
placeholder gradient used to verify the path, not final art.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The header contract and config plumbing for the depth work; the implementation
landed in the preceding commits.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
comparable between objects
Two real fixes, and an honest negative result.
The per-frame depth clear was keyed on the debug server's s_frame_count, which
is incremented from two different places depending on whether PSX_DEBUG_TOOLS is
compiled in. A Release build could therefore clear the depth buffer once and
never again, letting depth accumulate until nearly all geometry is rejected --
the ground vanishing mid-race. It is now armed at Swap by this file, which is
the real frame boundary and needs nobody else's counter. This class of bug is
invisible to a savestate A/B, because that only ever compares single frames.
znear was 128, chosen by a sweep that scored "distinct depth levels used". That
metric was exactly backwards: the map clamps with max(a_z, zn), so a large zn
collapses the near field onto one plane and CONCENTRATES the remainder into more
visible levels -- the broken value scored best. At 128 the track under the camera
was swallowed and then occluded the ship entirely. Now 1, which clamps nothing.
But depth stays OFF, because that did not fix it. Measured over 12s of motion,
sampling the ship's own livery pixels: off gives 5.05/0.23/1.16/1.34/0.82/...,
on gives 2.78/0.22/0.03/0.57/0.01/... -- consistently lower and twice near zero.
With clamping gone the mapping is no longer the suspect; the recovered Z simply
is not comparable BETWEEN objects. SZ is the GTE's post-RTPS projected depth,
and two objects projected with different h or shift factors do not share a
scale, so the ship can sit "behind" track it is plainly in front of. Resolving
that means reconstructing view-space Z rather than reading SZ, which is what
PGXP proper does.
The depth readout (pgxp_depth zdebug=1) and the wire-tunable znear stay, since
they are what turned this from guesswork into measurement.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The fragmentation was the mapping, not the data. A linear map has to pick a far
value and CLAMP past it, so everything beyond collapsed onto a single plane with
no ordering left among it -- which is most of a race track, and why the ship's
fuselage was being chopped apart by geometry behind it. 1 - 2*zn/z is monotonic
over the whole range with no clamp and spends its precision near the camera
where overlaps actually happen, exactly as a hardware perspective depth buffer
does.
znear 128 from a sweep against a savestate, measured on the depth readout rather
than picked: it resolves 135 distinct depth levels where 2 and 8 resolve 10 and
16.
Verified on two independent savestates at 6x internal resolution, same-frame
A/B (savestate replay is deterministic to the pixel -- the control is a 0-pixel
diff, so any difference is the setting under test). Both render the ship intact
and correctly ordered against the track, where the previous linear mapping left
it fragmented or missing entirely.
Default ON, here and in the framework. This is the point of the exercise: the
ordering table sorts one bucket per primitive and cannot resolve intersecting
geometry at all, so a recovered per-vertex depth is a real improvement over what
the hardware could do, not a compatibility risk to be hidden behind a flag.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
[hotkeys] rewind_pad / save_state_menu_pad accept the recomp-ui pad encoding,
where 1000 + a button bitmask is a CHORD. The parse guarded on `n < 256`,
which admits single buttons and rejects every chord there is — including the
two values this same file writes back out (1272 = select+r3, 2040 =
select+r1). A saved chord was silently dropped on load and the defaults
quietly reinstated, so the pad hotkeys were never actually configurable.
The bound predates the chord encoding; the runtime's own
normalize_hotkey_pad_binding has handled combos correctly all along.
Replaced with pad_bind_value_ok(), sharing one documented bound with the
encoding it validates. Verified against a live boot with rewind_pad = 1017
(select+cross): parsed has=0 value=1272 before, has=1 value=1017 after.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The reconstruction added in the previous commit was env-only (PSX_FMV_FILTER),
which is fine for trying it out and no good for shipping it. Promote it to
[video] fmv_filter in both game.toml and settings.toml, spelled
"nearest" | "bilinear" | "sharp" | "bicubic" (default bicubic), and push the
resolved value to the renderer instead of having the renderer read getenv.
PSX_FMV_FILTER still overrides for a single run without touching the user's
settings, which is what an env knob is actually for.
Also bridges the value to recomp-ui's launcher settings in both directions so
the Display settings row round-trips: recomp-ui stores it 1-based (0 = unset,
so a host predating the field gets the default rather than Nearest), the
config enum is 0-based, and the conversion is confined to two helpers at the
boundary.
Verified end to end: with fmv_filter = "bilinear" in settings.toml the
presented pixels match the bilinear reference exactly (mean |dx| 0.930,
hard-step fraction 0.14%), where the default build produces the bicubic
numbers (1.038 / 0.34%).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two follow-ups to [[widescreen.cull.widen]], both found by testing it.
INTERPRETER PARITY. The widen site type was recompiler-only: the AOT image
evaluated the widened compare while the dirty-RAM interpreter, and any overlay
shard built without the config, still evaluated the vanilla one. The same site
would then cull differently depending on which backend happened to run it.
Adds a runtime site registry mirroring psx_ws_cull_keep_site, wires it from the
config at startup, and consults it from the interpreter's SLT and SLTI paths
before the keep lookup so a config migrated from keep to widen cannot be served
the pinned answer by a stale entry.
GL SUPERSAMPLING CEILING 4x -> 16x. SW_MAX_INTERNAL_SCALE was applied to every
backend, which is the wrong constraint for GL. It exists because the software
path allocates a VRAM-sized hi-res MIRROR costing 1 MB * scale^2 (256 MB at
16x); under GL that mirror stays at 1x (glb_set_scale) and the cost is an FBO
instead. Capping GL at 4x limited it to about 2048x960 internal on hardware
that handles far more.
GL_MAX_INTERNAL_SCALE moves to gpu_render.h (main.cpp needs it to pick the
ceiling before a context exists), main.cpp selects the ceiling per backend, and
the config range becomes 1..16. init_gpu_raster now also clamps to the driver's
real GL_MAX_TEXTURE_SIZE / GL_MAX_RENDERBUFFER_SIZE and says so: previously an
over-large scale reached glTexImage2D, failed silently, and surfaced only as
"GL FBO incomplete" with no hint that the scale caused it.
Measured on WipEout 3 SE (PAL, NVIDIA, Release build + debug tools), sustained
present rate in-race, same track:
scale internal fps
1 508x256 50.0
6 3048x1536 49.8
9 4572x2304 50.2
16 8128x4096 50.0
Flat across the range - the title is guest-bound, so 8K internal is free on this
hardware. 50 fps is full speed for a PAL disc.
[[widescreen.cull.keep]] forces a comparison's verdict while wide. That is
correct for a separately proven binary decision, and config_schema.md already
says as much -- "keep has no queue policy and is appropriate only for a
separately proven binary verdict", and prefer an aspect cone "when the original
predicate is a camera-frustum test".
At a clip-code packer it is actively wrong. Pinning the classifier tells the
clipper that nothing crosses the screen edge, so polygons that do are never
subdivided; they are submitted whole with coordinates outside the GPU's legal
primitive range and the hardware discards the entire primitive. The visible
result is geometry disappearing as the camera moves -- widening the view draws
LESS, not more.
Measured on a WipEout 3 game repo, same track and camera path: ~323 primitives
emitted with the pinned sites active against ~761 with the margin at 0. Live
A/B via the ws_margin debug command also showed inverted ship steering and
broken AI with the pins active, both of which cleared at margin 0 while the
16:9 image was retained.
This adds the widening alternative:
[[widescreen.cull.widen]]
address = "0x8017032C"
expected = "0x28850000"
mode = "imm_lower"
Modes name which operand carries the bound and which way it travels:
imm_upper SLTI/SLTIU coord < imm + m
imm_lower SLTI/SLTIU coord < imm - m
bound_rt SLT/SLTU rs < rt + m
bound_rs SLT/SLTU rs + m < rt
Every mode reduces to the vanilla comparison at margin 0, so 4:3 output stays
bit-for-bit identical -- the same guarantee keep sites give. The immediate modes
reuse the existing psx_ws_cull_slti / psx_ws_cull_slti_lower helpers; the
register-bound modes need psx_ws_cull_slt_widen, added here.
The parser rejects a mode that does not match the instruction shape (an
immediate mode on a register compare would widen an operand that does not
exist), rejects duplicate addresses, and full-word guards the site as keep does
so an overlay variant at the same VA is left alone. Widen sites contribute to
overlay cache identity, otherwise migrating a site from keep to widen would
silently reuse the pinned overlay.
No game config uses this yet; migrating a title's sites is a separate change
that needs each site's operand roles established first.
Interactive isolation on Ape Escape (in-game, live TCP toggles): OFF =
baseline jiggle; geometry ON unclamped = sparse hairline background-bleed
seams along corrected/uncorrected shared edges (both user-reported
artifacts vanished with the toggle off - the THPS2 line class, not wild
vertices); ON with tolerance 0.5 = seams gone, only sub-half-pixel
misalignment remains. Ship 0.5 as the default; negative still disables.
Also documents the screenshot_hires scale-2 windowed readback defect
(tiled/black PNG - row pitch) found during the session.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>