mirror of
https://github.com/RetroPortingToolKit/psxrecomp
synced 2026-10-26 02:11:36 -04:00
84d718f981
The session reset clears g_bezel_path, which only stops the next load. gl_renderer_shutdown() deleted the context but kept s_bezel_tex, and present_bezel() draws whenever that name is nonzero, so a lobby rematch would bind the previous session's texture name in its new context. Shutdown now drops it through gl_renderer_set_bezel(NULL, 0, 0) while the context is still current. Only a soft return to the lobby and process exit shut the renderer down. The source guard checks the shutdown call and set_bezel's NULL path. MOD_PACKAGES.md says what each half of the bezel reset does and gives the exact reset values for the adaptive cap and the native pacing rate. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
884 lines
39 KiB
Markdown
884 lines
39 KiB
Markdown
# PSXRecomp mod packages and features
|
|
|
|
A `.psxmod` is a versioned installation, provenance, and trust boundary. A
|
|
package may contribute any number of independently configurable **features**.
|
|
The launcher presents those features as the primary Mods list; package
|
|
installation, version selection, and removal are a secondary management view.
|
|
|
|
Feature identity is always `(package_id, feature_id)`. Enabling one feature
|
|
never enables, disables, or reconfigures another feature.
|
|
|
|
The player selects a verified stock BIN/CUE. Resolution produces guarded native
|
|
operations and sparse disc overlays without rewriting or replacing that stock
|
|
image.
|
|
|
|
## Where packages live
|
|
|
|
Two catalog roots sit beside the executable, split by who owns the files:
|
|
|
|
```text
|
|
<exe>/mods/
|
|
bundled/ build output — the framework's mods/builtin/packages (minus any
|
|
the title declines with EXCLUDE_BUILTIN_MODS) plus the title's
|
|
mods/preloaded/packages. Every build WIPES and re-stages this
|
|
tree, so nothing a player owns may live here.
|
|
installed/ launcher-owned — .psxmod archives installed through the Mods
|
|
manager. No build ever touches this tree.
|
|
state.toml user selection state (enabled features, option values).
|
|
```
|
|
|
|
Both roots use the same `<package-id>/<version>/manifest.toml` layout and are
|
|
scanned into one catalog, bundled first. An installed package with the same id
|
|
as a bundled one deliberately shadows it and records that it did so, so an
|
|
override is visible rather than decided by directory-iteration order.
|
|
|
|
A bundled package is not removable from the Mods page: deleting build output
|
|
would succeed and then be undone by the next build. Remove it from the title's
|
|
`mods/preloaded/packages` instead.
|
|
|
|
Only `bundled/` ships in a release. `installed/` and `state.toml` are the local
|
|
machine's and are excluded by every packager.
|
|
|
|
**Migration.** A pre-split install has one `mods/packages/` tree holding both.
|
|
The first scan moves each package the current build did not also stage into
|
|
`installed/`, drops the rest as redundant build output, and removes the old
|
|
tree — but only once every version directory has been dealt with. Anything that
|
|
could not be moved is left exactly where it is and reported.
|
|
|
|
A manifest that fails to parse is never skipped silently: it is reported by
|
|
`scan_errors()` and logged, naming the path and the reason.
|
|
|
|
## How `bundled/` gets staged (titles: read this)
|
|
|
|
**The framework owns the layout. A title declares a directory, never a path
|
|
shape.** Hand the title's catalog to `psxrecomp_add_runtime_target()`:
|
|
|
|
```cmake
|
|
set(MYGAME_PRELOADED_MODS "${CMAKE_CURRENT_SOURCE_DIR}/mods/preloaded")
|
|
|
|
psxrecomp_add_runtime_target(psx-runtime
|
|
...
|
|
PRELOADED_MODS_DIR "${MYGAME_PRELOADED_MODS}"
|
|
)
|
|
```
|
|
|
|
`PRELOADED_MODS_DIR` names a directory shaped like
|
|
`<dir>/packages/<package-id>/<version>/manifest.toml`, optionally with a
|
|
`README.md` beside `packages/`. On every build of that target the framework:
|
|
|
|
1. wipes `<exe-dir>/mods/bundled` (build output only — never `installed/` or
|
|
`state.toml`),
|
|
2. removes exactly the ids it is about to stage from any pre-existing
|
|
`mods/packages`, which migrates a build directory made before the split
|
|
while leaving a player's own legacy packages for `migrate_legacy_root()`,
|
|
3. copies the framework's `mods/builtin/packages/<id>` then the title's
|
|
`<dir>/packages/<id>` into `mods/bundled/<id>` — in that order, so a title
|
|
may deliberately OVERRIDE a builtin at the same id and version (Tomba 2's
|
|
Italian runtime ships localized `psx.*` manifests exactly this way),
|
|
4. copies `<dir>/README.md` to `mods/README.md`, and
|
|
5. verifies the result with `runtime/psx_check_mod_catalog.cmake`.
|
|
|
|
Pass `PRELOADED_MODS_DIR NONE` to declare that a target intentionally ships no
|
|
game catalog. A target built with `COSIM` stages nothing: it has no launcher,
|
|
and it shares an output directory with the real runtime.
|
|
|
|
### Declining a framework builtin (`EXCLUDE_BUILTIN_MODS`)
|
|
|
|
Every package under the framework's `mods/builtin/packages` targets
|
|
`game_id = "*"`, so by default every title ships all of them. A title that does
|
|
not want one — because it ships its own replacement, or because the feature is
|
|
wrong for it — names it:
|
|
|
|
```cmake
|
|
psxrecomp_add_game_runtime(psx-runtime
|
|
...
|
|
PRELOADED_MODS_DIR "${CMAKE_CURRENT_SOURCE_DIR}/mods/preloaded"
|
|
# WipEout 3's own team-mark bezel replaces the generic file-picker one,
|
|
# and host-paced fast loading is not offered for it.
|
|
EXCLUDE_BUILTIN_MODS
|
|
psx.enhancement.fast-loading
|
|
psx.presentation.bezel
|
|
)
|
|
```
|
|
|
|
`EXCLUDE_BUILTIN_MODS` is accepted by both `psxrecomp_add_game_runtime()` and
|
|
`psxrecomp_add_runtime_target()` (the PGXP clone inherits it). An excluded
|
|
package is **absent, not hidden**, the same rule as the developer channel:
|
|
|
|
- it is never copied into `<exe-dir>/mods/bundled`, so the launcher cannot list
|
|
it and every release packager — which all ship the build's staged tree —
|
|
cannot ship it either;
|
|
- the build publishes the exclusions beside the catalog manifest as
|
|
`psx_mod_catalog_<target>.excluded.txt`. The build-time guard fails if an
|
|
excluded id turns up in `mods/bundled` anyway, and `tools/release_stage.py
|
|
stage-mods` both refuses such a catalog and stops counting an excluded
|
|
builtin as *missing* when a caller passes the framework's `mods/builtin` as a
|
|
`--mod-source`;
|
|
- the framework itself is unchanged: the package, its plugin and its C API stay
|
|
available to every other title.
|
|
|
|
Configure fails loudly when an excluded id is not a framework builtin (a typo
|
|
would otherwise leave the package shipping while the CMakeLists reads as if it
|
|
did not), and when the title's own catalog provides the same id — that is an
|
|
override, and excluding and overriding one id at once contradicts itself. The
|
|
selection lives in `runtime/psx_mod_catalog_select.cmake`; both it and the guard
|
|
are exercised by `runtime/tests/test_mod_catalog_layout.py`.
|
|
|
|
A player's `mods/state.toml` written by an earlier build may still name an
|
|
excluded package. That selection is dormant, not an error: see
|
|
[State and migration](#state-and-migration).
|
|
|
|
**Do NOT write your own `copy_directory` into `<exe-dir>/mods`.** Five titles
|
|
did, and the reason it is now a build error is worth stating: a hand-written
|
|
copy names the destination as a *string*, so when framework commit `4cc04be3`
|
|
renamed the staged catalog from `mods/packages` to `mods/bundled`, all five
|
|
kept staging into a directory nothing reads. Nothing failed to configure,
|
|
compile or link — the coupling has no compile-time or link-time consumer — and
|
|
the defect surfaced only when a release packager ran, in a different
|
|
repository, on a later day. Two guards now close that window:
|
|
|
|
* **configure time** — a project with packages under
|
|
`mods/preloaded/packages` that does not declare `PRELOADED_MODS_DIR` is a
|
|
`FATAL_ERROR`, naming the packages and the argument to add.
|
|
* **build time** — `psx_check_mod_catalog.cmake` runs as the *last* `POST_BUILD`
|
|
step (registered through `cmake_language(DEFER)`, so it lands after anything
|
|
the title registered) and fails the build if a declared package did not reach
|
|
`mods/bundled`, or if any package this build stages turned up under
|
|
`mods/packages`. It is also registered as the ctest
|
|
`psx_staged_mod_catalog_test`.
|
|
|
|
Both are exercised by `runtime/tests/test_mod_catalog_layout.py`.
|
|
|
|
The release packagers assert the same invariant one layer out:
|
|
`Add-ModCatalog` in `tools/release_overlay_stage.ps1` reads `mods/bundled` and
|
|
refuses to package when a package the *sources* define is missing from it. It
|
|
asserts that invariant rather than a hard-coded count, because a count
|
|
describes only one side of a catalog two repositories contribute to and goes
|
|
stale the moment either side gains a mod.
|
|
|
|
## Feature manifest
|
|
|
|
Write new manifests at the current format version, which is **7**. Older
|
|
versions stay readable so installed packages survive an update, and each
|
|
section below notes the version a field first required.
|
|
|
|
```toml
|
|
format_version = 7
|
|
id = "example.localization"
|
|
version = "1.2.0"
|
|
name = "Example Localization Pack"
|
|
author = "Example Author"
|
|
description = "Independent title and script features."
|
|
license = "MIT"
|
|
resolver = "declarative"
|
|
|
|
[[target]]
|
|
game_id = "SLUS-00000"
|
|
# Required for disc overlays. Use the digest of the supported stock image.
|
|
disc_sha256 = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
|
|
|
[[feature]]
|
|
id = "title-screen"
|
|
name = "Title Screen"
|
|
description = "Selects the title-screen artwork."
|
|
group = "Localization"
|
|
default_enabled = false
|
|
|
|
[[feature]]
|
|
id = "retranslation"
|
|
name = "Retranslation"
|
|
description = "Uses the revised English script."
|
|
group = "Localization"
|
|
|
|
[[option]]
|
|
feature = "title-screen"
|
|
id = "variant"
|
|
label = "Title artwork"
|
|
type = "choice"
|
|
default = "rockman"
|
|
|
|
[[option.choice]]
|
|
value = "mega-man"
|
|
label = "Mega Man X6 (USA)"
|
|
|
|
[[option.choice]]
|
|
value = "rockman"
|
|
label = "Rockman X6 (Japan)"
|
|
|
|
[[patch]]
|
|
feature = "title-screen"
|
|
target = "main_exe"
|
|
address = 0x80041234
|
|
expected = "2a 00 02 24"
|
|
replace = "0e 00 02 24"
|
|
when = { variant = "rockman" }
|
|
|
|
[[overlay]]
|
|
feature = "retranslation"
|
|
target = "disc_raw"
|
|
offset = 123456
|
|
file = "assets/retranslated-script.bin"
|
|
sha256 = "..."
|
|
# Optional additional guard over the same range in the stock image.
|
|
expected_sha256 = "..."
|
|
```
|
|
|
|
Every `[[option]]`, `[[patch]]`, and `[[overlay]]` in a feature-style manifest
|
|
must name its owning feature. Ambiguous operations are rejected.
|
|
|
|
Option types are `boolean`, `choice`, and bounded `integer`. Conditions are
|
|
feature-local: `when = { option = "value", ... }` requires every listed option
|
|
to match. The legacy `when_option`/`when_value` pair remains accepted for a
|
|
single condition.
|
|
|
|
A feature with `hidden = true` is left out of the launcher's lists while it is
|
|
disabled; while enabled it is listed so the player can turn it off. A title that
|
|
sets `[runtime] hide_hidden_mod_features = true` in `game.toml` never shows it:
|
|
the launcher does not list it (enabled or not), does not name it in the lobby
|
|
summary, "Enable all" / "Disable all" leave it alone, and a package whose every
|
|
feature is hidden is not listed under "Installed packages". Either way it runs
|
|
exactly as `default_enabled` and `mods/state.toml` say, so a hidden default-on
|
|
feature is simply active. (recomp-ui `launcher_mod_visibility.h`.)
|
|
|
|
## Bounded integer patches
|
|
|
|
Package format 2 can encode a bounded integer option directly into a guarded
|
|
write:
|
|
|
|
```toml
|
|
format_version = 2
|
|
|
|
[[feature]]
|
|
id = "starting-lives"
|
|
name = "Starting Lives"
|
|
|
|
[[option]]
|
|
feature = "starting-lives"
|
|
id = "count"
|
|
label = "Lives"
|
|
type = "integer"
|
|
min = 0
|
|
max = 99
|
|
step = 1
|
|
default = 2
|
|
|
|
[[patch]]
|
|
feature = "starting-lives"
|
|
target = "main_exe"
|
|
address = 0x8001DE64
|
|
expected = "02 00 02 24"
|
|
replace_from = { option = "count", encoding = "u16le", offset = 0 }
|
|
|
|
[[patch]]
|
|
feature = "starting-lives"
|
|
target = "main_exe"
|
|
address = 0x8001DE70
|
|
expected = "03 00 02 24"
|
|
replace_from = { option = "count", encoding = "u16le", offset = 0, addend = 1 }
|
|
```
|
|
|
|
`replace_from` and literal `replace` are mutually exclusive. The referenced
|
|
option must be a bounded integer owned by the same feature. The initial
|
|
encodings are `u8`, `u16le`, and `u32le`. `offset` selects a byte field inside
|
|
the expected guard and defaults to zero. Generated replacement bytes begin as
|
|
an exact copy of the expected bytes, then the encoded value replaces only that
|
|
field. This lets a MIPS immediate, for example, retain a guard and collision
|
|
claim over its complete instruction. `addend` is the only supported transform,
|
|
and the complete declared option range after that addend must fit the unsigned
|
|
encoding.
|
|
|
|
There is deliberately no host-endian encoding, signed inference, mask, shift,
|
|
scale, expression language, or partial-field merge. A package uses multiple
|
|
guarded `[[patch]]` entries when the same value has multiple destinations.
|
|
Generated bytes enter the ordinary pre-boot write plan, collision checks, and
|
|
fingerprint. A generated value identical to the stock guard is omitted as a
|
|
no-op, so an enabled stock-valued option does not claim or conflict on bytes it
|
|
does not change.
|
|
|
|
Integer values use canonical decimal text. Leading plus signs, redundant
|
|
leading zeroes, values outside the bounds, and values not aligned to `step` are
|
|
rejected.
|
|
|
|
## Ordered values and split MIPS immediates
|
|
|
|
Package format 3 adds feature-local ordering constraints for related integer
|
|
fields:
|
|
|
|
```toml
|
|
format_version = 3
|
|
|
|
[[constraint]]
|
|
feature = "rank-thresholds"
|
|
kind = "ordered_integer"
|
|
direction = "nondecreasing"
|
|
options = ["rank-c", "rank-b", "rank-a"]
|
|
```
|
|
|
|
All listed options must be integer options on that feature. Defaults must
|
|
satisfy the constraint. While a feature is enabled, an edit that would invert
|
|
the order is rejected with the neighboring option labels. Disabled features
|
|
may retain an incomplete or invalid draft, but cannot be enabled until it is
|
|
valid. `nonincreasing` is also supported.
|
|
|
|
Format 3 also provides a narrow, typed transform for constants constructed by
|
|
a linked MIPS `LUI`/`ORI` pair:
|
|
|
|
```toml
|
|
replace_from = {
|
|
option = "speed",
|
|
encoding = "mips_lui_ori_u32",
|
|
omit_when_default = true
|
|
}
|
|
```
|
|
|
|
The patch must target one aligned, fully guarded eight-byte `main_exe`
|
|
instruction pair. The loader verifies the opcodes and register linkage, then
|
|
places the raw high and low 16-bit halves into the two immediates. It does not
|
|
apply signed-`ADDIU` carry adjustment. `offset` and `addend` are not accepted
|
|
for this encoding.
|
|
|
|
`omit_when_default` suppresses the entire patch when the selected value equals
|
|
the option default. This models source tools whose declared default means
|
|
"make no writes," including cases where multiple guarded sites contain
|
|
different stock values. For any nondefault selection, every declared site
|
|
retains its collision claim even if one generated replacement happens to equal
|
|
its stock guard.
|
|
|
|
## Sparse fields and integer predicates
|
|
|
|
Package format 4 separates a patch's complete expected-byte guard from the
|
|
fields it owns and writes. This is for semantic records whose independently
|
|
configurable fields share one useful guard:
|
|
|
|
```toml
|
|
format_version = 4
|
|
|
|
[[option]]
|
|
feature = "saber-timing"
|
|
id = "frames"
|
|
label = "Frames"
|
|
type = "integer"
|
|
min = 0
|
|
max = 99
|
|
default = 2
|
|
|
|
# Positive values change only byte 0.
|
|
[[patch]]
|
|
feature = "saber-timing"
|
|
target = "main_exe"
|
|
address = 0x80077640
|
|
expected = "02 42 01 02"
|
|
fields = [
|
|
{ offset = 0, option = "frames", encoding = "u8" },
|
|
]
|
|
when_integer = { option = "frames", op = "gt", value = 0 }
|
|
|
|
# Zero has a narrow, explicitly declared compound representation.
|
|
[[patch]]
|
|
feature = "saber-timing"
|
|
target = "main_exe"
|
|
address = 0x80077640
|
|
expected = "02 42 01 02"
|
|
fields = [
|
|
{ offset = 0, replace = "01" },
|
|
{ offset = 2, replace = "00" },
|
|
]
|
|
when_integer = { option = "frames", op = "eq", value = 0 }
|
|
```
|
|
|
|
`fields` is mutually exclusive with `replace` and `replace_from`. Every field
|
|
has a nonnegative `offset` and exactly one payload form:
|
|
|
|
- `replace = "..."` supplies non-empty literal bytes; or
|
|
- `option` plus `encoding` uses a bounded integer option on the same feature,
|
|
with the optional checked `addend`.
|
|
|
|
Fields must fit inside `expected` and their owned byte ranges may not overlap.
|
|
The supported dynamic encodings are the existing `u8`, `u16le`, `u32le`, and
|
|
linked `mips_lui_ori_u32` forms. A sparse MIPS field owns only the two
|
|
immediate halfwords while the complete linked instruction pair remains
|
|
guarded; as with format 3, that encoding does not accept an addend.
|
|
|
|
Resolution omits individual fields whose generated payload already equals the
|
|
guard. If no field changes, the patch is a no-op. Otherwise the plan retains
|
|
the complete guard and the exact remaining owned ranges. Runtime guard
|
|
validation checks every byte of `expected` before applying any writes, while
|
|
collision detection and writing use only the owned fields. Thus two features
|
|
can safely own adjacent bytes in one record without either overwriting the
|
|
other. Overlapping guards must still agree on their expected bytes; conflicting
|
|
guards make the plan unsatisfiable and are rejected.
|
|
|
|
`when_integer` is a patch-level, feature-local predicate over one bounded
|
|
integer option. `op` is exactly one of `eq`, `ne`, `lt`, `le`, `gt`, or `ge`,
|
|
and `value` is an integer constant inside the option bounds. Equality
|
|
constants must also be selectable under the option's `step`. String-valued
|
|
`when` conditions and one `when_integer` predicate may coexist and are ANDed.
|
|
|
|
Sparse fields and integer predicates are still pre-boot plan construction.
|
|
They do not provide a general expression evaluator, masks, arithmetic beyond
|
|
the checked field addend, package code execution, or per-frame dispatch.
|
|
|
|
## Channels
|
|
|
|
A feature declares how finished it is. Format 6 puts `channel` on the
|
|
**feature**, not the package:
|
|
|
|
```toml
|
|
format_version = 6
|
|
id = "example.enhancements"
|
|
|
|
[[feature]]
|
|
id = "widescreen"
|
|
channel = "experimental" # ships, badged, default off
|
|
|
|
[[feature]]
|
|
id = "hook-trace"
|
|
channel = "developer" # absent from any release build
|
|
```
|
|
|
|
| Channel | Ships | In the launcher |
|
|
|---|---|---|
|
|
| `stable` (default) | yes | no tag — the absence is the stable case |
|
|
| `experimental` | yes | amber `EXP` tag, and a line saying it is unvalidated |
|
|
| `developer` | **no** | secondary-accent `DEV` tag; only ever visible on a local build |
|
|
|
|
An absent `channel` means `stable`. A package may still declare one, which its
|
|
features inherit unless they state their own — which is how a format-5 manifest
|
|
carrying a package-level `channel` keeps working unchanged.
|
|
|
|
**Why the feature and not the package.** A package is the installation and
|
|
**trust** boundary; how finished one of its features is has nothing to do with
|
|
that. When the marker sat on the package, a catalog holding one player-ready
|
|
feature and one developer instrument had to declare itself entirely
|
|
developer — so neither shipped, and the documented workaround was to split the
|
|
catalog into two packages. Channels per feature remove that trade.
|
|
|
|
**"Developer does not ship" means absent, not hidden.** Two mechanisms, one per
|
|
catalog root:
|
|
|
|
- **`bundled/` is filtered when it is staged.** `tools/mod_channel_filter.py`
|
|
emits a manifest without the developer features and without the `[[option]]`,
|
|
`[[patch]]`, `[[overlay]]`, `[[plugin]]`, `[[resource]]`, `[[constraint]]` and
|
|
`[[requirement]]` entries that only served them; a package whose every feature is developer has
|
|
its directory removed. This is generation, not rewriting: the staged catalog
|
|
is build output and the author's manifest in the repo is never touched.
|
|
- **`installed/` is refused at load.** A third-party archive is never modified,
|
|
so the runtime declines to surface developer features from one instead.
|
|
|
|
The runtime gate is the build definition `PSX_MOD_DEVELOPER_CHANNEL`, which
|
|
`runtime.cmake` sets for a local build and clears under `$CI`. A contributor
|
|
reaches developer features by cloning the repo and building; a release build
|
|
carries neither the features nor their operations, so a stale `state.toml`
|
|
naming one cannot reach them either.
|
|
|
|
Packaging defaults the exclusion from `$CI` — on under any CI provider, off
|
|
locally. `--exclude-dev-mods` / `--include-dev-mods` (or `EXCLUDE_DEV_MODS=0|1`)
|
|
override it, and `project_studio build export --exclude-dev-mods` reproduces
|
|
what a release would contain.
|
|
|
|
## Trusted static plugins
|
|
|
|
Package format 5 can activate a game-owned plugin that is already statically
|
|
linked into the executable:
|
|
|
|
```toml
|
|
format_version = 5
|
|
|
|
[[feature]]
|
|
id = "warp-debug-menu"
|
|
name = "Warp Debug Menu"
|
|
|
|
[[plugin]]
|
|
feature = "warp-debug-menu"
|
|
id = "example.warp-debug"
|
|
```
|
|
|
|
The plugin id is a stable registry key, not a library path or symbol name. The
|
|
package archive supplies no native code. Resolution fails before launch when
|
|
an enabled plugin has no registered implementation or when two features claim
|
|
the same plugin id. Active plugin identities and owners participate in the
|
|
canonical plan fingerprint.
|
|
|
|
An implementation may register an activation callback, a deterministic
|
|
guest-VBlank callback, function-entry hooks, or any combination under the same
|
|
id. A function-entry hook (`psx_mod_register_function_entry_plugin`) runs at the
|
|
top of a generated function the game config lists in
|
|
`[recompiler] mod_function_entry_funcs`, and at every interpreted entry to the
|
|
same address (segment bits ignored), so the backend running the page does not
|
|
matter. Like the other kinds it satisfies a manifest `[[plugin]]` and runs only
|
|
while the resolved plan activates its id: the active hooks are flattened into an
|
|
address table at plugin activation, and a plan change drops them until the next
|
|
activation. Activation runs after the
|
|
launcher's final mod-plan commit and before renderer/window initialization; it
|
|
is appropriate for a game-owned mod that selects a fixed display aspect or
|
|
another pre-boot host feature. VBlank callbacks run from the emulated GPU
|
|
VBlank event, independent of host presentation, pacing, turbo, or skipped
|
|
frames. Trusted callbacks receive only the narrow C services exposed by
|
|
`runtime/include/mod_plugins.h`. Games should continue to use declarative
|
|
patches and overlays when those operations are sufficient.
|
|
|
|
### Session starts and mod-owned state
|
|
|
|
What a plugin sets up should last only for a session whose resolved plan
|
|
activates it. Function-entry hooks follow that rule by construction: the commit
|
|
and the netplay clear empty the hook table, and only activation rebuilds it, so
|
|
a hook never runs in a session that did not activate its id (including a
|
|
netplay session, which clears the plan). The host state a plugin changes
|
|
through the `psx_mod_*` setters is process-wide, so the runtime resets it at
|
|
every session start instead.
|
|
|
|
An offline session ends the process when the player closes the game. The one
|
|
in-process second session is the lobby rematch: a netplay match launched from
|
|
the lobby returns to the lobby launcher when it ends, and the next launch from
|
|
there, netplay or offline, re-enters the emulator in the same process. The
|
|
session before a rematch is therefore always a netplay match, which ran with
|
|
the plan cleared, so no plugin activated in it.
|
|
|
|
The reset runs immediately before activation in the first session, and on the
|
|
rematch path after its commit or netplay clear. What it fixes today is the
|
|
netplay local viewport's Fit and fixed aspect carrying from a match into an
|
|
offline rematch. The rest of the table is defensive, for the state listed here.
|
|
|
|
| State | Setter | Reset to | When |
|
|
|---|---|---|---|
|
|
| Fit / capped adaptive aspect | `psx_mod_set_adaptive_display_aspect` | off, cap 16:9 | every session |
|
|
| World-scene predicate | `psx_mod_set_world_scene_predicate` | NULL | every session |
|
|
| Retained-scene predicate | `psx_mod_set_retained_scene_predicate` | NULL | every session |
|
|
| Adaptive backdrop preload | `psx_mod_set_adaptive_backdrop_preload` | 0 | every session |
|
|
| Bezel artwork | `psx_mod_set_bezel_artwork` | none (see below) | every session |
|
|
| Frame-interpolation blend mode | `psx_mod_set_frame_interpolation_blend` | default | every session |
|
|
| Native VBlank pacing and its rate | `psx_mod_set_native_vblank_rate` | off, 0 | every session |
|
|
| Frame period, if native VBlank pacing was on | `psx_mod_set_native_vblank_rate` | first-session value | later sessions |
|
|
| Frame interpolation and its rate | `psx_mod_set_frame_interpolation` | first-session value | later sessions |
|
|
| Vsync forced off | `psx_mod_set_frame_interpolation`, `psx_mod_set_native_vblank_rate` | first-session value | later sessions |
|
|
| Automatic FMV skipping | `psx_mod_set_auto_skip_fmv` | first-session value | later sessions |
|
|
| 8 MiB main RAM request | `psx_mod_set_main_ram_8mb` | retail 2 MiB | later sessions |
|
|
| Texture-bank resolver and batching | `psx_mod_set_texture_bank_resolver`, `psx_mod_set_texture_bank_batching` | NULL, off | later sessions |
|
|
|
|
"First-session value" is what settings, environment overrides (such as
|
|
`PSX_VSYNC`) and the launcher resolved before the process's first activation.
|
|
The first call records those values and changes nothing that is not already at
|
|
its initial value, so the first session, and every run that never
|
|
soft-returns, behaves exactly as without the reset. The RAM request matters on
|
|
a rematch because `memory_init()` latches the requested geometry again at every
|
|
boot, including the rematch's.
|
|
|
|
Bezel artwork has two parts. The reset clears the artwork path, so the next
|
|
session start loads nothing unless its own activation selects artwork again.
|
|
The OpenGL renderer drops the loaded texture itself when it shuts down, which
|
|
happens when a session ends by returning to the lobby (and at process exit),
|
|
so a rematch never draws the previous session's artwork in its new context.
|
|
|
|
Not reset, and why:
|
|
|
|
- The **fixed display aspect** (`psx_mod_set_fixed_display_aspect`) and the
|
|
**renderer** (OpenGL, which `psx_mod_set_frame_interpolation` and
|
|
`psx_mod_set_bezel_artwork` force) are launcher controls. A rematch takes
|
|
both from the lobby launcher, which is seeded from the live values. For the
|
|
aspect, the rematch path then re-applies the Settings clamp (4:3, since
|
|
widescreen is mod-owned) before the netplay local viewport, so a match's
|
|
16:9 or 21:9 does not carry into an offline rematch. A renderer forced by a
|
|
plugin would be carried the same way, but no plugin activates in the session
|
|
before a rematch.
|
|
- **Guest memory, GPU-DMA memory, texture-packet arenas and defined texture
|
|
banks** (`psx_mod_alloc_guest_memory`, `psx_mod_alloc_gpu_dma_memory`,
|
|
`psx_mod_alloc_texture_packet_memory`, `psx_mod_define_texture_bank`) live
|
|
for the process. A bank ID is read only from packets in a plugin's own arena,
|
|
which stock game code does not use.
|
|
|
|
**A rematch is not a full session start.** It re-enters below the first
|
|
session's setup block, so it does not run `mod_runtime_activate_plugins()` and
|
|
does not reset controller overrides and policies, load acceleration or disc
|
|
speed (the first session resets those before activation). An offline rematch
|
|
with mods enabled therefore applies the plan's main-EXE and disc patches and
|
|
runs its VBlank callbacks, but no activation callback runs and no
|
|
function-entry hook runs. A VBlank callback must not assume its plugin's
|
|
activation ran in the same session. If the rematch path ever activates
|
|
plugins, the reset must run before that activation.
|
|
|
|
A plugin should establish what it needs in its activation callback and not rely
|
|
on state from an earlier session; its own static variables are its
|
|
responsibility.
|
|
|
|
`psx_mod_set_load_acceleration(multiplier, release_frames)` is the narrow
|
|
pre-boot service for a game-owned fast-loading feature. It changes host
|
|
wall-clock pacing only: guest VBlanks, CD deadlines, interrupts, callbacks, and
|
|
game logic still execute. Multipliers 2 through 16 are bounded choices; zero
|
|
selects uncapped host speed. A zero-frame release stops acceleration as soon as
|
|
the sustained-load predicate clears, which is appropriate for timing-sensitive
|
|
or speedrun-oriented packages.
|
|
|
|
`psx_mod_set_disc_speed(divisor, instant_max_per_frame)` is the guest-visible
|
|
alternative. Divisors 2 and 4 shorten emulated CD deadlines; zero selects the
|
|
bounded instant scheduler. Because this changes interrupt timing, packages
|
|
should label it experimental and make it mutually exclusive with host-only
|
|
load acceleration (normally as choices in one default-off feature).
|
|
|
|
## Native operations
|
|
|
|
`main_exe` writes use PSX guest virtual addresses. Expected bytes are checked
|
|
after the BIOS loads the executable, then the complete write plan is applied
|
|
before its entry point. Changed executable ranges use the existing dirty-RAM
|
|
interpreter/native-overlay machinery; untouched functions remain on the static
|
|
native path.
|
|
|
|
Small `disc_raw` and `disc_user` patches are equal-length guarded writes and may
|
|
not cross a sector boundary:
|
|
|
|
- `disc_raw` offsets use `lba * 2352 + byte_in_sector`.
|
|
- `disc_user` offsets use `lba * 2048 + byte_in_sector`.
|
|
|
|
File-backed `[[overlay]]` operations are intended for large assets and may span
|
|
any number of sectors. Their paths must remain inside the archive. Payload
|
|
size and SHA-256 are verified while scanning, but disabled payloads are not
|
|
retained in memory. Enabled payloads are loaded and reverified during
|
|
resolution, then indexed by target and LBA before boot. A CD read performs a
|
|
direct indexed lookup rather than scanning every installed mod.
|
|
|
|
Feature disc overlays require an exact `disc_sha256` on every target entry.
|
|
`expected_sha256` can additionally guard the replaced stock range.
|
|
|
|
## State and migration
|
|
|
|
`mods/state.toml` format 2 stores selected package versions separately from
|
|
per-feature enabled states and values:
|
|
|
|
```toml
|
|
format_version = 2
|
|
|
|
[[package]]
|
|
id = "example.localization"
|
|
version = "1.2.0"
|
|
|
|
[[feature]]
|
|
package_id = "example.localization"
|
|
id = "title-screen"
|
|
enabled = true
|
|
|
|
[feature.values]
|
|
variant = "rockman"
|
|
|
|
[feature.resources]
|
|
artwork = "C:/Users/You/Pictures/example-bezel.png"
|
|
```
|
|
|
|
State format 1 and package-only manifests remain readable as a migration aid.
|
|
They appear through one synthetic legacy feature. New packages should use
|
|
explicit features.
|
|
|
|
**Selections for packages the catalog does not hold are dormant.** State can
|
|
outlive the package it names: the player deleted an installed archive, a
|
|
release build stripped a developer-only package, or the title now declines a
|
|
framework builtin with `EXCLUDE_BUILTIN_MODS`. Resolution only visits packages
|
|
that are present, so such a selection contributes nothing to the plan (and
|
|
does not change its fingerprint), and it is not a launch error — the Mods page
|
|
no longer lists the package, so the player would have no way to clear one.
|
|
`save_state()` keeps the entry verbatim, so the choice applies again if the
|
|
package ever returns, and the runtime names each one at startup
|
|
(`mod selection kept but inactive: <id> is not in this build's mod catalog`).
|
|
A selection pinned to a *version* that is missing while other versions of the
|
|
package are present remains an error, because the Mods page can fix that one.
|
|
|
|
The old `derived_disc` VCDIFF mechanism is legacy conversion scaffolding only.
|
|
Feature-style manifests reject it. It is not a product mod primitive, fallback,
|
|
or image-selection workflow; patched discs may be used offline as parity
|
|
oracles while converting known mods to native operations.
|
|
|
|
## Resolution and diagnostics
|
|
|
|
Before boot, the manager:
|
|
|
|
1. verifies the selected stock game and revision;
|
|
2. expands only enabled features and their selected options, plus every
|
|
feature an active `[[requirement]]` activates (see
|
|
[Implicit requirements](#implicit-requirements-across-packages));
|
|
3. orders active packages deterministically by dependencies;
|
|
4. verifies enabled payloads and operation bounds;
|
|
5. collision-checks the complete owned byte-range plan and guard
|
|
compatibility;
|
|
6. coalesces only truly identical target/range/expected/replacement writes or
|
|
identical overlays; and
|
|
7. produces a canonical SHA-256 plan fingerprint.
|
|
|
|
Incompatible overlaps fail before launch. Structured diagnostics identify both
|
|
`(package, feature)` owners and the exact contested target range. The launcher
|
|
marks both feature rows and lets the user decide what to disable. It never
|
|
silently chooses a winner.
|
|
|
|
Operation boundaries are not semantic boundaries. Legacy full-record writes
|
|
compose when both their expected and replacement bytes agree throughout the
|
|
owned intersection. Format-4 sparse patches collide only on declared owned
|
|
fields, while their complete guards must remain mutually compatible.
|
|
Partially overlapping overlays compose when their replacement payload bytes
|
|
agree. A differing owned byte or incompatible guard produces a diagnostic at
|
|
that exact location. Exact duplicate operations may be coalesced.
|
|
|
|
Package-level dependencies and conflicts are reserved for actual implementation
|
|
relationships. Mutually exclusive choices such as US versus Japanese artwork
|
|
belong inside one feature as option values. A relationship that holds only for
|
|
some selections of one feature is a `[[requirement]]`, not a dependency.
|
|
|
|
## Implicit requirements across packages
|
|
|
|
Package format 7 lets a feature, while a condition on its own options holds,
|
|
need a feature of **another** package:
|
|
|
|
```toml
|
|
format_version = 7
|
|
id = "wipeout3.enhancement.framerate"
|
|
|
|
[[requirement]]
|
|
feature = "framerate" # the requiring feature (owner)
|
|
package = "psx.enhancement.8mb-ram" # the package that provides it
|
|
requires_feature = "8mb-ram" # its feature to activate
|
|
when = { extras = "enhanced" } # same condition syntax as [[plugin]]
|
|
|
|
[[requirement]]
|
|
feature = "framerate"
|
|
package = "psx.enhancement.8mb-ram"
|
|
requires_feature = "8mb-ram"
|
|
when = { extras = "full" }
|
|
```
|
|
|
|
`when` (or `when_option`/`when_value`) is the same feature-local condition
|
|
plugins and overlays use; every listed option must match, so a requirement that
|
|
holds for several values is written once per value. Omit it for a requirement
|
|
that holds whenever the feature is enabled. `version` optionally restricts the
|
|
provider with the `[[dependency]]` range syntax (`"*"` by default). Unknown
|
|
fields are rejected, and so is a requirement naming its own package: a feature of
|
|
the same package is a `requires_feature` `[[constraint]]`.
|
|
|
|
The two existing mechanisms could not express this. `[[dependency]]` is
|
|
package-level and unconditional, so WipEout 3's "NTSC / PAL Mode" would need
|
|
8 MB RAM even with Extras = None; a `requires_feature` constraint cannot name
|
|
another package.
|
|
|
|
**Resolution.** While the requiring feature is enabled and its condition holds,
|
|
`resolve()` activates the required feature for the session:
|
|
|
|
- even when it is `hidden`, default-off, or explicitly disabled in
|
|
`mods/state.toml` -- the player chose the requiring option, and the requiring
|
|
option cannot run without it;
|
|
- recursively: a derived feature's own `[[requirement]]`s and in-package
|
|
`requires_feature` constraints are derived too, to a fixed point;
|
|
- **without writing it to `state.toml`**. A derived activation is not a player
|
|
choice, so `save_state()` persists only what the player selected, and turning
|
|
the requiring option back off leaves nothing behind. `feature_enabled()` keeps
|
|
reporting the player's choice (the launcher's checkbox, so a hidden required
|
|
feature stays hidden); `feature_implicitly_enabled()` reports the derived one.
|
|
|
|
The plan lists each derived feature in `ModResolution::implicit_features` with
|
|
what required it, plans with and without the derivation have different
|
|
fingerprints, and a committed plan's plugins read option values from
|
|
`ModResolution::selections` -- the selection the plan was actually built from.
|
|
The runtime names every derived feature at launch:
|
|
|
|
```text
|
|
psxrecomp: mod feature psx.enhancement.8mb-ram/8mb-ram activated implicitly (required by wipeout3.enhancement.framerate/framerate)
|
|
```
|
|
|
|
**An unmet requirement fails loudly.** When the provider is absent from the
|
|
catalog (removed, stripped from a release, or declined with
|
|
`EXCLUDE_BUILTIN_MODS`), the selected version is missing, the version is out of
|
|
range, or the provider has no such feature, the plan is rejected before launch:
|
|
|
|
```text
|
|
wipeout3.enhancement.framerate/framerate requires psx.enhancement.8mb-ram/8mb-ram, but package psx.enhancement.8mb-ram is not in this build's mod catalog
|
|
```
|
|
|
|
It is also a diagnostic on the requiring feature, so the launcher marks that
|
|
row. The requiring selection never runs without what it requires -- an 8 MB
|
|
build on 2 MB RAM is exactly the failure this prevents. A package an active
|
|
requirement uses cannot be removed from the Mods manager.
|
|
|
|
AOT profiles pin active requirements the way they pin plugins:
|
|
`tools/mod_package_images.py` reports each active one as `"<package>/<feature>"`
|
|
and a `mod_packages` entry must list the exact set under `requirements`.
|
|
|
|
The launcher needs no change for a hidden required feature: it lists a hidden
|
|
feature only when the player enabled it, and the player did not. A *visible*
|
|
required feature still shows the player's own checkbox state while it is
|
|
derived; showing "on, required by X" needs a launcher field that does not exist
|
|
yet.
|
|
|
|
## Trusted adapters and archive safety
|
|
|
|
### Owner-selected resources
|
|
|
|
Format-5 packages may declare feature-owned resources that the launcher renders
|
|
with its native file/folder picker:
|
|
|
|
```toml
|
|
[[resource]]
|
|
feature = "bezel"
|
|
id = "artwork"
|
|
label = "Bezel image"
|
|
description = "Select an image to draw behind the game frame."
|
|
format = "file"
|
|
file_patterns = "*.png,*.jpg,*.jpeg,*.bmp"
|
|
file_description = "Image files"
|
|
required = false
|
|
```
|
|
|
|
Resources are paths selected by the player and persisted in `mods/state.toml`;
|
|
they are not copied into the package. Optional resources with no selected path
|
|
are omitted from the committed plan. Required resources reject launch while the
|
|
feature is enabled and unset.
|
|
|
|
`resolver = "builtin:<id>"` selects a resolver statically registered by the
|
|
game. Format-5 plugin ids likewise select only statically registered
|
|
implementations. Packages cannot load arbitrary native code or select
|
|
arbitrary symbols.
|
|
|
|
The installer accepts stored or DEFLATE-compressed ZIP entries, validates CRCs,
|
|
rejects encrypted entries and unsafe or absolute paths, limits archives to 4096
|
|
files and 256 MiB expanded size, stages extraction, validates the manifest, and
|
|
publishes the version atomically.
|
|
|
|
### Presentation bezel packages
|
|
|
|
The framework registers `psx.bezel`, a trusted presentation plugin for OpenGL
|
|
margin artwork. The built-in package declares an optional `artwork` image
|
|
resource; when the feature is enabled and the player has selected an image, the
|
|
runtime draws that image behind the game frame before presenting the normal 4:3
|
|
or widescreen content.
|
|
|
|
The built-in package `psx.presentation.bezel` targets every game but defaults to
|
|
off, so the default presentation is unchanged: letterbox and pillarbox margins
|
|
remain black. Enabling the feature without choosing artwork is also a no-op.
|
|
The package supplies only the declaration and trusted plugin selection; archives
|
|
still cannot load native code.
|
|
|
|
A title's own trusted plugin can call `psx_mod_set_bezel_artwork(path)` directly
|
|
to ship its artwork. An absolute path (such as the built-in package's selected
|
|
resource) is used as-is; a relative path like `"bezels/qirex.png"` is resolved
|
|
against the executable's directory when the artwork loads, never the current
|
|
working directory, so artwork the title stages beside its binary is found
|
|
however the game was launched.
|
|
|
|
### Retained-scene loading presentation (native-wide opt-in)
|
|
|
|
A trusted game plugin can register
|
|
`psx_mod_set_retained_scene_predicate(predicate)` from `mod_plugins.h` when the
|
|
game keeps displaying its previous framebuffer while loading. The cheap, pure
|
|
emulation-thread callback returns `PSX_MOD_SCENE_HOLD` while that same scene is
|
|
retained. It must not call presentation APIs recursively. Passing NULL removes
|
|
the opt-in. Without registration, existing presentation behavior is unchanged.
|
|
|
|
In native-wide mode this holds the previous wide/4:3 classification even if a
|
|
game-state flag or absent GTE activity would normally classify loading as 2D.
|
|
It does not force menus wide, stretch artwork, change guest rendering or memory,
|
|
or override the FMV veto. Return `PSX_MOD_SCENE_RELEASE` when a new scene replaces
|
|
the retained image, so ordinary classification resumes. For double-buffered
|
|
games whose draw-ready signal precedes the actual display flip, return
|
|
`PSX_MOD_SCENE_UNTIL_FLIP`: the prior hold ends only when the displayed VRAM
|
|
origin changes. Without a prior HOLD it behaves like RELEASE. Do not use
|
|
UNTIL_FLIP for in-place image replacement. Crash's experimental
|
|
adaptive feature uses its pending level transition and draw-skip globals for
|
|
this; these game-specific addresses do not belong in the framework.
|
|
|
|
GPU reset and savestate restore discard this host-only history. A save loaded
|
|
directly into a frozen loading frame cannot recreate wide reveal strips absent
|
|
from the canonical saved framebuffer. `ws_scene_hold_test` covers long holds,
|
|
menu release, delayed buffer flips, retained 4:3 scenes, FMV and timeline reset.
|