Files
psxrecomp/docs/config_schema.md

1386 lines
71 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# PSXRecomp v4 — config TOML schema
Consumed by:
- `psxrecomp-bios.exe` and `psxrecomp-game.exe` (via `--config <toml>`)
- `tools/audit_config.py` and the audit tooling that wraps it
- The runtime cmake macro (`runtime/runtime.cmake`)
Examples:
- `bios/SCPH1001.toml` — BIOS-only config (no game; psx-runtime targets this)
- `../TombaRecomp/game.toml` — game config (tomba-runtime targets this)
## How configs combine
A PSXRecomp v4 process **always** has a BIOS config. It optionally also has
a game config. Both are TOML files in this schema:
- `bios/SCPH1001.toml` (or another BIOS .toml in `bios/`) — describes the
BIOS. Always loaded.
- `<game>/game.toml` — describes a single game. Loaded ONLY when running
that game.
```
psxrecomp # BIOS-only regen (uses default bios.toml)
psxrecomp games/tomba/game.toml # BIOS + game regen
psx-runtime # boots BIOS discless
psx-runtime games/tomba/game.toml # boots BIOS, then loads game
```
How the two configs relate:
- **Scalar keys (`debug_port`, `window_title`, `memcard_dir`, ...)**: these come
from `game.toml` only. There is **no BIOS→game inheritance.** An earlier
version of this document described a shallow override where the game won and
otherwise inherited from `bios.toml`; that merge was never implemented.
`load_bios_config` (`recompiler/src/config_loader.cpp`) is called only from the
recompiler front-ends — `main_bios.cpp`, and `main_psx.cpp` purely to build the
`BiosAddressModel` — and never from `runtime/src/main.cpp`. Setting a
`[runtime]` scalar in a BIOS toml has no effect on a game run.
Runtime precedence is: environment > CLI > `settings.toml` > `game.toml` >
compiled-in default.
- **`[program]` (BIOS) and `[game]` blocks**: NOT merged — they describe
different programs. Both are visible to the loader.
- **Generated dispatch tables and C output**: ADDITIVE. BIOS contributes
`SCPH1001_*.c`; game contributes `<exe>_*.c`. No address overlap is
expected (BIOS lives at RAM 0x500-0x8500 + ROM 0xBFC..., game at
0x80010000+). The cmake macro `psxrecomp_v4_add_runtime_target` already
links both.
- **`[[audit.regions]]` and `[[audit.normalize.remap]]`**: additive — game
adds its regions on top of BIOS's.
## Top-level blocks
```toml
[program] # in bios.toml; describes the BIOS
[game] # in game.toml; describes the game
[prepare_disc] # optional; data-track digests for prepare/verify
[netplay] # optional; TOC / cue policy for online
[recompiler]
[draw_distance] # optional; opt-in far-geometry clamps (below)
[runtime]
[timing] # optional; title timing constants (below)
[audit]
```
`[prepare_disc]` digests identify the main track only. See
[disc companions](DISC_COMPANIONS.md) for the separate SBI input gate,
exact revision coverage, and preparation receipts. No SBI configuration key
is required.
## Netplay disc mount (`[netplay]`)
Optional. Online play needs the same CD geometry on every peer — data-track
CRC/SHA alone cannot distinguish a Track-01-only dump from a full Redump
multi-track cue. The runtime mounts the resolved path, fingerprints the TOC
(`disc_fp`), and gates Host/Join on `netplay_ok`. Peers also exchange
`disc_fp` through the lobby (`disc_mismatch` on join).
| Field | Default | Description |
|---|---|---|
| `require_cue` | `false` | Require a `.cue` mount (reject bare `.bin` / cue→bin fallback) |
| `required_tracks` | `0` | Exact `iso_track_count` when > 0 (e.g. MotK Redump = `17`) |
| `required_leadout_lba` | unset | Exact lead-out LBA when set |
| `required_disc_fp` | `""` | Exact lowercase hex SHA-256 TOC fingerprint when non-empty |
| `required_disc_fps` | unset | Multi-disc: array parallel to `[game] discs`, the TOC fingerprint of each disc. Each disc of a set has its own TOC, so a set gated only on `required_disc_fp` (the boot disc's) refuses online play on every other disc the launcher lets the player select. A disc with no entry falls back to `required_disc_fp`. |
The whole `[netplay]` policy is resolved **per mounted disc**, not once per
build — `required_tracks` and `required_leadout_lba` are per-disc facts too (a
set may mix a CD-DA disc with a data-only one, and the lead-out LBA is the
disc's size). Only `required_disc_fps` carries per-disc data today; the
resolution point is `netplay_expect_for_disc()` in `runtime/src/main.cpp`, and
that is where the others go when a title needs them.
Offline Play may still launch with a TOC warning; first-run setup Finish and
online Create/Join require `netplay_ok` (and online also a clean verify +
non-empty `disc_fp`). Mirror `required_tracks` in the Retro catalog as
`rom_identity.track_counts` so the hub library scan rejects Track-01-only dumps.
Wizard / Retro / catalog submission accept Redump `.cue` + sibling `.bin`
tracks, or a MAME-compatible `.chd` of the same dump: `tools/psx_chd.py` reads
it through the libchdr the emitters build (CMake target `chdr`) and writes the
Redump-shaped track files back, so `[prepare_disc]` digests, SBI companions and
`required_tracks` apply unchanged. A bare `.iso` cannot expand to multi-track.
## Program / game block
- **bios.toml** has a `[program]` block describing the BIOS ROM.
- **game.toml** has a `[game]` block describing the game EXE / disc.
These are NOT alternatives; they're complementary. A runtime loading both
sees both blocks. The legacy single-file audit loader
(`tools/audit_config.py`) accepts either as the program-info source for
backwards compat, but going forward they are the canonical names for their
respective files.
### Fields
| Field | Required for | Description |
|---|---|---|
| `name` | both | display name, e.g. `"SCPH1001 BIOS"` |
| `id` | both | canonical id, e.g. `"SCPH-1001"` or `"SCUS-94236"` |
| `rom` | bios | path to raw flat binary, relative to project root |
| `exe` | game | path to PS-X EXE file, relative to project root |
| `load_address` | both | hex string, virtual address of first byte (`"0xBFC00000"` BIOS, `"0x80010000"` typical game) |
| `entry_pc` | both | hex string, first PC to execute |
| `text_size` | both | hex string, size in bytes of the static region. For games this also bounds main-EXE analysis and establishes the overlay floor. A smaller-than-header bound must be verified non-code and 4 KiB aligned. |
| `stack_base` | game | hex string, initial `$sp` value for the game |
| `disc` | game (single-disc) | path to .cue, relative to project root |
| `discs` | game (multi-disc) | array of .cue paths; `disc` is sugar for `discs = [disc]` |
| `disc_serials` | game (multi-disc, optional) | array parallel to `discs`: the serial each disc carries (`["SCUS-94163", "SCUS-94164", "SCUS-94165"]`). Without it every disc is checked against `[game] id` — the BOOT disc's serial — so selecting disc 2 reports "wrong disc". A disc with no entry here is not serial-gated; the ISO-header check still applies. |
### Multi-disc selection
Relative paths in executable-side `settings.toml` (`[disc] path`, `[bios] path`,
`[memcard] dir`, `card1`, `card2`) and in `disc.cfg` / `bios.cfg` resolve from the
executable directory, including when launched from another working directory.
Absolute paths and UNC paths keep their original location. A relative `--disc`
command-line argument still resolves from the caller's working directory.
A build whose `discs` array has more than one entry grows a **Disc Selection**
dropdown in the launcher, above the Serial/Region/ISO-header checklist. The
choice is persisted in `settings.toml`:
```toml
[disc]
path = "/abs/path/Game (Disc 2).bin" # the image actually mounted
selected = 2 # 1-based index into [game] discs
```
`selected` names the disc and `path` only survives when it *is* that disc
(same file-name stem), so writing `selected` alone — from an external launcher
or by hand — switches discs even though `path` still points at the previous
one. That is what makes disc choice manageable like any other setting. Both
keys are written only for multi-disc titles.
## Recompiler block
```toml
[recompiler]
seeds = "recompiler/seeds/phase2_ghidra_seeds.json" # BIOS
seeds = "seeds/ghidra_funcs.txt" # game (note: game seeds aren't json today)
bios_thunks = "seeds/tomba_bios_thunks.txt" # game-only
bios_config = "psxrecomp/bios/SCPH1001.toml" # game-only: BIOS profile whose address
# model game codegen folds RAM aliases
# through (see "BIOS profiles" below);
# defaults to the SCPH1001 profile
out_dir = "generated" # both
strict = true # both — currently always true
discovery = "whole-image" # game-only: "whole-image" or "reachable"
out_stem = "SCPH1001" # optional; overrides the auto-derived stem
[[recompiler.patch]]
id = "descriptive-policy-name"
address = "0x80012340"
expected = "0x24020002"
replacement = "0x24020001"
note = "Why this game-owned instruction change is required" # optional
```
Output filenames: `<out_dir>/<out_stem>_full.c` and
`<out_dir>/<out_stem>_dispatch.c`. If `out_stem` is omitted, it's derived
from the `rom`/`exe` file basename with the trailing `.BIN` or `.EXE`
stripped (`Path.stem` is NOT used because it mishandles `SCUS_942.36`).
Game `discovery` defaults to `"whole-image"`, preserving the existing sweep and
pointer-table heuristics. Opt-in `"reachable"` starts at the executable entry
and evidence-backed seed roots, then follows callable direct `jal` targets. It
does not sweep arbitrary bytes for prologues or return-shaped words. Unresolved
`jalr`/indirect targets and unseen callbacks fail closed to runtime
interpretation; add an evidence-backed seed (or a `dispatch_root` seed for a
proven nonstandard boundary) when they should be compiled.
When `game.text_size` is smaller than the PS-X EXE header size, recompilation
uses it as a static-analysis bound. The value must be nonzero, instruction- and
4 KiB-aligned, retain `entry_pc`, and not extend past PS1 RAM. The original EXE
is still loaded from the user's disc. A generated config's canonical final-page
reservation may be slightly larger than the header; it does not widen analysis.
Each `[[recompiler.patch]]` replaces one exact 32-bit MIPS word before function
discovery, control-flow analysis, and normal translation. It is intended for
small, understood game-code changes whose addresses, opcodes, and policy remain
in the game repository. The framework does not contain title IDs or
title-specific addresses.
- `id`, `address`, `expected`, and `replacement` are required hex/string
fields; `note` is optional.
- IDs are case-sensitive and unique within one config.
- Addresses must be four-byte aligned and are unique by the PSX 29-bit physical
address. Thus `0x00012340`, `0x80012340`, and `0xA0012340` are aliases of one
site and cannot define separate patches.
- Main-EXE generation fails if the word at the target site is not `expected`.
This catches a wrong disc revision or stale patch instead of guessing.
- Captured overlays may place unrelated variants at one virtual address. In
overlay mode, a patch is applied only to a variant whose word is `expected`;
a nonmatching variant is translated unchanged.
- When `--config` and `--ws-config` supply the same byte-identical patch it is
deduplicated. Reusing an ID or physical address for different patch data is
an error.
Patches are build-time inputs, not runtime memory writes or live toggles.
Regenerate the affected main executable or captured overlays after changing
them.
### Guarded packed-coordinate rejection
Some polygon funnels combine packed SXY flags and reject on `BNE flags,zero`,
or read the GTE error summary with `BLTZ flags`.
The native-wide GPU can clip horizontal overflow while the game retains its
vertical rejection bits. Declare only verified render rejection branches:
```toml
[[widescreen.cull.masked_reject]]
address = "0x80021ECC"
expected = "0x15E0FFC7"
reject_mask = "0xFFFF0000"
```
The full instruction guards each site on static, cached-overlay and interpreter
paths. At native 4:3, menus and FMV the original predicate is unchanged. During
wide gameplay only the specified flag bits participate in the predicate; guest
registers, the branch delay slot, vertical/depth/backface tests and timing remain
intact. Adapters must provide sufficient bounded primitive/capture capacity.
Changing a site, guard or mask changes the overlay configuration fingerprint.
For a verified GTE `BLTZ` consumer, mask the underlying error bits rather than
bit 31 itself. For example, `0x7F87A000` preserves the hardware error constituents
except horizontal SX saturation. Positive non-summary flags retain their original
acceptance at 4:3. Link branches and other REGIMM forms are not supported.
### Guarded widescreen participation comparisons
`[[widescreen.cull.packed_x]]` handles a proven mesh screen-X predicate
expressed as `SLTU rd, (SX << 16), (width << 16)`:
```toml
[[widescreen.cull.packed_x]]
address = "0x801466A0"
expected = "0x0079C02B"
```
The complete instruction guards native overlays and the interpreter. While
wide, X must lie in `[-margin, width + margin)`; both sides widen together.
At 4:3 the original unsigned comparison is exact. Nonzero low-half operands,
zero width, and widths above 1024 retain the original comparison. It changes
only the predicate, preserving GPR operands, vertical, backface, depth and
ordering-table limits. Sites affect the overlay cache identity; regenerate
main/native overlays after changing them. Empty lists are inert.
Games may disable a proven object/model cull verdict in widened world views
without changing true 4:3 behavior:
```toml
[[widescreen.cull.keep]]
address = "0x8002B310"
expected = "0x28A21C01"
result = 1
```
- `expected` must encode `SLT`, `SLTU`, `SLTI`, or `SLTIU`.
- `result` must be 0 or 1.
- The site identity is the normalized physical address plus the complete
32-bit instruction. A nonmatching overlay variant at the same VA is left
unchanged.
- At true 4:3 the original comparison is evaluated. The configured result is
forced only when `psx_ws_x_margin() > 0`.
- Native generated code and the dirty-RAM interpreter implement the same
semantics.
- Regenerate main/overlay native code after changing the list.
### Packed-SXY all-vertices rejects
```toml
[[widescreen.cull.sxy]]
kind = "quad"
vertex_regs = [8, 20, 11, 9]
fold_addresses = ["0x8001A9B4", "0x8001A9B8", "0x8001A9BC", "0x8001A9C0"]
fold_expected = ["0x01110824", "0x02810824", "0x01610824", "0x01210824"]
consumer_address = "0x8001A9C4"
consumer_expected = "0x14200049"
```
- `kind` is `"tri"` (3 vertices) or `"quad"` (4); `vertex_regs`,
`fold_addresses` and `fold_expected` have one entry per vertex.
- Every `fold_expected` is `and r, ...` into the same `r != $zero`; the first
reads `vertex_regs[0]` and a separate mask register, each later one reads
its vertex and `r`. `consumer_expected` is `beq`/`bne r, zero`.
- Addresses are word-aligned, in one segment, strictly increasing, the fold
within 16 instructions and the consumer within 4 after it; no address is
shared with another sxy site. At most 64 sites.
- Identity at 4:3; while widened only the X-sign bit of each AND changes. See
`docs/WIDESCREEN.md`.
Prefer an aspect-derived cone over `keep` when the original predicate is a
camera-frustum test. `keep` has no queue policy and is appropriate only for a
separately proven binary verdict.
### Aspect-aware terrain and model participation
Exact terrain-frustum angle loads can follow the live horizontal field:
```toml
[[widescreen.cull.angle]]
address = "0x8013F138"
expected = "0x24020155"
```
`expected` must be `ADDI`/`ADDIU rt,zero,imm`, with a positive 12-bit angular
half-extent below one quarter-turn. The helper widens `tan(angle)` by the live
per-side horizontal extent. It is exact at 4:3 and full-word guarded against
same-address overlay variants.
A model-list or per-child cosine rejection can use a horizontal-only envelope:
```toml
[widescreen.cull.aspect_cone]
forward_addr = "0x1F8000E8" # three signed Q12 halfwords
forward_offsets = [0, 4, 2] # byte offsets for X/Z/Y; this title stores X/Y/Z
object_type_offset = 12
object_reg = 19
x_reg = 16
z_reg = 18
y_reg = 17
hysteresis_pixels = 24
queue_reserve = 4
queue_count_addrs = ["0x1F800144", "0x1F800150", "0x1F80015C"]
queue_capacities = [24, 40, 28]
queue_type_masks = ["0x00000204", "0x00000010", "0x00000020"]
[[widescreen.cull.aspect_cone.sites]]
address = "0x80077368"
expected = "0x28620358" # signed SLTI reject predicate
[[widescreen.cull.aspect_cone.sites]]
address = "0x8002B368"
expected = "0x0082202A" # signed SLT reject predicate
cosine_threshold = 856 # required for SLT; Q10
object_reg = 20 # optional per-site register overrides
x_reg = 19
z_reg = 17
y_reg = 18
queue_guard = false # this lower-level predicate appends to no fixed queue
```
- Sites must be signed `SLTI` or `SLT` reject predicates: zero is the keep
path and one is rejection.
- An `SLTI` site derives its Q10 cosine threshold from the immediate unless
`cosine_threshold` is given. An `SLT` site requires it explicitly.
- A vanilla keep is always preserved. Only a vanilla rejection is retested.
- `x_reg`/`z_reg` describe the world ground plane; `y_reg` is world vertical.
`forward_offsets` maps the corresponding X/Z/Y forward components from the
three halfwords at `forward_addr`. It must be a permutation of `[0, 2, 4]`.
Omitting it preserves the legacy `[0, 2, 4]` order. Match both register and
forward mappings to the actual camera basis; the title's coordinate names
alone are not sufficient evidence.
- Horizontal reach follows the current client aspect. Vertical reach,
near/far checks, type dispatch, and the game’s queue-capacity branches are
unchanged.
- `guard_pixels` is the activation guard outside the visible field.
`hysteresis_pixels` moves deactivation farther out.
- For `queue_guard = true`, non-visible guard/hysteresis candidates are
rejected at `capacity - queue_reserve`, preserving headroom for candidates
intersecting the visible wide field.
- Use `queue_guard = false` only after proving that the exact predicate does
not append to those queues.
- Site address, instruction, threshold, registers, queue policy, guard size,
and enclosing cone/queue metadata all contribute to overlay cache identity.
- Generated/native overlay code and the dirty-RAM interpreter use the same
live helper. Dynamic resizing therefore needs no recompilation.
The debug server’s `ws_aspect_cone_site` command accepts an `address` string
and reports exact-site identity/keep/reject counters.
Signed horizontal bounds can be widened at the constant-load site:
```toml
[[widescreen.signed_x_bound]]
address = "0x800BD290"
expected = "0x2402FF00" # addiu v0,zero,-256
```
- `LUI rt,imm` sites are signed Q16 gameplay bounds and use the gameplay-field
scaler.
- `ADDIU rt,zero,imm` and `ORI rt,zero,imm` sites are screen-pixel bounds and
move by the live horizontal margin. ADDIU sign-extends the constant; ORI
zero-extends it. The destination and immediate must both be nonzero.
- Site identity is the normalized physical address plus the complete
instruction word. The helper is identity at 4:3.
Signed per-vertex screen-X culls can be listed site by site
(`slti_sites`, `slti_lower_sites`, `bltz_sites`, `bgez_sites`,
`clip_edge_x_load_sites` with `clip_edge_width`, `branch_keep_sites`). All are
identity at 4:3, empty by default, and need a regen; `docs/WIDESCREEN.md`
("Explicit screen-X cull sites") has the semantics of each kind:
```toml
[widescreen.cull]
bgez_sites = ["0x80013F40"] # bgez SX,keep: keep while SX >= -margin
bltz_sites = ["0x80013F58"] # bltz SX,reject: reject while SX < -margin
clip_edge_x_load_sites = ["0x8005F5F4"] # lh of a clip bound: 0 -> -m, W -> W+m
clip_edge_width = 320 # 1..1024; default screen_w_imms[0]
```
- Main-EXE generation fails (exit 1) when a listed address does not hold the
expected instruction: `slti` for both slti kinds, exactly `bltz` / `bgez`,
`lh`/`lhu`/`lw` to a nonzero register for clip-edge loads, and a
conditional branch (`beq`, `bne`, `blez`, `bgtz` or REGIMM) for
`branch_keep_sites`.
- `bgez_sites` and `clip_edge_x_load_sites` (with the width) contribute to the
overlay-cache identity only when non-empty.
Explicit `bias_sites` / `bias_lower_sites` / `range_sites` may opt into an additional resident
object lead without widening terrain or render queues:
```toml
[widescreen.cull]
guard_pixels = 16
activation_guard_pixels = 256
bias_sites = ["0x80069BA8"]
range_sites = ["0x80069BB0"]
```
`activation_guard_pixels` is added only to the live margin emitted at those
three explicit site families, and only while widescreen reveals extra world.
At true 4:3 it is exactly zero. `guard_pixels` remains the shared
render/terrain participation guard; keep it small when terrain producers or
model queues have fixed capacity. Both values are restricted to `[0, 256]`
and contribute to native-overlay cache identity. Changing the activation
guard requires regenerating the game and overlay code.
`bias_lower_sites` is the lower-endpoint counterpart to `bias_sites`:
an `ADDI`/`ADDIU` camera-relative bound subtracts the activation margin from
its original immediate. Both native code and the dirty-RAM path apply it.
For strip-based enemy spawning, expand the outer strip edge, initial and
vertical scan X bounds, and any associated respawn-reset interval together.
Keep authored placement flags and vertical bounds intact. Empty is inert;
configured sites require regeneration.
## Draw-distance clamps (`[[draw_distance.clamp]]`)
Many PS1 renderers drop a primitive whose depth index falls past the end of
their ordering table (`sltiu t, z, N; beqz t, reject`). The limit protects the
OT, so raising it can write past the table. A title can list such guards and
let a trusted mod keep the far primitive in the farthest slot instead:
```toml
[[draw_distance.clamp]]
address = "0x80061230"
expected = "0x2C4A01C0" # sltiu t2, v0, 0x1C0
reg = 2 # v0: the depth index the guard tests
max = 0x1BF # last safe index
[[draw_distance.clamp]]
address = "0x80066FF8"
expected = "0x2441FFFF" # addiu at, v0, -1 (then sltiu at, at, 0x1BF)
reg = 2
max = 0x1BF
```
- While a mod has called `psx_mod_set_draw_distance_clamp(1)`, `reg` is
clamped to `max` (signed: `if ((int32_t)reg > max) reg = max`) immediately
before the instruction at `address` runs. The original guard then keeps
the primitive and the code that follows indexes the table with the clamped
value. A negative (wrapped) value is left alone, so a guard that also
rejects too-near primitives keeps doing so.
- Off, the default and the state at every session start, the sites run the
original code. Netplay never activates a mod, so it stays vanilla.
- `expected` is the complete instruction word and must be an ALU
instruction (`ADDI`/`ADDIU`/`SLTI`/`SLTIU`/`ANDI`/`ORI`/`XORI`, or a
SPECIAL shift/arithmetic/logic/`SLT`/`SLTU`) that reads `reg`. `reg` is
1..31; `max` is a signed 32-bit integer. Addresses are unique by physical
address.
- Main executable only. Generation fails when the listed word is not
`expected`, or when the previous instruction loads `reg` (its value would
still be in the load-delay slot). Captured overlay code at a listed address
keeps its own code, so the sites do not enter the overlay-cache identity.
The dirty-RAM interpreter applies a clamp where the game's text image holds
the listed word.
- Pick `max` so the clamped index reaches no further than the farthest index
the unclamped code can already produce. Choose the register the following
code really indexes with: in the `addiu` form above, the guard tests `at`
but the slot is computed from `v0`.
- Regenerate after changing the list. With no sites the generated code is
unchanged.
## Mod immediates (`[mod_immediate]`)
A trusted mod sometimes needs the same compiled instruction to behave
differently from one session to the next without changing the guest bytes
(a capacity compared against `slti v0, a0, 32`, a table address formed by
`addiu a1, a1, 0x6988`, a struct offset in `lbu v0, 0x2E9(v1)`). Patching RAM
fails for statically compiled code: every function carries a CRC of its
original bytes and a patched function drops to the interpreter. Listing the
instruction as a mod immediate site keeps the bytes and the CRC, and moves the
choice into the generated code:
```toml
[mod_immediate]
sites = [
{ address = "0x80023500", expected = "0x28820020", group = 1, values = [0x40, 0x80] }, # slti v0, a0, 0x20
{ address = "0x80023B14", expected = "0x24A5AEB1", group = 1, values = [0xAEB1, 0x9000] },
]
```
- `expected` is the complete word at `address` and must be an I-type ALU
instruction (`ADDI`..`LUI`, opcodes 0x08-0x0F) or a byte/half/word load or
store (`LB`..`SW`, 0x20-0x2B). Branches, jumps and R-type instructions are
refused. `group` is 0..63; `values` lists up to 15 replacement 16-bit
immediates (decimal, hex, or negative), selector 1 first.
- Selector 0 is the original instruction. A trusted plugin selects variant N of
every site in a group with `psx_mod_set_immediate_selector(group, N)` from its
activation callback; the selector is 0 again at every session start, so an
inactive mod, a disabled feature and netplay all run the stock code.
- Generated code emits `switch (psx_mod_immediate_select(group))` with one case
per value, each translated exactly like the instruction it stands for
(cycle accounting, PGXP hooks, load-delay handling), plus the original as the
default. The dirty-RAM interpreter substitutes the same word
(`psx_mod_immediate_patch`), so native and interpreted execution agree. The
forwarder is part of the overlay ABI (v31).
- A site is keyed by physical address and word. Overlays share load addresses,
so one address may carry several sites with different words; a variant whose
word differs is left alone in overlay mode and is an error in a main EXE.
- The sites enter the overlay-cache identity (the hash changes only when the list
is non-empty). Regenerate after changing the list; with no sites the generated
code is unchanged.
- The mod owns the consequences: a relocated table needs its own free memory and
a save written under one selector is not interchangeable with another's.
## Runtime block
Consumed by the cmake macro `psxrecomp_v4_add_runtime_target` (eventually)
and by `runtime/src/main.cpp` as the source of compiled-in defaults.
```toml
[runtime]
debug_port = 4370 # TCP port for the debug server
window_title = "..." # SDL window title
controller = "digital" # "digital" or "dualshock"
memcard_dir = "." # memcard files location, relative to project root
```
### Opt-in warm CD routes
Per-game read acceleration is disabled unless the game explicitly declares
one or more strict routes. Each route arms on one `SetLoc`, then requires every
later file start to match `lbas` in order. A mismatch immediately restores the
normal configured disc timing. Only data-read cadence is accelerated; XA/CDDA,
seek, and motor timing remain authentic.
```toml
[[runtime.warm_cd_routes]]
arm_lba = 95947
lbas = [298, 299, 306]
instant_max_per_frame = 32
```
Up to 16 routes may be declared, with 1–64 LBAs each. The old singular
`[runtime.warm_cd_route]` table is deprecated and emits a warning when loaded;
it remains readable for compatibility. This enhancement is intentionally
opt-in and must not acquire a global default.
The other load-time accelerators are likewise opt-in:
```toml
[runtime]
idle_skip = true
turbo_audio_sink = true
overlay_region_floor = "0x10000" # optional: lowest RAM address treated as overlay region.
# Default = boot EXE text end. Lower it for titles whose gameplay
# code loads at/inside the boot text range (GT1 secondary EXEs at
# 0x80010000, Driver 2 mission pages) so it is overlay-cache
# eligible. Clamped >= 0x10000; PSX_OVERLAY_REGION_FLOOR overrides.
```
`idle_skip` only elides loops with no stores and one interrupt-check edge.
`idle_skip_store_counters = true` (with `idle_skip`; `PSX_IDLE_SKIP_EXT=0/1`
overrides it for one run) also elides loops that count a stack timeout down
by one each pass and span up to eight block edges: PsyQ libetc's `v_wait`,
the `VSync(0)`/`VSync(n)` spin most PsyQ titles run every frame. The skip
lowers the counter by the number of passes it skips (several edges per pass are
accepted only together with such a counter store; the device distance is
measured from the current cycle, not from the devices' last service) (never to 1 or below, so
the real timeout test still runs) and lands on the loop's first edge strictly
before the next device event, so an interrupt is taken at the same edge as
without it. Guest cycles, device-register writes and scratchpad writes are
unchanged; the only difference is that the skipped passes' stores to the
counter word are not individually made (`frame_fingerprint` `wc`/`ws` count
fewer writes, its judge columns match). In R4 races it removes about a quarter
of the emulation thread's time. Off under netplay, like `idle_skip`.
#### Developer contract for `idle_skip_store_counters`
**Eligibility is an audited allowlist, not an automatic guarantee.** The
detector can check a loop's shape over the passes it observes. It cannot prove
that the loop has no later exit that depends on the counter (for example, an
exit at `counter == 500`): a skip would jump past it. So the extension only
skips at sites the title's developer has audited and declared:
```toml
[runtime]
idle_skip = true
idle_skip_store_counters = true
# Block-edge PCs of each audited loop (the detector anchors on either).
idle_skip_store_counter_sites = ["0x8008B4D0", "0x8008B518"]
```
Unlisted sites are never skipped. An empty or missing list turns the
extension off, and the runtime log says so. Before adding a site, read the
loop's code and record its exit structure next to the list: every exit that
depends on the counter must be the final timeout test (the counter reaching
the value the skip stops short of), and no other code may read the counter.
R4 (SLUS-00797) is the reference audit. PsyQ libetc `v_wait` sits at
`0x8008B4A8`:
1. It stores `timeout << 15` to `0x10(sp)` and returns at once if the VSync
count at `0x800A6548` has already reached the target.
2. **`0x8008B4D0`:** it loads the counter, subtracts 1, stores it and reloads
it. If it equals -1 it takes the timeout path (`0x8008B4F0`: prints
"VSync: timeout", calls `ChangeClearPAD(0)` and `ChangeClearRCnt(3, 0)`,
then jumps to the exit).
3. **`0x8008B518`:** it reloads the VSync count and branches back to
`0x8008B4D0` while the count is still below the target.
4. **`0x8008B530`:** the exit.
The only exit that depends on the counter is the `== -1` timeout test. The skip
never takes the counter to 1 or below, so that test always runs for real. The
counter is a private stack slot of this frame.
A skip leaves out stores to the counter word that the guest would have made.
That is invisible only if nothing observes the word between those stores. The
assumptions, and how each one is enforced:
| Assumption | Enforced by |
|---|---|
| The loop makes exactly one guest store per pass: a 4-byte-aligned word in RAM or the scratchpad, at the same address every pass, one lower each pass. | `idle_stores_ok` plus the address/value check at the anchor. Any other store count, any device or BIOS-area address, or a changed pattern resets the detector. |
| The loop touches no MMIO, so it cannot start DMA, acknowledge an IRQ or poll a device mid-span. | `g_mmio_access_count` must be unchanged across the pass. |
| Everything else in the pass is a pure function of the registers. Only the counter register and the counter word progress; every other GPR is invariant at the anchor. | The register snapshot is compared at the anchor; one progress register is allowed, and only if it moves in step with the word. |
| No other observer (IRQ handler, DMA, timer, SPU, CD, GPU, host-side mod or debugger code) runs between two device events. | The skip lands on the anchor strictly before the next scheduled device event, measured from the current cycle. Every IRQ, DMA completion and VBlank callback therefore sees the counter value that unskipped execution would have stored at that same edge. Host mod hooks run only at VBlank or at function entry, and neither happens inside the span. |
| The loop has no exit that depends on the counter, except the final timeout test. | **Not checkable by the detector.** It is the caller's audit: only the sites listed in `idle_skip_store_counter_sites` are eligible. |
| The loop's own exit test still runs. | The counter is never lowered to 1 or below, so the `--timeout == -1` path executes for real. |
| The game does not read the counter word through another pointer, inside or outside the loop. | Partly checked: a read that changes a register or the edge count resets the detector. Otherwise this is part of the site audit. |
What differs from unskipped execution: the guest store count (`frame_fingerprint`
`wc`/`ws`) and the host time. Guest cycles, device-register writes, scratchpad
and RAM contents at every device event are the same. That is why the judge
columns `cyc`, `mmio`, `mc`, `sp`, `sc` and `qc` must match. A title that
breaks one of the assumptions in a way the detector cannot see (for example, a
second CPU-side writer driven by a device it does not schedule) must leave
the key off.
Evidence (R4, OpenBIOS, M4 release build with debug tools, `frame_fingerprint`, key off vs on):
- boot into attract: 9000 frames
- race from a savestate: 3600 frames
- all judge columns identical, 0 dispatch misses
- `wc`/`ws` lower with the key on
The other psxrecomp titles have no disc in this test setup, so they keep the
key off until they are run the same way.
### `turbo_loads` / `offer_turbo_loads` — deprecated and ignored
**Do not use these keys.** Load acceleration is owned by the Mods catalog:
`psx.enhancement.fast-loading` ("Fast Loading (host pacing)") and
`psx.enhancement.cd-speed`. Both target `game_id = "*"`, so they ship with every
title, both default to off, and both expose the multiplier and instant-scheduler
detail that a single opaque boolean never could. recomp-ui correspondingly draws
no generic Turbo loads row.
Both keys are still parsed so existing configs load without error, but neither is
honoured — the runtime logs a deprecation line naming the Fast Loading mod and
leaves acceleration off. Remove them from `game.toml`.
The same applies to `[video] turbo_loads` in a user's `settings.toml`: it is no
longer restored at startup, and it is no longer written back out, so the stale
row disappears on the first save after updating. This is deliberate. Because the
launcher stopped drawing a control for it, a persisted `true` was simultaneously
authoritative and unreachable: one run of a build whose `game.toml` said `true`
latched turbo on permanently, and no later config change could undo it. That
shipped to players in MegaManX6Recomp v1.0.4/v1.0.5 (MegaManX6Recomp#14). Never
restore this row without also restoring a UI control for it.
For development, the `turbo_loads` TCP debug command still toggles acceleration
at runtime.
`turbo_audio_sink` is meaningful only while load acceleration is active. It keeps
the guest SPU timeline advancing but discards accelerated samples before host
playback, then fades normal output back in.
## Audio Block
Game projects may choose the host playback cushion after validating their
audio production cadence:
```toml
[audio]
buffer_ms = 60
```
`buffer_ms` accepts 30–500 milliseconds and defaults to 180. Lower values
reduce audible input-to-sound delay, but leave less reserve for frames where a
game temporarily produces no audio and can therefore crackle on affected
titles. This is deliberately a per-game developer choice; it is not read from
the player's `settings.toml`.
## Video Block
Runtime video defaults live in `[video]`:
```toml
[video]
renderer = "opengl" # "software", "opengl", or "vulkan"
offer_vulkan = false # show Vulkan in the launcher only after game validation
auto_skip_fmv = false # legacy Settings/runtime default
offer_skip_fmv = true # false when the game exposes this through Mods
```
`renderer = "vulkan"` remains an experimental runtime choice and still requires
a build compiled with Vulkan support. `offer_vulkan` controls launcher
visibility only; it defaults to false so game projects must explicitly expose
Vulkan after validating their visuals and stability.
### The game's look: title baseline and built-in mods
These `[video]` / `[audio]` keys are the title's **baseline**: what a player
sees and hears with the matching built-in mod off. They are not player
settings any more (no `settings.toml` key, no launcher Settings row, no
in-game menu row); a player changes them through the framework's built-in
mods on the Mods page, whose "Game default" option values keep the baseline
(`runtime/include/host_enhancements.h`, docs/MOD_PACKAGES.md "Built-in host
enhancements"):
| `game.toml` key | Built-in mod (option) |
|---|---|
| `[video] crt_filter` (screen model) | CRT Filter (`psx.enhancement.crt-filter`: Screen model) |
| `[video] scanlines`, `scanline_strength` | CRT Filter (Scanlines, Scanline strength) |
| `[video] antialiasing` (linear present) | Image Filtering (`psx.enhancement.image-filtering`: Smoothing) |
| `[video] texture_filtering` | Image Filtering (Texture filtering) |
| `[video] fmv_filter` | Image Filtering (FMV filtering) |
| `[audio] spu_hq` | Enhanced Audio (`psx.enhancement.enhanced-audio`) |
| `[video] geometry_correction`, `perspective_texturing` | PGXP Precision (`psx.enhancement.pgxp`) |
Local rewind has no `game.toml` key; it is the Rewind mod
(`psx.feature.rewind`, below).
A `settings.toml` written before this change may still carry `antialiasing`,
`texture_filtering`, `fmv_filter`, `crt_filter`, `scanlines`,
`scanline_strength`, `geometry_correction`, `perspective_texturing`, `rewind`,
`rewind_depth`, `rewind_interval`, `supersampling`, `internal_resolution`,
`supersample`, `dynamic_resolution`, `dynamic_resolution_min`, `[audio]
spu_hq`, `[audio] frequency` or the `quality_*` keys. They are ignored, never
an error, and the next save drops them (an empty `[audio]` table included).
**Audio sample rate.** The host audio device opens at the runtime's built-in
rate (44.1 kHz, the SPU's own). It is not a player setting: the launcher has no
Sample rate row for PSX and `settings.toml` has no `[audio] frequency`. Volume
stays a launcher setting.
**Graphics presets were removed.** A `game.toml` that still has a `[quality]`
table (`[quality.low]` .. `[quality.ultra]`) fails to load with an error naming
the table; delete it. No title shipped one.
### Internal resolution (`internal_resolution`, `supersampling`)
The internal resolution is the title's `game.toml` baseline, and the player
changes it with the built-in **Internal Resolution** mod
(`psx.enhancement.internal-resolution`, docs/MOD_PACKAGES.md "Built-in host
enhancements"): Game default, Native, 720p, 1080p, 1440p, 4K, 5K, 8K, Match
display or **Auto**. It is not a Settings row and not a `settings.toml` key.
With the mod off, or at Game default, the title's keys apply unchanged:
```toml
[video]
internal_resolution = "4k" # native | 720p | 1080p | 1440p | 4k | 5k | 8k | display | auto, or a number of lines
resolution_reference_lines = 240 # the title's usual display height (120..1024)
supersample_disable_at_lines = 0 # supersample 1.0 at/above this display height (0 = off, 240..8640)
match_display_max_lines = 0 # cap on the display height Match display uses (0 = none, 240..8640)
```
`match_display_max_lines` caps the monitor height that Match display (and
Auto) and its dynamic-resolution floor (`display`) resolve against. It also caps the final Match display target after supersampling,
so a larger factor cannot exceed it. Fixed presets keep their own target.
With no cap, an 8K panel asks 18x (27x with `supersample = 1.5`), which is
past the full-VRAM surface. The high-resolution window then engages, and
dynamic resolution cannot step there. A title whose GPU budget is set for
4K lines sets 2160.
`supersample_disable_at_lines` uses the physical monitor height before the cap.
At or above its threshold the effective factor is 1.0; the configured factor is
retained for smaller monitors. Zero preserves the default behaviour.
A preset is a target height. The runtime renders at the integer scale
`S = ceil(target / resolution_reference_lines)`: at the default 240 lines,
720p is 3x, 1080p is 5x (1200 lines, area-resolved to 1080), 1440p 6x, 4K 9x,
5K 12x and 8K 18x. A 480-line interlaced screen renders at twice the target
and is resolved down. **Match display** takes the monitor's pixel height,
measured from the game window's display when it opens. The backend clamps S
to what it can allocate (below); `video_info` over TCP reports both numbers.
PGXP Precision's sub-pixel geometry needs S >= 2 (720p and up at 240 lines) to
be visible: at native resolution the corrected positions round back to the
same pixels.
**Auto** is Match display with dynamic resolution on: the scale starts at the
monitor's resolution and steps down to the title's `dynamic_resolution_min`
floor only while the game would miss frames, then back up. The mod's fixed
choices turn dynamic resolution off; there is no separate Dynamic resolution
switch or Lowest resolution row. A title may ship `internal_resolution =
"auto"`, or its own fixed ceiling with `dynamic_resolution = true`; Game default
keeps either. `PSX_DYNRES=0|1` still overrides dynamic resolution for one run.
Precedence for a session: `PSX_INTERNAL_RESOLUTION` (one run), then a title
mod's `psx_mod_set_internal_resolution` request, then the Internal Resolution
mod's choice, then the title's `internal_resolution` (or its legacy
`supersampling` factor). Every session start restores the title's values before
the mod's choice applies, so a rematch never inherits the last session's scale.
The mod's option changes apply live from the in-game menu when the OpenGL
backend can change its scale on a running game (otherwise at the next start),
and never during a netplay match. The debug server's `video_set` changes the
session's effective internal resolution and supersample live; it saves
nothing.
`PSX_INTERNAL_RESOLUTION=<preset or lines>` overrides every layer for one run
(validation, and two local netplay peers that share one install).
On OpenGL, any choice above native opens the game window with a
high-pixel-density drawable (macOS Retina, Wayland scaling), so the window has
the pixels to show it. Native keeps the window exactly as before.
`supersampling = N` (game.toml) renders at N times the native resolution per
axis and downsamples to the window, 1 to 32; 1 (the default) is native and
unchanged. It is the title's legacy form of the baseline: an
`internal_resolution`, or the mod's choice, supersedes it. `settings.toml`
`window_width` accepts 640 to 7680.
### Bloom (`bloom`)
Opt-in glow around lights, OpenGL only. Off (0) changes nothing.
```toml
[video]
bloom = 1.0 # 0.0 (off, default) .. 2.0 strength
```
Only what 3D primitives (proven from GTE data, so it needs PGXP geometry) and
additive semi-transparent primitives (blend modes 1 and 3) drew can glow: each
draw marks its pixels in a spare stencil bit, and a sprite, fill or HUD element
drawn over them clears the mark. The marks gate both ends of the pass: only
marked pixels feed the bright pass, and only marked pixels take the glow back,
so a HUD, sprite or fill pixel is never changed, even right next to a lamp
(the halo spreads over 3D surroundings only). The pass runs on the composed
game image like `antialiasing_mode` (before it, sharing its copy texture and
full-screen pass): a quarter-resolution bright pass, three Gaussian iterations
and a screen blend.
It follows 4:3, native-wide, supersample, dynamic resolution, the render
thread and Smooth motion (generated frames carry the marks of the frame they
are drawn from). Without PGXP geometry only additive primitives glow.
Bloom is the built-in `bloom` chain of the post-effect pipeline
(`docs/POST_EFFECTS.md`), which also loads your own chains through
`post_shader = "<file>.pfx"` (relative to the game root; `PSX_POST_SHADER`).
`PSX_BLOOM=<0..2>` overrides it for one run and the `bloom` TCP command reads
or changes it live.
### Anti-aliasing (`antialiasing_mode`, `supersample`)
Both are opt-in and change nothing unless set.
```toml
[video]
supersample = 1.5 # 1.0..4.0, default 1.0
antialiasing_mode = "fxaa" # off (default) | fxaa | fxaa_hq
```
`supersample` multiplies the target of a line preset or Match display before
it becomes a scale: Match display on a 1080 px monitor with 2.0 asks for 2160
lines (S = 9) and the present's area resolve brings it back to 1080, which is
ordered-grid SSAA of everything, 2D included. Native and the legacy
`supersampling` factor are left alone. Dynamic resolution takes the result as
its ceiling and steps under it as before. It is the title's key only (the
Internal Resolution mod applies it to the player's choice too);
`PSX_SUPERSAMPLE=<factor>` overrides it for one run.
To make supersampling the elastic part only, set the dynamic resolution floor
to the output: `dynamic_resolution_min = "display"` (game.toml, also
`PSX_DYNRES_MIN=display`) means the selected internal resolution *without* the
supersample factor, i.e. the monitor's own lines under Match display or the
preset's lines otherwise. Dynamic resolution then steps between the
supersampled ceiling and the output and never renders below it:
```toml
[video]
internal_resolution = "display"
supersample = 2.0
dynamic_resolution = true
dynamic_resolution_min = "display"
```
`antialiasing_mode` filters the composed game image on OpenGL: after the game
quad (4:3, native-wide, Smooth motion and generated frames alike) and before
hold-last capture, the OSD, screenshots and the swap. It reads only output
pixels, so guest VRAM, readbacks, render passes and the Smooth motion sources
are untouched; the bezel and OSD stay sharp; 24-bit FMV frames are not
filtered. The PS1 HUD is part of the same image and is filtered too. `fxaa` is one copy and one full-screen pass (on an M4, at most 0.4 /
0.7 / 1.5 ms at 1080p / 1440p / 4K, fence-timed with the copy); `fxaa_hq` lowers the contrast threshold and searches further along
long edges. `PSX_AA_MODE=off|fxaa|fxaa_hq` overrides it for one run and the
`post_aa` TCP command switches it live. The older boolean `antialiasing` key is
unrelated: it is the linear present filter, the title's baseline for the
Image Filtering mod's Smoothing.
Not offered: MSAA. The hi-res surface *is* guest VRAM (texture pages, CLUTs,
VRAM copies, readbacks and the mask stencil all sample or copy it between
draws), so a multisampled target would need a resolve before every one of
those and the mask/semi-transparency rules would have to run per sample; that
is a renderer redesign, not an option. SMAA needs its area/search lookup
textures and three passes; on an image that is already area-resolved from a
higher internal resolution it buys little over FXAA, so it is left for later.
### FMV chroma smoothing (`fmv_chroma_smoothing`)
```toml
[video]
fmv_chroma_smoothing = false # default
fmv_filter = "bicubic" # optional upscale filter (needs antialiasing on)
```
MDEC movies are 4:2:0: one colour (Cb/Cr) sample per 2x2 pixels, which the
decoder replicates, so saturated edges in a 24-bit movie frame show 2-pixel
colour blocks around sharp luma. With `fmv_chroma_smoothing = true` the
OpenGL present of a 24-bit frame keeps each sample's luma (from whatever
`fmv_filter` reconstructs) and takes its chroma from a [1 2 1] tent over the
3x3 source pixels around it (BT.601), which removes the colour blocks without
softening luma detail. Guest VRAM and MDEC output are untouched; 15-bit frames
are never affected. It pairs with the existing `fmv_filter` upscale choice
(`nearest`, `bilinear`, `sharp`, `bicubic`). OpenGL only.
`PSX_FMV_CHROMA=0|1` overrides it for one run and the `fmv_chroma` TCP
command switches it live.
### Texture LOD and anisotropic filtering (`texture_lod`, `anisotropic_filtering`)
```toml
[video]
texture_lod = "off" # off (default) | mipmap
anisotropic_filtering = 1 # 1..16, taps along the footprint (with mipmap)
```
PS1 texture pages and CLUTs live in VRAM and are decoded per fetch, so there
is no GL mip chain to sample, and a far road or wall at high internal scale
still point-samples one texel per pixel (shimmer). `mipmap` emulates the mip
level in the textured shader: the screen-space UV footprint is box-averaged
(opaque texels of the same STP class only), and `anisotropic_filtering` > 1
spreads up to that many taps along the footprint's long axis so grazing
surfaces stay sharp across it. Every tap clamps to the primitive's own UV
bounds (or its texture window), so packed atlases never bleed into each
other, and a live CLUT change is seen at once (nothing is cached). Only
proven 3D world primitives are affected; sprites, HUD and magnified textures
keep their exact texels, and `off` is the historical path. Cost is up to 64
fetches per minified pixel. OpenGL only. `PSX_TEXTURE_LOD=0|1` and
`PSX_ANISO=N` override them for one run; the `texture_lod` TCP command
switches them live.
### Accurate blending (`accurate_blending`)
```toml
[video]
accurate_blending = true # default: PS1 colour/blend correctness
```
Accurate blending is enabled by default because it corrects PS1 rendering.
`false` retains the historical path for diagnosis.
The OpenGL surface holds each 5-bit VRAM channel as an 8-bit value. Uploads,
copies and untextured primitives store `k*8`, but fills and texels have
always stored `k*255/31` (full-range white). Semi-transparent blending of two
operands in different bases, truncated back to 5 bits when the game reads
VRAM, is off by one step in places: B+F/4 over a fill, B/2+F/2 with an odd
sum, B+F/4 of a texel. `true` makes every VRAM write use `k*8`, so GL's
8-bit blend followed by the readback's `>>3` is exactly the PS1's 5-bit
arithmetic in all four modes (B/2+F/2, B+F, B-F, B+F/4), with set-mask,
mask check, texel STP and PGXP depth, at any internal scale and on drivers that quantise the blend constant to 8 bits (it uses 128/255 and 64/255, exact in unorm8, instead of 0.5 and 0.25) (checked against
the formulas and the software rasterizer by `gl_blend_accuracy_test`). The
visible cost: textured and filled whites present as 248 instead of 255, as
uploaded images and untextured primitives already do. One divergence
remains in both settings: a second semi-transparent layer blends with the
first layer's full 8-bit result where the console would have truncated it to
5 bits first (at most one 5-bit step). OpenGL only.
`PSX_ACCURATE_BLENDING=0|1` overrides it for one run and the
`accurate_blending` TCP command switches it live.
### Dithering (`dithering`)
```toml
[video]
dithering = "on" # on (default) | fine | off
```
The PS1 GPU dithers while the game sets GP0(E1h) bit 9: it adds a 4x4
offset pattern to every pixel of a Gouraud-shaded polygon or line and of a
texture-modulated polygon, then truncates to 5 bits per channel. Flat
untextured primitives, raw-texture polygons and all rectangles (sprites) are
never dithered. Eligibility is decided per primitive from its command and
carried to the shader, never guessed from colours.
`on` (the default; `scaled` is accepted as an alias) is that hardware
behaviour with the pattern on the native VRAM grid, so it looks like the
console at any internal scale and the VRAM a game reads back is identical at
every scale. `fine` is elective: the pattern per internal-resolution pixel, a
grain that all but vanishes at high scales (guest VRAM then depends on the
scale). `off` is the old true-colour output (8 bits per channel, no pattern).
Semi-transparent primitives are an approximation: the pattern is applied to
the source colour before blending, while the console dithers the blended
result at its final 8-to-5-bit write. Fixed-function GL blending cannot run
a shader after the blend (GL 3.3 has no framebuffer fetch, and macOS offers
none), so a dithered semi-transparent pixel can differ from the console by
one 5-bit step; opaque pixels match exactly. The dither output follows the
surface colour basis, so it composes with `accurate_blending`. OpenGL only;
the software and Vulkan renderers do not dither. `PSX_DITHERING=0|1|2`
overrides it for one run and the `dithering` TCP command switches it live.
### Texture filtering (`texture_filtering`)
```toml
[video]
texture_filtering = "nearest" # nearest (default) | bilinear | xbr
```
`nearest` is the PS1's point sampling. `bilinear` blends the four nearest
texels (opacity-gated, so cutout edges keep their colour). `xbr` (OpenGL
only) is edge-directed magnification after Hyllian's xBR (MIT; see
`THIRD_PARTY_ATTRIBUTION.md`): for the texel
corner nearest each sample it compares weighted colour distances over a 5x5
neighbourhood, and where an edge runs along that corner's diagonal the corner
is cut and takes the closer side neighbour, with an anti-aliased boundary one
output pixel wide. Flat areas, straight edges and gradients stay crisp
texels; diagonals and curves lose their staircase. It acts only where a
texel spans two or more output pixels (high internal scale or a magnified
texture), so a 1:1 draw is exactly nearest; cutouts and STP stay the centre
texel's. It costs up to 13 texel fetches per pixel. JINC2 is not offered.
`PSX_TEXTURE_FILTER=0..3` overrides it for one run (2 is the mod-only "stable
world" minification filter) and the `texture_filter` TCP command switches it
live. This key is the title's baseline; a player chooses nearest, bilinear or
xBR through the Image Filtering mod.
The runtime clamps N per backend:
- Software and Vulkan stop at 4.
- OpenGL keeps VRAM as one `1024*N x 512*N` surface and clamps N at context
init to the driver's `GL_MAX_TEXTURE_SIZE`, `GL_MAX_RENDERBUFFER_SIZE` and
`GL_MAX_VIEWPORT_DIMS`, and to a memory budget of 2 GiB for that surface
(`PSX_GL_VRAM_BUDGET_MB` overrides it; `0` removes it). Apple's OpenGL
reports 16384, so 16 is the largest full-VRAM scale there. A surface that
still fails to allocate is retried one scale lower; the backend never drops
to software because of the scale. The log line
`GL internal scale Nx clamped to Mx (...)` names the limit that applied.
**Past the full-VRAM limit** (8K is 18x, over Apple's 16384), OpenGL keeps
the whole VRAM at 1x — the native renderer, so everything the game reads back
is exactly native — and renders only the columns it displays at the full
scale, in a separate high-resolution window (`5760x9216` for a 320-wide
game at 18x, about 405 MiB). The window grows to cover each displayed
rectangle the first time it is shown; when the displayed rectangles are too
far apart for one surface (side-by-side 512-wide buffers), it splits into up
to four tiles so every buffer still presents at the full scale. A copy whose
source lies outside it is taken from the 1x surface. `PSX_GL_HIRES_WINDOW=0`
turns the mode off (the scale is then clamped as above) and `=1` forces it at
any scale above 1. `PSX_GL_MAX_DIM=N` lowers the GPU limit the backend plans
with, to check a layout on a smaller GPU.
Above 1x the OpenGL present averages the whole footprint of each output pixel
when the internal image is more than 1.25 times larger than the window (for
example 1200 internal lines into a 1080-line window), and lines are drawn one
native pixel thick at any scale.
`offer_skip_fmv` defaults to true for compatibility with the shared PSX
Settings surface. A game migrating Skip FMVs into its built-in mod catalog sets
it to false. The runtime then hides the Settings row, ignores stale persisted
values, and leaves activation to the selected trusted plugin.
### Render thread (`render_thread`, OpenGL)
```toml
[video]
render_thread = true # default false; PSX_RENDER_THREAD=0/1 overrides
```
Runs the OpenGL backend on its own thread: the emulation thread records each
frame's draw work and the render thread replays and presents it, so GL driver
stalls leave the guest's frame. Everything the guest can read back is
identical; a readback, savestate, depth24 (FMV) display, render pass or
setting change makes that frame synchronous. Netplay, frame interpolation,
Vulkan and software keep the synchronous path. `PSX_RENDER_THREAD_FRAMES`
(default 2) bounds the frames in flight. See `docs/RENDER_THREAD.md`.
### Texture-window batching (`texture_window_batching`, OpenGL)
```toml
[video]
texture_window_batching = true # game.toml only; default false
```
The OpenGL renderer draws consecutive textured primitives in one batch while
their blend, mask and filter state match. By default a GP0(E2h) texture-window
change also ends the batch, although each vertex carries its primitive's
texture window. With this key on, primitives with different windows share a
batch. The image is the same either way; only the number of draws changes.
While mask checking (GP0(E6h) bit 1) is on, a window change still ends the
batch, because an opaque batch updates the mask bits of its own texels only
after its colour pass.
It is for games that tile textures through per-primitive windows. Ridge Racer
Type 4's split screen changes the window about 515 times a frame, which drew
about 180 batches a frame instead of 17, and every batch that reaches the
native-wide margins is drawn again into the wide surface. The software and
Vulkan renderers ignore the key.
`PSX_GL_TEXWIN_BATCH=0|1` overrides it for one run, and the TCP command
`gl_texwin_batch on=<0|1>` switches it live.
### Local rewind (the Rewind mod)
Rewind is the framework's built-in **Rewind** mod (`psx.feature.rewind`), off
by default: enabling it on the Mods page turns the ring on, with its **Rewind
buffer** (snapshots kept: 50 / 100 / 150 / 200, default 50) and **Rewind
interval** (frames between snapshots: 1 / 4 / 8 / 12 / 15, default 15)
options; changes apply at the next start. The controller hotkey that opens it
stays a player setting, `settings.toml [hotkeys] rewind_pad` (the launcher's
Hotkeys page); the keyboard key is `config.ini [KeyMap] Rewind`. The old
`[video] rewind / rewind_depth / rewind_interval` keys are ignored.
**Off by default.** The ring holds whole *machine* snapshots — 2 MB main RAM
+ 1 MB VRAM + 512 KB SPU RAM, stored uncompressed — and captures one every
interval frames (denser, toward 4, while an FMV runs). At the default depth
that is a few hundred MB of resident memory plus a periodic multi-megabyte
copy, which is not a cost to charge every host for a feature a session may
never open; nothing is allocated until the mod is on.
`PSX_REWIND=1` / `PSX_REWIND=0` override the mod either way, and
`PSX_REWIND_DEPTH` / `PSX_REWIND_INTERVAL` / `PSX_REWIND_FMV_INTERVAL` override
the tuning. A build configured with `-DPSX_REWIND=OFF` has no rewind at all,
ignores all of these, and does not stage the Rewind package
(`runtime.cmake` declines it like an `EXCLUDE_BUILTIN_MODS` entry).
Netplay rollback is a separate subsystem with its own ring and is unaffected by
the mod; rewind is in fact suppressed while a netplay session is active (the
mod is host-only, so it is no part of the netplay plan either).
A title can also block Rewind while it is in a mode that must not be rewound,
such as local split-screen multiplayer: a trusted mod plugin calls
`psx_mod_set_rewind_blocked(1)` when the mode starts and `0` when it ends
(`runtime/include/mod_plugins.h`). While blocked, opening Rewind is refused
with an OSD note, no snapshots are captured (the ring keeps what it already
has), and a title-allowed direct Rewind button (`psx_mod_allow_direct_shortcut`)
is not claimed, so it reaches the game as if Rewind were off. The block clears
at every mod/session reset; nothing changes for a title that never calls it.
A title that computes vibration but cannot send it over SIO to the presented
pad (a NeGcon has no motors) can call `psx_mod_set_host_rumble(player, small,
large)` every frame with DualShock motor values. The runtime drives the host
pad with the louder of that and the guest's SIO motors; a value lapses 8
VBlanks after the last call and all are cleared at every mod/session reset.
`PSX_RUMBLE_TRACE=1` logs every change, headless included.
**EXPERIMENTAL:** JogCon wheel force feedback is a player setting,
`settings.toml [controller] jogcon_force_feedback` (off by default; the
launcher / in-game menu toggle writes it via
`RecompLauncherCSettings.jogcon_force_feedback` (recomp-launcher; read behind
`RECOMP_LAUNCHER_HAS_JOGCON_FORCE_FEEDBACK`, so other providers still build), applied live through
`psx_jogcon_ffb_set_player_enabled`). A title may set its default with
`game.toml [controller] experimental_jogcon_force_feedback` (off when
absent); the player's setting always wins. When on, it drives a host
force-feedback wheel's SDL haptics from a presented JogCon's motor commands: a constant force for turn commands 1 and 2, and a centring
spring for hold. It is off by default. Gamepads and devices without constant
or spring haptics are never driven. Every force has a 120 ms lifetime and is
renewed each VBlank, so a pause, stall or crash leaves the wheel slack. The
Rewind and save-state pause menus, disconnects and session end also stop it
explicitly. The turn direction has not been checked on JogCon hardware or a
physical wheel; `experimental_jogcon_force_feedback_invert = true` flips it.
SDL has no virtual haptic device, so CI covers the driver with a recording
backend and the SDL backend only against an SDL3 virtual wheel (no haptics).
Bezel artwork is intentionally not a `[video]` key. It is exposed as the
disabled-by-default `psx.presentation.bezel` mod package, which draws a
user-selected image resource behind the game image in OpenGL letterbox or
pillarbox margins. With the mod disabled, or with no bezel image selected,
margins remain the historical black clear.
Reserved future fields:
- `default_disc_path` — game runtimes can pre-mount a disc
- `default_game_root` — for sibling-junction setups
## Timing block (`[timing]`)
Title constants. They live in the game's `game.toml`, are never written to
`settings.toml`, and are not shown in the launcher.
```toml
[timing]
guest_cycle_scale = 1 # 1..64; 1 = faithful (default)
# Optional declarative gate: the scale applies only while every predicate
# holds, judged at each VBlank edge (one inline table or an array, max 8).
guest_cycle_scale_gate = [
{ addr = 0x800AC794, value = 0x180 }, # size = 4, mask = ~0
# { addr = 0x80010003, size = 1, mask = 0x0F, value = 5 },
]
guest_cycle_scale_gated = false # also require the mod gate (custom logic)
```
No CPU is emulated. Recompiled code charges the guest clock a fixed number of
cycles for each MIPS instruction it runs, and VBlank, timers, CD, SPU and DMA
are scheduled against that clock. `guest_cycle_scale = N` charges each
instruction 1/N of its cost; the devices keep hardware time. Larger N means
the game never runs out of frame time: N times as much game code fits
between two VBlanks. A game that paces itself on VBlank keeps its speed; one
that busy-waits on a frame counter spins longer in host time, which is what
the gates are for.
Accounting is the same on every tier. Guest time has two entry points
(`psx_cycles.h`): the **CPU charge** `psx_cpu_charge()` (instruction base
cost, load fudge/region wait/completion, i-cache refills, BIOS HLE costs),
which applies the scale, and the **device advance** `psx_advance_cycles()`
(DMA, idle skips, mul/div and GTE deadline waits), which never does.
Native generated code, overlay DLLs (their batched charge publishes through
the `cpu_charge` callback into the same function), the dirty-RAM
interpreter and the BIOS all charge CPU work through it; mul/div and GTE
latencies are scaled where their deadline is set. At 1 the CPU charge is
exactly the device advance and nothing else changes.
`guest_cycle_scale_batch = true` (opt-in; `PSX_GCS_BATCH=0/1` for one run)
scales each CPU charge where it is made and then batches it like the scale-1
path, instead of publishing every one-cycle step to the devices. The
fractional carry and the flush points (stores, MMIO, interrupt checks, device
deadlines) are the same, so guest-visible timing is unchanged. In R4 races,
`frame_fingerprint` A/Bs over 9000 frames (boot to attract, 1P race, 2P VS
race) agree on `cyc`, `mmio` and `mc` on every frame. They also end with
identical ordered write hashes. The only differences are one-frame VBlank
straddles of `wc`/`sc`, as documented for batched code.
The scale and selected batching profile are fixed at session startup and
included in the existing netplay compatibility fingerprint. Peers with
different batching choices cannot start together. Both paths use portable C;
this does not authorize compatibility between otherwise different builds or
replace the existing game/core/config identity checks.
Gates (all present gates must be open; with none the scale always applies):
- `guest_cycle_scale_gate` — RAM predicates on aligned main-RAM words
(`(word & mask) == value`, `size` 1/2/4), judged at every VBlank edge, so
every netplay peer and every rollback re-simulation sees the same verdict.
Shut until the first VBlank that sees them hold. Needs no mod.
- `guest_cycle_scale_gated = true` — a trusted plugin opens the gate with
`psx_mod_set_guest_cycle_scale_gate(1)` (`mod_plugins.h`) for logic a
predicate cannot express, or to tie the scale to a mod that must be active
for it (every mod session reset shuts the gate; mod plans are cleared
online, so a mod-gated scale stays out of netplay). With only the
declarative gate the scale applies online too.
The carried fraction of the scaled clock and both gate states are saved in
savestates and the netplay rollback snapshot (`boot_state` section
`BS_SEC_GCS`, written only when the scale is not 1). A scale other than 1 is
folded into the netplay content fingerprint, so peers that differ never
match. `PSX_GUEST_CYCLE_SCALE=<n>` overrides the scale for testing only.
## Audit block
See `docs/internal/audit_inventory.md` for the audit pipeline. The schema here is
the input side: regions to walk, address-normalisation rules.
```toml
[audit]
function_starts = "generated/ghidra_function_starts.json" # optional
[[audit.regions]]
name = "..." # e.g. "Boot", "Kernel", "Shell", "Text"
rom_start = "0x..." # byte offset in rom/exe file
rom_end = "0x..." # exclusive
vaddr_base = "0x..." # virtual address corresponding to rom_start
[audit.normalize]
kseg_mask = "0x1FFFFFFF"
[[audit.normalize.remap]]
description = "..." # human-readable; not consumed by tooling
from_lo = "0x..." # inclusive
from_hi = "0x..." # exclusive
to_lo = "0x..." # target start offset (phys = phys - from_lo + to_lo)
```
## What's NOT in the schema yet (Phase B+)
These are noted here so future work knows where to slot them:
- Game `discs` field (Phase D). For now Tomba uses `disc = "..."`.
- `[runtime] disc_swap_command` — runtime-side disc swap (Phase D).
- `[recompiler] seeds` as an array of paths (currently single file;
Phase A might allow multiple).
- `[program] type` explicit discriminator (currently inferred from
`rom` vs `exe` field presence).
## BIOS profiles (`bios/<STEM>.toml`)
One profile per BIOS image; the profile is the single source of truth for the
image identity, the relocation windows, and the runtime anchors. Two ship:
`bios/SCPH1001.toml` (retail; user supplies the dump) and `bios/OpenBIOS.toml`
(MIT, redistributable, shipped with the build). Normal runtimes link both
generated backends (`PSXRECOMP_BIOS_STEMS=OpenBIOS;SCPH1001`) and select one at
launch. The recompiler's `[recompiler] bios_config` identifies the profile used
for game code generation; it does not choose the player's runtime BIOS.
```toml
[program.image] # identity; recompiler refuses a mismatched ROM
sha256 = "..." # pins the exact image (empty = unchecked)
redistributable = false # true: BIOS ships with the game; runtime hides
# any requirement to provide that image
[recompiler.address_model] # boot-time bulk code copies out of ROM
normalize_mask = "0x1FFFFFFF"
[[recompiler.address_model.copy]]
name = "Kernel Part 2" # comment label in the emitted C
rom_lo = "0x1FC10000" # [lo, hi) physical, hi EXCLUSIVE
rom_hi = "0x1FC18000"
ram_lo = "0x00000500" # physical RAM destination
runtime_base = "0x00000500" # vaddr the CPU executes the copy at
dispatch_key = "ram" # "ram": functions keyed by RAM address;
# "rom": RAM alias folds back to ROM
kernel_bless = true # runtime may byte-verify + run native
[[recompiler.install_slots]] # kernel-RAM RANGES patched at runtime
ram_addr = "0x00000CF0" # legacy form: len 0x10, resume "jalr"
[[recompiler.install_slots]]
ram_addr = "0x00000C88"
len = "0x30" # bytes; the patched range is [addr, addr+len)
resume = "fallthrough" # "jalr" (default) | "fallthrough" | "none"
[recompiler.runtime_exports] # per-image HLE anchors (omit = unavailable)
shell_entry_phys = "0x00030000"
deliver_event_ret = "0x80001720"
```
An `install_slots` entry declares kernel-RAM words the guest is EXPECTED to
overwrite at runtime — the BIOS's own install stubs and, far more often, the
Psy-Q libapi patchers every SDK title runs (`_patch_gte`, `_patch_card`,
`_patch_card2`, `_patch_pad`). The emitter plants a compare-against-ROM hook
at the range start, so a live patch dispatches into the interpreter and the
guest's own instructions execute; the runtime excludes the range from the
kernel-bless memcmp and resumes native at the range end. Without the
declaration the patched body fails verification forever and interprets for
the life of the process.
`resume` says how the compiled body picks up again:
| `resume` | Continuation PC | Use for |
|---|---|---|
| `"jalr"` (default) | `ram_addr + 0x10` | the classic 4-word `lui/addiu/jalr/nop` stub, whose call returns there |
| `"fallthrough"` | `ram_addr + len` | the patched words ARE the function (a prologue rewrite, a NOP'd routine) |
| `"none"` | — | the patch jumps out and never returns to this body (a `jr` into game text) |
`ram_addr` and `len` must be 4-aligned, `len` non-zero, ranges must not
overlap, and every range must lie inside the `kernel_bless` window; the
loader refuses the profile otherwise and sorts the list for the runtime.
Finding the ranges for a new image is described in
[`dynamic_handler_install.md`](dynamic_handler_install.md).
Every `copy` entry is a claim that the boot copy is byte-verbatim; the
runtime kernel-bless memcmp enforces it, minus the declared install-slot
ranges. A BIOS with no copies (runs
entirely from ROM) is valid: normalization degenerates to the KSEG mask.
Semantic invariants (disjoint windows, no fold-output/input intersection,
single bless window) are enforced at load; violations refuse to build.
Immediate selector choices are fixed during offline mod activation until the
next session restart. Non-stock choices isolate savestate slots through the
existing content scope; live setter calls and nonzero online selection fail.