20 KiB
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
- First package: your own bezel artwork
- Make a guarded gameplay patch
- Add options
- Replace disc data with an overlay
- When you need C or C++
- Test and distribute
- 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:
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:
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:
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.
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:
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:
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:
[[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]]:
[[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:
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:
[[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:
#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:
[[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.
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:
<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 for advanced conditions,
sparse fields, dependencies, and resolution rules; the implementations are
mod_packages.cpp and
mod_runtime.cpp.