mirror of
https://github.com/RetroPortingToolKit/psxrecomp
synced 2026-10-28 02:56:36 -04:00
407 lines
15 KiB
Markdown
407 lines
15 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.
|
|
|
|
## Feature manifest
|
|
|
|
```toml
|
|
format_version = 1
|
|
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.
|
|
|
|
## 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.
|
|
|
|
## 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, or both under the same id. 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.
|
|
|
|
`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"
|
|
```
|
|
|
|
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.
|
|
|
|
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;
|
|
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.
|
|
|
|
## Trusted adapters and archive safety
|
|
|
|
`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.
|