5 Guide Publishing
bryanthaboi edited this page 2026-10-20 17:01:40 -04:00

Publishing a mod

The path from a working mods/<id>/ directory to something other people can install, and the rules that bind it.

First release: local package

This route is for a mod author with a working mod. Run it from the engine repository root, the directory containing tools/modkit.py, and replace my_mod with the directory or id of your mod.

python3 tools/modkit.py validate my_mod --strict
python3 tools/modkit.py lint my_mod
python3 tools/modkit.py pack my_mod -o my_mod-1.0.0.zip

Check the named file and line for each finding, repair it, and rerun the failed command. A package is ready for a local player when the commands pass and its archive contains the mod files at the archive root. Test that result from a clean mods folder before sharing it.

Player install

Give the player the .zip, the games it targets, and the names of any required import files. The player uses MODS > Import mod .zip (or drops the .zip on the launcher window), selects the intended game, and enables the mod for that game. Unpacking the archive into its own folder under the save directory's mods/ also works. The launcher's import and drop only recognize a .zip name, so do not hand out a bare .modpkg. A required import is supplied through the launcher's import control; it must not be bundled inside the archive. Link play uses the launcher's approved vanilla/custom-cart flow; an ordinary mod package is not a link cart.

The checklist

  1. Manifest hygiene. A real version (semver, so dependents can range-check you), a description the manager can show, an honest profile, and game_version pinned only if you rely on something version-specific. If the release is intended for link play, include it in a sealed custom cart and follow Link Compatibility; manifest link fields are author metadata, not a player permission switch. Set github to owner/repo if you want launcher Update / Versions. Mark unfinished work experimental so it stays off until the player confirms enable (Manifest). State games honestly (["gen1"], ["gen2"], ["gen3"], ["frlg"], ["rse"], specific version ids, or ["all"]) so the launcher's per-game checkboxes and each game's mod list behave. A manifest with no games is Gen 1 only, and a mod is not loaded on a game it does not claim. Claim a game only after testing there; modkit gen2check and gen3check report what a mod would hit (Modkit). For one entry file across generations, use "api": 3 (Portable Mods across Gen 1, 2 and 3). Dependencies can carry a repository hint ("dep_id@^1.0.0#owner/repo", or a structured object with github), which lets the launcher offer the download when the dependency is missing, and a games scope ({ "id": "dep_id", "games": ["gen2"] }) so a dependency only your Gold content needs never blocks a Gen 1 boot.

  2. README.md: what the mod does, how to install it, and the regenerate instructions for any transform-derived art.

  3. DIFFERENCES.md: what you change from vanilla, in the format of the engine's docs/known-differences.md. Your divergences are yours to document; the base game's ledger stays "None currently." (Compatibility).

  4. Validate and lint clean.

    python3 tools/modkit.py validate my_mod --strict
    python3 tools/modkit.py lint my_mod
    
  5. Pack or publish Releases from GitHub.

    python3 tools/modkit.py pack my_mod -o my_mod-1.0.0.zip
    

    pack re-runs both gates at strict level (any finding refuses the package) and zips the directory with files at the archive root. Players install it with Import mod .zip (Getting Started).

    For GitHub-hosted updates, put the mod in its own repo, set "github": "owner/repo" on the manifest, and add the release workflow:

    python3 tools/modkit.py set-github my_mod owner/repo   # if the field is missing
    python3 tools/modkit.py add-release-workflow my_mod
    

    Each push to main (or a manual workflow run) publishes a my_mod-<version>.zip GitHub Release with files at the archive root, the shape Import mod .zip, Update, and Versions install. Bump manifest.json's version, or put [release X.Y.Z] in the commit message, or pass a version on a manual run; otherwise the patch number is bumped past the latest tag.

The content rule

A distributed mod contains no ROM-derived content: no extracted PNGs, no chip-audio banks, no ROM images, no IPS/BPS patches (lint rules MK301-MK304, MK307 for baseroms/). lint refuses .gb, .gbc, .ips and .bps by extension; a .gba image is just as forbidden even though no rule names it. Derived art ships as an asset transform that regenerates it from the player's own cache (Art Pipeline). Because nothing you distribute contains Nintendo/Game Freak content, your mod is shareable as ordinary open-source Lua and PNGs, the same posture the engine itself claims.

Branding is your responsibility: name and present your mod as your own work, not as an official product.

User-supplied files (required_imports)

When your mod needs source material you cannot ship (another game's ROM, say), declare it instead of bundling it. required_imports and optional_imports in the manifest each list objects with a stable id, a display name, a destination file (a filename, never a path), one MD5 digest or an array of accepted digests, and optionally a description, an exact size (or max_size), and a format ("raw" default, or "n64", which accepts .z64/.v64/.n64 byte orders and canonicalizes them).

The launcher does the rest on every platform: the mod's row shows an import control, the player picks the file through the platform's native picker (desktop chooser, Android SAF, iOS Files, and so on), the launcher verifies size and digest, and the accepted bytes are copied into mods/<id>/baseroms/<file>. Your code reads them with the normal scoped API, mod:read("baseroms/<file>"), or for large files the bounded mod.imports:info(id) / mod.imports:read(id, offset, length) (8 MiB per call); no host path is ever exposed. A mod with missing required_imports does not load until they are supplied; optional_imports never block loading.

Never ship anything under baseroms/ in your archive: it is the player's installation state, and modkit lint/pack refuse it (MK307).

Versioning etiquette

  • Bump patch for fixes, minor for compatible additions, major when you break your own save shape or exported API.
  • Ship a mod.migrations:add step whenever your modData shape changes (Save Model); old saves are your users' progress.
  • Treat mod.exports as a public API: additive changes only within a major.
  • Declare conflicts (or the alias incompatible) when you know two mods cannot coexist; the declaring mod loses at load time, so state it in the mod that knows.

Play-testing the failure paths

Before shipping, test what your users will actually hit: enable alongside popular mods (merge-order surprises), disable mid-save (your content quarantines and reclaims cleanly), and load a save from your previous version (migrations fire). A mod that fails politely (attributed errors, no crashes, clean rollback) is one the manager can help users live with.

Check and recover

The local release checkpoint is a package that passes strict validation and lint, installs from a clean mods/ folder, and reaches the expected in-game change. If installation or loading fails, remove only the new package, read the manager's attributed error, repair the named file, and rebuild before sharing another archive.