Skip to content

Latest commit

 

History

History
294 lines (235 loc) · 10.4 KB

File metadata and controls

294 lines (235 loc) · 10.4 KB

NESRecomp mod packages and trusted plugins

NES mod support is an explicit per-game build feature:

set(NESRECOMP_ENABLE_MODS ON CACHE BOOL "" FORCE)
include(${NESRECOMP_ROOT}/runner/runner.cmake)

The normal default is OFF. Opting in compiles the package runtime and opens recomp-ui's compile-time Mods gate. The launcher shows Mods only after the game also initializes a valid provider.

Product and trust model

  • A package is the installation, update, provenance, and trust boundary.
  • A feature is independently enabled and may own boolean, choice, or bounded-integer options.
  • A trusted plugin is game-owned native behavior already statically linked into the executable and registered under a stable ID.

.nesmod archives are ZIP files with a root manifest.toml. Archives contain data only. They cannot provide native code, symbols, DLLs, or library paths. The loader accepts stored and DEFLATE entries, verifies ZIP CRCs, rejects encrypted or unsafe paths, caps archives at 4096 files and 256 MiB expanded, stages extraction, validates the manifest, and publishes a version atomically.

The selected game image remains a stock ROM. Targets use the lowercase CRC32 of all bytes after the 16-byte iNES header, matching NESRecomp's existing ROM verification and allowing equivalent header variants.

Package layout

Installed and preloaded packages use the same executable-relative catalog:

mods/
  state.toml
  packages/
    example.display/
      1.0.0/
        manifest.toml

Built-in features should default to disabled.

Required owner ROMs

A feature may require a second, user-owned source ROM without treating that ROM as package content or as the game image being launched:

[[external_rom]]
feature = "smash64-player"
id = "smash-64-us-v1"
label = "Super Smash Bros. 64 ROM"
description = "Select a legally owned USA v1.0 dump."
format = "n64"
identity = "Super Smash Bros. (USA), NTSC-U v1.0 (NALE)"
size = 16777216
normalized_sha1 = "e2929e10fccc0aa84e5776227e798abc07cedabf"

Resource IDs are unique across their whole package because persisted paths and provider callbacks address them at package scope. format = "n64" gives the launcher .z64/.v64/.n64 filters and tells the runtime to detect N64 byte order from the magic and normalize the bytes in memory. format = "raw" hashes the selected file without a byte-order transform and uses a generic ROM picker. format = "nes" accepts either a headerless cartridge payload or an iNES 1.0/2.0 .nes image without a trainer. The 16-byte iNES header is removed before verification, and size describes the normalized PRG+CHR payload; this lets equivalent iNES 1.0 and 2.0 headers identify the same cartridge revision. normalized_sha1 may be one string or an array of explicitly supported revision hashes. The runtime compares the normalized SHA-1 and exact size declared by the package, then displays the manifest's identity as the verified revision. SHA-1 is used as a ROM-revision identity, not as a security primitive. The selected path is stored in mods/state.toml; the ROM is never copied into or packaged with the mod.

Required owner ROMs are fail-closed at every activation boundary. A missing, changed, or unsupported file prevents both package-level and feature-level enable operations, invalidates a hand-edited or legacy enabled state, and is reopened and rehashed when PLAY commits the activation plan. UI status alone is never activation authority.

An activated, trusted game plugin may obtain the selected owner-ROM path after that successful PLAY commit:

const char* source = nes_mod_external_rom_path(
    "com.example.smash64", "smash64-player", "smash-64-us-v1");
if (!source) return;  /* no committed, verified resource for this feature */

nes_mod_external_rom_path returns NULL before a successful commit, for an inactive feature, and for any package/feature/resource outside the committed activation plan. It returns a runtime-owned path snapshot, not a pending launcher selection, so a later UI edit cannot change what an already committed plugin reads. The pointer remains valid until the next initialize or commit. It exposes no ROM bytes; trusted game code is responsible for opening the user-owned file. Native plugins are intentionally trusted code, so this is an activation-scope gate rather than a sandbox between plugins.

Manifest format 1

format_version = 1
id = "example.display"
version = "1.0.0"
name = "Example Display Enhancements"
author = "Example Author"
description = "Game-specific presentation features."
license = "MIT"
resolver = "declarative"
save_compatibility = "shared"

[[target]]
game_id = "example-game-us"
rom_crc32 = "0123abcd"

[[feature]]
id = "widescreen"
name = "Widescreen"
description = "Enables the game's surveyed widescreen implementation."
group = "Display"
exclusive_group = "display-mode"
default_enabled = false

[[plugin]]
feature = "widescreen"
id = "example.widescreen"

Options are feature-owned:

[[option]]
feature = "widescreen"
id = "margin"
label = "Side margin"
type = "integer"
default = 64
min = 16
max = 128
step = 8

Supported types are boolean, choice, and bounded integer. Trusted plugins may read integer values with nes_mod_get_option_int, and any option's committed value as a string with nes_mod_option_value:

char character[64];
if (!nes_mod_option_value(package, feature, "character",
                          character, sizeof character))
    snprintf(character, sizeof character, "captain-falcon");  /* own default */

It writes a NUL-terminated value and returns 1. On failure — plan not committed, ids unresolved, or the value too long for the buffer — it returns 0 and sets out[0] = '\0', so a caller applies its own default instead of treating an empty string as a selection.

Features with the same non-empty exclusive_group are mutually exclusive. Enabling one in the launcher automatically disables the other selected feature in that group, even when the features come from different packages. Validation also rejects a hand-edited state file that enables more than one, so the runtime can never activate incompatible presentation modes together.

Conditional plugin activation

A [[plugin]] may be conditioned on one of its own feature's option values, so one feature can offer several implementations instead of degenerating into one pseudo-feature per value:

[[option]]
feature = "smash64-player"
id = "character"
label = "Character"
type = "choice"
default = "captain-falcon"

[[option.choice]]
value = "captain-falcon"
label = "Captain Falcon"

[[plugin]]
feature = "smash64-player"
id = "example.captain-falcon"
when_option = "character"
when_value = "captain-falcon"

when_option and when_value must both be present or both absent. The referenced option must belong to the same feature, and the value must be one of its declared choices — both are checked when the manifest loads, so a typo is a package diagnostic rather than a feature that silently does nothing.

Unselected variants are skipped before the duplicate-plugin check, so sibling implementations of one choice never collide with each other. A plugin without a condition activates whenever its feature is enabled, which is the historical behaviour and is unchanged.

Prefer conditions when the choice merely selects which implementation runs, and nes_mod_option_value when a plugin must act on the value itself.

Plugin registration

Game code registers trusted behavior before main():

#include "mod_runtime.h"

static void enable_widescreen(void) {
    GameDisplay_SetWidescreenEnabled(1);
}

static void reset_display_mods(void) {
    GameDisplay_SetWidescreenEnabled(0);
}

NES_MOD_CONSTRUCTOR(register_display_mods) {
    nes_mod_register_reset_callback(reset_display_mods);
    nes_mod_register_activation_plugin(
        "example.widescreen", enable_widescreen);
}

Function-entry hooks

An activation plugin can flip a global switch, but some mods need to take over a specific guest subroutine while enabled and leave it untouched otherwise. replace_func in game.toml cannot express that — it is a compile-time substitution that makes codegen drop the original body, so there is nothing to fall back to.

A game opts specific entries in:

[[mod_function_hook]]
addr = 0xB0E9           # PlayerCtrlRoutine
# bank = 0              # optional; omitted means any bank

Codegen then emits, as the first statement of that entry:

void func_B0E9_b0(void) { /* PlayerCtrlRoutine */
    if (nes_mod_function_entry(0xB0E9u)) return;  /* trusted opt-in game-mod hook */
    ...

Nonzero skips the original body; zero runs it unchanged. The check precedes the stack-tracking push, so a handling mod owns the whole call including its frame and needs no push/pop bookkeeping.

Register the implementation like any other trusted plugin — archives still select behavior only by stable id, never by supplying native code:

#include "mod_function_hooks.h"

static int take_over_player(uint16_t addr) { (void)addr; ...; return 1; }

NES_MOD_CONSTRUCTOR(register_hooks) {
    nes_mod_register_function_entry_plugin(
        "example.player-control", 0xB0E9, take_over_player);
}
/* Hooks start disabled. Activation plugins enable, reset callbacks disable: */
nes_mod_set_function_hook_enabled("example.player-control", 1);
nes_mod_disable_all_function_hooks();

Empty by default: a title that declares no hooks emits no callback and its generated code is byte-identical. A declared hook that matches no emitted entry is reported by codegen ([[mod_function_hook]] ... matched no emitted function entry) rather than silently never firing — worth heeding, since the 6502 address alone does not tell you which bank the entry landed in.

This is intentionally narrow. It is not a general per-instruction mod dispatcher.

Mod-enabled game targets must define string literals for NESRECOMP_GAME_ID and NESRECOMP_GAME_ROM_CRC32. On Play, the runtime revalidates the selected stock ROM, resolves enabled features, rejects missing or multiply claimed plugin IDs, persists state, runs every reset callback, and then activates the resolved plugins. Netplay launches clear the in-session plan without overwriting the user's offline selections.

Format 1 intentionally limits executable behavior to trusted activation plugins. Guarded guest-ROM writes, assets, and interpreter hooks can extend the same package contract later, but must retain pre-boot validation and the no-arbitrary-code archive boundary.