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>
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>
[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.
Load acceleration moved to the Mods catalog in 125d84d
(psx.enhancement.fast-loading "Fast Loading (host pacing)" and
psx.enhancement.cd-speed, both `game_id = "*"` so they ship with every
title, both default-off), and recomp-ui deliberately stopped drawing a
generic Turbo loads row. But the legacy switch was left live here with
`offer_turbo_loads` defaulting TRUE, so any title that had not explicitly
migrated still got turbo forced on — with no UI to turn it back off.
That shipped to players. MegaManX6Recomp v1.0.4 and v1.0.5 both carry
`[runtime] turbo_loads = true` in the game.toml inside the release zip
(verified by extracting the artifacts). Two paths forced it on:
* game.toml [runtime] turbo_loads set g_turbo_loads_enabled directly;
* a stale [video] turbo_loads in settings.toml was restored, and the
seed write persisted it — so one run latched the value permanently
and no later game.toml change could undo it.
Reported as MegaManX6Recomp#14: the WARNING screen self-advances and load
spinners run fast, with Turbo confirmed off in Mods. Tomba 2 hit the same
defect (fixed per-title), and MMX5 is also unmigrated. Third occurrence of
one config default, so fix the class instead of the titles.
Both keys are still PARSED, so existing configs load without error, but
neither is honoured:
* offer_turbo_loads defaults false and turbo_loads_offered becomes a
constexpr false, matching how ws_offered / skip_fmv_offered /
frame_interpolation_offered already model mod-owned features. No title
can offer the generic switch, so forgetting to migrate is no longer
punished.
* game.toml turbo_loads logs a deprecation line naming the Fast Loading
mod and enables nothing.
* settings.toml [video] turbo_loads is neither restored nor written back
out, so the stale row is dropped on the first save — same write-only
latch reasoning already documented for fast_boot, which bit harder here
because there was no UI control at all.
Also fixes a related hole found while auditing: the in-game Settings apply
path wrote both g_turbo_loads_enabled and g_auto_skip_fmv from a launcher
snapshot taken BEFORE mod_runtime_activate_plugins() ran, so the first
in-game Apply silently turned a player's enabled Fast Loading or Tweaks FMV
feature back off. Both are now gated on their offered flags.
The supported surfaces are unchanged: the Fast Loading / CD Speed mod
plugins still enable acceleration (verified: "mod selected 8x load
acceleration"), and the `turbo_loads` TCP debug command still toggles it
for development.
Verified against MMX6 built on this framework: with game.toml
`turbo_loads = true` AND `offer_turbo_loads = true` AND a stale
settings.toml `turbo_loads = true`, both deprecation lines fire and the
debug server reports enabled=0, turbo_frames=0. With the keys removed and
no mods enabled, it boots silently with enabled=0.
test_mod_load_acceleration.py is updated to pin the new contract; it also
had a stale assertion on the old 2..16 multiplier range, which is now
pinned to the symbolic PSX_MOD_LOAD_ACCEL_MAX bound. The guard-test suite
goes from 9 pre-existing failures to 8 (no new failures; this one fixed).
Refs: beads-eio.3.26, MegaManX6Recomp#14
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Hybrid kept reappearing in titles that never meant to ship it, because it was
the ZERO value of PadMode and therefore the default everywhere: PlayerInput,
p1_mode/p2_mode, ctrl_locked_*, and default_p1/p2_mode all initialised to it.
A game then had to opt OUT with [controller] allow_hybrid = false, and every
shipping title did exactly that or pinned a mode - so no title actually ran it.
Hybrid now has exactly one route: a trusted game plugin calling
psx_mod_set_controller_mode_override(), which is how Tomba's hybrid controller
mod already requests it. Everything that made it reachable by accident is gone:
- defaults are ANALOG (PlayerInput, p1/p2, ctrl_locked_*, default_p1/p2)
- [controller] allow_hybrid is removed, along with the startup clamp that
existed only to undo the bad default
- game.toml default_mode/p1_mode/p2_mode = hybrid now THROWS, naming the
mod API as the only route, rather than being silently coerced
- a stale settings.toml hybrid migrates to analog via
pad_mode_from_settings_string(); a user should not fail to launch over a
value they never typed
- pad_mode_to_string() never writes hybrid back
Deliberately KEPT, because the mod runs on it: PAD_MODE_HYBRID,
PSX_MOD_CONTROLLER_HYBRID, psx_mod_set_controller_mode_override(),
hybrid_stick_active/hybrid_dpad_active, PlayerInput::hybrid_analog and
psx_keybinds_dpad_active.
Paired with the recomp-ui change that drops the Hybrid segment and the
allow_hybrid field from RecompLauncherCGameInfo - the two must land together.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Makes the parked projection/perspective work reachable and gets it into the
renderers people actually use. Addresses issue #92 (PGXP-style geometry and
texture correction).
The GTE side cache, SWC2 RAM provenance, GP0 triangle preparation and the
software-renderer consumption path already landed in 2ceaf5a ("park projection
and perspective correction", from kem0x PR #14), but were unreachable: the
generic setters gte_geometry_correction_set() / gpu_texture_correction_set()
had no production caller, and the corrected vertices went straight to
sw_set_precise_triangle(), bypassing the renderer vtable — while the default
renderer is OpenGL.
This adds:
- [video] geometry_correction and [video] perspective_texturing, in game.toml
and settings.toml, both defaulting false. Independent knobs; the launcher
seed marks them present so a save round-trips a hand-edited key instead of
dropping it. Startup names the active pair, and notes when geometry
correction is on below supersampling 2 (where it cannot be seen).
- gr_set_precise_triangle() / gr_set_perspective_triangle() on the
GpuRenderBackend vtable, so gpu.c hands the correction to whichever backend
is live rather than to the software rasterizer directly. NULL entries no-op.
- OpenGL and Vulkan consumption. Positions are already float end-to-end, so
sub-pixel vertices drop in; the readback dirty rect widens by one pixel to
cover them. Perspective UVs use a per-vertex a_q weight and clip coordinates
pre-multiplied by w = 1/q, letting the hardware's own perspective divide
interpolate a smooth UV varying beside the affine noperspective one. a_q == 0
— the default, and what every prim gets with the feature off — makes w
exactly 1.0 and selects the affine varying, so the faithful path is the
pre-feature pipeline unchanged.
The PS1-visible GTE SXY FIFO stays integer either way: guest-side screen-bounds
culling and SXY readback are untouched, and this remains visual-only.
Also fixes a pre-existing concurrent-build race found while validating: every
runtime target runs embed_spirv.py over the same shaders, and each wrote
"<shader>.spv" next to the source, so under -j N one process deleted the file
another had just written (FileNotFoundError on a shader that compiled fine).
The intermediates now go to a per-invocation temp dir.
Parked-work provenance retained; see ENHANCEMENTS.md G1.
Co-authored-by: Kareem Olim <kareemolim@gmail.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
std::filesystem answers by the *host* path grammar, so this guard meant
different things on different hosts.
Under POSIX, "C:/outside" is a relative path whose first component is
literally "C:", a backslash is an ordinary filename character, and
"\\server\share" is a single relative component -- so Windows-authored
absolute paths and backslash ".." traversals were accepted. Under Windows,
a rooted "/outside" has no root name, so operator/ drops the project root's
relative path and the result lands at the drive root; and a drive-relative
"C:foo" is not absolute at all, yet replaces the whole path when the project
sits on another drive.
Scan the raw config bytes under both separator conventions instead, and
reject an embedded NUL. TOML forbids a raw NUL byte but allows the \u0000
escape that produces one, and the runtime hands this value on as a C string
(runtime/src/main.cpp:5236-5241 -> overlay_capture.c:230), so
"..\u0000keep" validated as a four-byte component and then truncated to ".."
at the point of use, escaping the project. Validating what the consumer
actually sees closes that gap.
recompiler/src/config_loader.h carries the contract next to the field and
still described the old, narrower rule; docs/AOT_OVERLAY_PLAN.md carries the
only prose statement of it. Both now match what is enforced. Note the doc
claims identical accept/reject across hosts, not identical meaning: a value
like "nested\dir" is accepted everywhere but still names different things.
The registered recompiler_patch_test assertion "parser rejects absolute
capture history directory" failed on POSIX before this; the suite is now
33/33 on macOS arm64.
`bios_hle` (default on) is the player-facing "skip the BIOS and go
straight to the game" flag, and it did nothing under the bundled
OpenBIOS: choosing OpenBIOS sat the player in the boot animation while
the identical build on retail SCPH-1001 went straight to the game.
Root cause was ordering, not policy. The tier has two axes with
different per-image requirements:
* boot-skip needs only `shell_entry_phys`, works under pure LLE
(nothing is synthesized — the shell call just returns
immediately), so any image exporting it can skip
* kernel-call needs `deliver_event_ret`, the kernel's own
DeliverEvent `$ra`, or a delivered callback returns to
the wrong PC
main.cpp correctly refused the call axis on an image without
`deliver_event_ret` by assigning `bios_hle = false` — and then derived
`boot_skip` from that already-mutated flag. OpenBIOS omits
`deliver_event_ret` (B0 semantics unvalidated) but DOES export
`shell_entry_phys`, so an available skip was cancelled by an unrelated
refusal, and one flag meant two things depending on a BIOS detail no
player can see.
Both axes are now decided by one pure, dependency-free function,
`psx_bios_hle_plan()` (runtime/src/bios_hle_plan.c): the boot decision
reads the REQUESTED `bios_hle`, each axis is gated on only the anchor it
needs, and refusals are reported at startup instead of downgrading
silently. `psx_bios_hle_configure()` clamps `boot_skip` to
`shell_entry_phys != 0` as a backstop so the banner and `hle_dump`
cannot overstate what can fire. The main.cpp local is renamed
`bios_hle_requested` and is no longer written back from the plan — the
`session_reboot` path re-enters that block, so folding a refusal into
the request would have made a netplay rematch differ from first launch.
Also in this pass:
* A BIOS profile's `[runtime]` block was parsed into
`BiosConfig::runtime` and read by NOTHING, and bios/OpenBIOS.toml
carried an inert `bios_hle = false` there that read as the reason HLE
was off on OpenBIOS. It was not. `load_bios_config` now REJECTS
`[runtime]` in a BIOS profile: a profile states facts about an image,
never preferences.
* `psx_icache_fastpath_test` had been unbuildable ("redefinition of
`psx_advance_cycles`" — the test's counting stub vs the header's
`static inline`), which blocked every runtime test registered after
it. It now builds with `PSX_OVERLAY_DLL_BUILD=1`, the seam
psx_cycles.h already provides for a TU supplying its own advance.
Tests: new `bios_hle_plan_test` pins the matrix, including a 64-case
sweep asserting the boot decision is independent of
`deliver_event_ret` and the call decision independent of
`shell_entry_phys`. Runtime ctest 15/15, recompiler ctest 30/30.
Validated on Ape Escape (SCUS-94423) built against this framework with
both backends linked, headless, all four cases:
OpenBIOS default bios_boot=skip to game; route=2 fire at vec 0x30000
ra=0xBFC06EA8 (the continuation OpenBIOS.toml
documents); in-game on screen
retail default bios_boot=skip to game; route=2 at vec 0x30000
ra=0xBFC0702C; in-game on screen; differs only in
bios_backend=HLE (LLE fallback)
PSX_BIOS_HLE=0 bios_boot=real intro; ring empty (no hook)
KEEP_INTRO=1 bios_boot=real intro; ring empty
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>