Document manual PSX mod authoring against the current package format

This commit is contained in:
Matthew Stanley
2026-10-20 15:43:47 -07:00
parent fe8d937d69
commit 958ae0e396
3 changed files with 488 additions and 5 deletions
+6 -1
View File
@@ -233,7 +233,8 @@ These are the 3 most important folders to be aware of:
| **Netplay** (rollback, SFU/ICE, dual-raster, disc gates) | [`docs/NETPLAY.md`](docs/NETPLAY.md) |
| Bundled-release CI template | [`docs/ci/templates/game-release.yml`](docs/ci/templates/game-release.yml) |
| Local Generate & rebuild CLI | [`docs/LOCAL_CODEGEN_SDK.md`](docs/LOCAL_CODEGEN_SDK.md) |
| Mods | [`docs/MOD_PACKAGES.md`](docs/MOD_PACKAGES.md) |
| Make mods by hand | [`psxmod.md`](psxmod.md) |
| Mod schema and integration | [`docs/MOD_PACKAGES.md`](docs/MOD_PACKAGES.md) |
| Widescreen / native-wide | [`docs/WIDESCREEN.md`](docs/WIDESCREEN.md) |
| Enhancement tier (PGXP, renderers, load accel) | [`docs/ENHANCEMENTS.md`](docs/ENHANCEMENTS.md) |
| TCP debug command reference | [`docs/TCP_COMMANDS.md`](docs/TCP_COMMANDS.md) |
@@ -301,6 +302,10 @@ See [`docs/WIDESCREEN.md`](docs/WIDESCREEN.md) for the per-game configuration.
## Mods
To make a package yourself, start with [`psxmod.md`](psxmod.md): a complete
first package, guarded patches, asset overlays, packing, and testing with
ordinary tools. No AI tools are required.
Mods are **versioned packages**, not patched discs. A `.psxmod` is an
installation, provenance and trust boundary; each package contributes any number
of independently toggleable **features**, and enabling one never silently
+8 -4
View File
@@ -1,12 +1,16 @@
# PSXRecomp mod packages and features
For a hands-on guide to making mods with ordinary tools, start with
[`../psxmod.md`](../psxmod.md).
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.
Feature identity is always `(package_id, feature_id)`. Player selections are
independent. Explicit `[[requirement]]` declarations can additionally activate
required features for a session without changing saved player selections.
The player selects a verified stock BIN/CUE. Resolution produces guarded native
operations and sparse disc overlays without rewriting or replacing that stock
@@ -173,12 +177,12 @@ stale the moment either side gains a mod.
## Feature manifest
Write new manifests at the current format version, which is **7**. Older
Write new manifests at the current format version, which is **10**. 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
format_version = 10
id = "example.localization"
version = "1.2.0"
name = "Example Localization Pack"
+474
View File
@@ -0,0 +1,474 @@
# Making a `.psxmod` by hand
You can make PSXRecomp mods with a text editor, a ZIP packer, and the usual
tools for editing or understanding game data. No AI service, account, or
generated assets are required. Many mods need no C/C++ and no game rebuild.
The extension is **`.psxmod`** (singular). It is a ZIP with a `manifest.toml`
at its root. The manifest describes the mod, launcher controls, and supported
operations. The game keeps using the player's stock disc; mod operations are
applied in memory or when reading sectors.
Check the target game's pinned framework version. This guide describes
manifest format **10**; an older game release may need an update to read it.
- [Choose the right kind of mod](#choose-the-right-kind-of-mod)
- [First package: your own bezel artwork](#first-package-your-own-bezel-artwork)
- [Make a guarded gameplay patch](#make-a-guarded-gameplay-patch)
- [Add options](#add-options)
- [Replace disc data with an overlay](#replace-disc-data-with-an-overlay)
- [When you need C or C++](#when-you-need-c-or-c)
- [Test and distribute](#test-and-distribute)
- [Troubleshooting](#troubleshooting)
## Choose the right kind of mod
| Goal | Mechanism | Rebuild the game? |
| --- | --- | --- |
| Change known bytes in the main executable | Guarded `[[patch]]` with `target = "main_exe"` | Usually no |
| Change a few known bytes on the disc | Guarded `disc_raw` or `disc_user` patch | Usually no |
| Replace a larger range of asset data | File-backed `[[overlay]]` | Usually no |
| Use an existing host feature | `[[plugin]]` naming an implementation the game already contains | No, if that implementation is present |
| Add new host logic or a recurring gameplay callback | Write and statically link a trusted C/C++ plugin | Yes |
An overlay replaces bytes at a particular location. It is not an automatic
"replace an ISO file by filename" service. Formats, pointers, compression,
and file sizes still matter. A package also cannot load a DLL, compile C, or
invent a plugin implementation by naming it.
A **package** is what you install and version. Its **features** are independent
switches; each feature can have **options**. An operation names its owning
feature and may have conditions on that feature's options. The loader resolves
the whole enabled set, checks compatibility and conflicting byte ranges, and
produces a plan before boot. Runtime guards verify original bytes when the
corresponding executable or disc data is encountered.
## First package: your own bezel artwork
This example uses the existing `psx.bezel` plugin to draw a player-selected
image behind the game frame in OpenGL margins. It requires a game build that
contains that plugin and supports format 10. It does not require a compiler.
You can draw the image yourself with any ordinary image editor.
### 1. Create a source folder
Make a folder named `my-bezel` containing `manifest.toml` and `README.txt`.
Use UTF-8 text and save the manifest with that exact name, not
`manifest.toml.txt`. Python **3.11 or newer** can run all commands in this guide.
Save this complete manifest:
```toml
format_version = 10
id = "yourname.my-bezel"
version = "1.0.0"
name = "My Bezel"
author = "Your Name"
description = "Show my chosen artwork behind the game frame."
license = "MIT"
resolver = "declarative"
save_compatibility = "shared"
[[target]]
game_id = "*"
[[feature]]
id = "bezel"
name = "My Bezel"
description = "Choose an image, then launch with an OpenGL renderer."
group = "Visual"
default_enabled = false
[[resource]]
feature = "bezel"
id = "artwork"
label = "Bezel image"
format = "file"
file_patterns = "*.png,*.jpg,*.jpeg,*.bmp"
file_description = "Image files"
required = false
[[plugin]]
feature = "bezel"
id = "psx.bezel"
```
Here `game_id = "*"` is appropriate because the existing bezel implementation
is a generic presentation feature. Game-specific patches should target exact
game IDs and supported revisions instead.
Use your own package namespace when publishing. Package and feature IDs are
machine identifiers: use lowercase letters, numbers, dots, underscores, and
hyphens. Names and labels are the human-facing text. `format_version` is the
schema version, while `version` is your mod's version. Keep the package ID
stable and increase the version when distributing changes.
The image is a **resource**: the player chooses its path, and the package does
not contain or copy it. A plugin must understand the resource ID; this plugin
specifically looks for `artwork`. With no image selected, it draws nothing.
If you want packaged artwork loaded automatically, you need an implementation
that supports that behavior; adding an image to the ZIP is not sufficient.
### 2. Check and pack it
Put target-build requirements and instructions in `README.txt`. From the
**framework repository root**, run:
```sh
python -c "import pathlib,tomllib; tomllib.loads(pathlib.Path('my-bezel/manifest.toml').read_text(encoding='utf-8')); print('TOML syntax OK')"
python tools/psxmod_pack.py my-bezel dist/my-bezel-1.0.0.psxmod
python -m zipfile -l dist/my-bezel-1.0.0.psxmod
python -m zipfile -t dist/my-bezel-1.0.0.psxmod
```
The packer uses only Python's standard library. It packages files in a stable
order with fixed ZIP timestamps. It checks that a manifest exists, but does
**not** validate the manifest schema or the mod's behavior. Keep its output
outside the source folder: it includes every file under that folder.
The archive must look like this:
```text
my-bezel-1.0.0.psxmod
manifest.toml
README.txt
```
A graphical ZIP utility works too: archive the folder's **contents**, use
ordinary ZIP storage or DEFLATE without encryption, and rename `.zip` to
`.psxmod`. The loader accepts up to 4096 entries and 256 MiB expanded. Absolute
paths and `..` paths are rejected.
### 3. Install and try it
Open the target game's launcher and its **Mods** package manager, install the
archive, enable **My Bezel**, select your image, and launch with OpenGL. Use
a window/aspect that leaves margins so there is somewhere to see the artwork.
Disable any other enabled bezel feature: two features claiming `psx.bezel`
conflict instead of taking turns.
Disable your feature and relaunch to verify the normal presentation returns.
Changes generally require a new launch. Management button labels can vary
with the game's pinned recomp-ui version.
## Make a guarded gameplay patch
A guarded patch means "at this location, expect these original bytes, then
replace them with these bytes." The guard catches a wrong revision or a
mistaken address. It is not a substitute for understanding the change.
### 1. Find and verify the change
Use the game's symbols, disassembly, debugger, and existing mod source to find
the relevant code or data. Record:
- The exact game ID, disc revision, and game build.
- The location and whether it is a guest address or a disc-stream offset.
- The original and replacement bytes, with a written explanation.
- When the code/data is loaded and what should change during play.
For example, suppose you have verified a MIPS instruction that initializes a
value to 3: the word `0x24020003` (`addiu v0, zero, 3`). PS1 words are
little-endian, so its bytes are `03 00 02 24`. Changing the immediate to 5
gives `05 00 02 24`. That only means "starting lives" if you have established
that the game uses the result that way.
### 2. Write the package
This is a complete **template**, not a cheat for a particular game. Replace
the target placeholders and the illustrative address with surveyed values.
Do not paste the address into an unrelated title.
```toml
format_version = 10
id = "yourname.starting-lives"
version = "1.0.0"
name = "Starting Lives"
author = "Your Name"
description = "Start a new game with five lives."
resolver = "declarative"
save_compatibility = "shared"
[[target]]
game_id = "REPLACE_WITH_EXACT_GAME_ID"
disc_sha256 = "REPLACE_WITH_STOCK_DISC_SHA256"
[[feature]]
id = "starting-lives"
name = "Starting Lives"
description = "Apply the surveyed new-game lives change."
group = "Gameplay"
default_enabled = false
[[patch]]
feature = "starting-lives"
target = "main_exe"
address = 0x80012340
expected = "03 00 02 24"
replace = "05 00 02 24"
```
`main_exe` uses a **guest virtual address**, not an offset in a BIN, ISO, or
PS-X EXE. These writes are applied after the BIOS loads the main executable
and before its entry point. A runtime variable that is initialized later can
overwrite your write; a streamed code overlay is not automatically part of
the main executable. Choose the operation that matches the actual load path.
For a standard PS-X EXE, the payload begins at file offset `0x800`, and its
load address is the little-endian word at header offset `0x18`. For a location
inside that payload:
```text
guest address = load address + (PS-X EXE file offset - 0x800)
```
This formula does not apply to arbitrary files or runtime-relocated overlays.
Confirm the address in a debugger. Guard a complete instruction when changing
an instruction, keep ordinary replacements equal in length to `expected`, and
account for MIPS delay slots and register use when editing logic.
### 3. Get the right hashes
Copy known target identities from the game's manifests and source where
possible. `exe_sha256`, when used, hashes the loose **PS-X EXE file**, not the
native PC executable; some releases do not carry that loose file, so requiring
it can make an otherwise correct package incompatible with those releases.
For a typical BIN/CUE, `disc_sha256` hashes the resolved BIN data track,
**not the CUE text**. For CHD, the current runtime decodes the disc and hashes
its complete sequence of raw 2352-byte sectors; hashing the compressed CHD
file will give the wrong identity. Check the target title's pinned
`mod_runtime_commit()` and disc-resolution code, especially for multiple
tracks and older releases. Do not guess a hash from the disc's display name.
For a BIN data file whose identity you have confirmed, this command prints its
hash without loading the whole disc into memory:
```sh
python -c "import hashlib; f=open('game.bin','rb'); print(hashlib.file_digest(f,'sha256').hexdigest()); f.close()"
```
Use lowercase 64-character hashes. Additional `[[target]]` rows are
alternatives, not a way to translate addresses across revisions. Separate
packages or implementations may be needed if the actual byte locations differ.
## Add options
To make the example's value configurable, add this option to that package:
```toml
[[option]]
feature = "starting-lives"
id = "count"
label = "Starting lives"
type = "integer"
min = 1
max = 9
step = 1
default = 3
```
Then **replace**, rather than add to, its original `[[patch]]`:
```toml
[[patch]]
feature = "starting-lives"
target = "main_exe"
address = 0x80012340
expected = "03 00 02 24"
replace_from = { option = "count", encoding = "u16le", offset = 0 }
```
The loader copies the expected bytes and fills the selected field with the
option value. `u8`, `u16le`, and `u32le` encode unsigned values. The declared
range must fit the encoding; the loader will not infer signed arithmetic or
arbitrary expressions. The linked schema guide covers split MIPS immediates
and sparse field ownership for more complex patches.
Boolean and choice options can select operations with feature-local
conditions. For example, after declaring a `style` choice on this feature
with values `stock` and `extra`, a patch may contain:
```toml
when = { style = "extra" }
```
All named conditions must match. Declare choice values with `[[option.choice]]`
entries immediately after their owning option; `default` must name one of
those values. Options alone do not change the game: patches consume them, or
a trusted plugin must query and implement them.
## Replace disc data with an overlay
Use `[[overlay]]` for a larger asset range stored in a separate package file.
Find the original asset and its format with ordinary disc/file tools, edit it
by hand, and export the replacement bytes. Preserve the game's expected
layout or explicitly implement the additional pointer/size changes needed.
The two disc coordinate systems are:
| Target | Offset calculation | Typical use |
| --- | --- | --- |
| `disc_raw` | `LBA * 2352 + byte within raw sector` | Raw-sector-aware changes, including formats outside a 2048-byte data payload |
| `disc_user` | `LBA * 2048 + byte within user data` | Logical 2048-byte data sectors |
LBA is the logical sector number in the runtime's disc reader. A file-relative
offset is not an LBA. Match the runtime's track mapping; do not treat a
multi-track CUE as one flat file. A raw 2352-byte sector contains headers and
other bytes besides its data payload. The runtime maps Mode 2 Form 1 raw data
to its 2048-byte payload; `disc_user` is not the mechanism for XA/Form 2 audio
or CD audio sectors.
Small disc `[[patch]]` writes use `offset` instead of `address` and cannot
cross a sector boundary. File-backed overlays may span sectors. For example,
add this fragment to a package that already declares a feature named `art`:
```toml
[[overlay]]
feature = "art"
target = "disc_user"
offset = 204800
file = "assets/replacement.bin"
sha256 = "REPLACE_WITH_PAYLOAD_SHA256"
expected_sha256 = "REPLACE_WITH_ORIGINAL_RANGE_SHA256"
```
The illustrative offset is LBA 100, byte 0. Use your own verified location.
`file` is relative to the package root. Its length determines the replacement
range; it does not insert space into the disc. Hash the replacement file for
`sha256`. The optional `expected_sha256` hashes the **same-length original
range in the selected target stream**, not the whole disc or raw sectors
when the target is `disc_user`.
Every target row must declare an exact `disc_sha256` when using feature disc
overlays. Include the payload in the archive, update its hash whenever it
changes, and pack using the same command as the first example. The loader
checks payloads during installation/scanning and checks enabled payloads again
during resolution.
To adapt an existing disc mod, inspect and understand its changes, then
reimplement the behavior as guarded operations, overlays, or a trusted plugin
against the stock disc. Do not just rename an IPS/PPF/VCDIFF to `.psxmod`.
The legacy `derived_disc` path is not supported for new feature-style mods.
## When you need C or C++
Use a trusted plugin for new host behavior or a change that cannot be
expressed by the declarative operations. It lives in the **game repository**,
is reviewed and compiled into that game, and is selected by a manifest ID.
A minimal implementation in `src/hello_mod.c` looks like this:
```c
#include <stdio.h>
#include "mod_plugins.h"
static void hello_activate(void) {
fprintf(stdout, "My PSX mod is active.\n");
fflush(stdout);
}
PSX_MOD_CONSTRUCTOR(register_hello_mod) {
(void)psx_mod_register_activation_plugin(
"yourname.hello.startup", hello_activate);
}
```
Add it with `target_sources(MyGame PRIVATE src/hello_mod.c)`, using the game's
actual target name, and follow that repository's build instructions. A package
with the normal metadata, target, and a feature named `hello` selects it with:
```toml
[[plugin]]
feature = "hello"
id = "yourname.hello.startup"
```
The plugin ID is a registry key, not a filename or exported symbol. Shipping
that C file inside a package does not install the implementation. Players
need the game build containing it.
Activation callbacks run before renderer/window initialization. Deterministic
guest-VBlank callbacks can be registered with
`psx_mod_register_vblank_plugin()`; gate gameplay work until
`psx_mod_game_started()`. The narrow guest read/write services and option and
resource queries are in [`mod_plugins.h`](runtime/include/mod_plugins.h).
For guest instructions, use `psx_mod_write_code_word()` rather than an ordinary
data write so executable-RAM handling sees the change. Test actual execution
and save-state restoration; compilation alone cannot establish correctness.
For assets or settings, read the committed package/feature option or resource
instead of opening a player's `state.toml` yourself. Register no unconditional
gameplay effects merely because the source file is linked. Disabling the
feature must restore stock behavior on a fresh launch.
## Test and distribute
Use a separate game installation and fresh mod selections, with normal saves
backed up. Test the **packed archive**, including its actual assets.
- Verify the intended game revision and build, feature on/off behavior, and
each option including range limits.
- Exercise what changed: new-game initialization, relevant stages or menus,
transitions, death/retry, audio, and save/load as appropriate.
- Test wrong original bytes and missing/changed assets. A rejected guard is
evidence to investigate, not a reason to remove the guard.
- Test relevant mod combinations. Different writes to the same owned bytes
fail rather than letting a load order choose a winner. Match the reported
package, feature, target, and range to the source operations.
- Validate netplay in actual supported sessions before claiming it. Matching
compilation or version labels alone does not prove compatible simulation.
Use `channel = "experimental"` on an unvalidated feature; it ships with a
badge and defaults off. `channel = "developer"` is for local development and
is unavailable in release builds. Features can declare their own channel.
Elective enhancements should be opt-in; engine correctness fixes
belong in the normal implementation.
Repository release policy excludes **CD Speed**
(`psx.enhancement.cd-speed`) and **Fast Loading / host pacing**
(`psx.enhancement.fast-loading`) entirely from game releases. Do not ship
their packages or controls. Development catalogs keep both hidden and off.
Installed and bundled packages have different owners:
```text
<game executable directory>/mods/
bundled/<package-id>/<version>/ staged build output; rebuilt by the game
installed/<package-id>/<version>/ player packages installed by the launcher
state.toml player selections
```
Keep your editable sources outside both staged and installed trees. Game
maintainers put bundled sources under
`mods/preloaded/packages/<package-id>/<version>/` and pass the catalog through
`PRELOADED_MODS_DIR` when creating the runtime target. The framework stages it.
Distribute the `.psxmod` with source, license/credits, supported game IDs and
hashes, minimum tested build, known limits, and a short changelog. State when
a custom executable is needed. The package's license covers only work you can
license; include only assets/code you have permission to redistribute. Stock
disc/BIOS images, personal saves, and `state.toml` do not belong in the ZIP.
`save_compatibility` metadata is not a promise that a gameplay change can load
every existing save; validate that behavior explicitly.
## Troubleshooting
| Symptom | What to check |
| --- | --- |
| No root manifest | Archive the contents, not an extra parent folder. |
| Unsupported format version | The game's pinned framework may predate format 10. Update the game or deliberately use only fields its older schema supports. |
| Package absent / manifest ignored | Read the game's scan error for the path and parsing reason. Syntax validation alone is not schema validation. |
| Target incompatible | Match exact game ID and actual runtime disc/EXE hash inputs. |
| Expected-byte guard failed | Recheck revision, load timing, byte order, and address/offset coordinates. |
| Overlay payload checksum failed | Pack the right file and update its lowercase SHA-256. |
| Missing trusted plugin | Use a game build containing the implementation. An ID alone cannot add code. |
| Conflicting features | Inspect the reported owned range or plugin ID and disable one; ZIP order is not patch priority. |
| Bezel enabled but invisible | Select an image, use OpenGL, and leave visible margins. |
| Package version already installed | Bump `version` or remove your prior test version through the manager. |
| Changes vanish after rebuilding | Edit source catalogs, not the build's `mods/bundled` output. |
See [`docs/MOD_PACKAGES.md`](docs/MOD_PACKAGES.md) for advanced conditions,
sparse fields, dependencies, and resolution rules; the implementations are
[`mod_packages.cpp`](runtime/src/mod_packages.cpp) and
[`mod_runtime.cpp`](runtime/src/mod_runtime.cpp).