diff --git a/docs/mod-api-gen2-compat.md b/docs/mod-api-gen2-compat.md index 92cc0839..b0c50d28 100644 --- a/docs/mod-api-gen2-compat.md +++ b/docs/mod-api-gen2-compat.md @@ -469,8 +469,11 @@ is warned once per name and the rest of the list still runs. The engine's own Gen 1 verbs are **not** seeded on Gold: a row-list verb handed Gold's ctx would find no runner on it, so `data.commands` under Gen 2 is the mod verbs alone. -**`mod.save`, `mod.options`, `mod.log`, `mod.assets`, `mod.find`, exports.** -Generation-agnostic; nothing to adapt. +**`mod.save`, `mod.options`, `mod.log`, `mod.assets`, `mod.find`, +`mod.developer`, exports.** Generation-agnostic; nothing to adapt. +`mod.developer` is the same fixed boot-time boolean on both generations and is +available while the entry chunk runs. Gold does not gain Gen 1's developer +console or F5 hot-reload hotkey; the field reports the loader's mode only. **`mod.world`.** Same method set, resolved against Gold's world (`src/world/gen2/WorldAPI.lua`). Two differences show through and are diff --git a/docs/modding.md b/docs/modding.md index 05c11360..73e748c5 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -761,15 +761,38 @@ the hook context. ## Developer console -Boot with developer mode on to unlock the in-game console and hot-reload -hotkeys. Either set `POKEPORT_DEV=1` in the environment or pass -`--developer` on the command line: +On a Gen 1 boot, developer mode unlocks the in-game console and hot-reload +hotkeys. Either set `POKEPORT_DEV=1` in the environment or pass `--developer` +on the command line: ```sh love . --developer ``` -While developer mode is active: +The mod loader independently derives a matching boolean for every sandboxed +entry chunk as `mod.developer`. It is available while the entry file is +loading, so a mod can keep diagnostic commands, screens, and verbose tracing +out of player builds: + +```lua +if mod.developer then + mod.commands:register("my_mod:diagnostics", function(ctx) + -- open or print this mod's diagnostic view + end) +end +``` + +`mod.developer` is a plain boolean snapshot for this boot. It grants no +permission and exposes neither the process environment nor the loader. In a +normal player boot it is `false`; `POKEPORT_DEV=1` and `--developer` make it +`true`. On Gen 1 those inputs separately enable the console and hot-reload +hotkeys. The headless loader's `opts.dev` test seam changes only the loader +signal and diagnostics; it does not enable the game's console or hot reload. +Gold exposes the same `mod.developer` boolean but does not implement the Gen 1 +console or hotkeys. Use a mod option for player-facing feature toggles rather +than treating developer mode as configuration. + +While developer mode is active on Gen 1: - `` ` `` (backtick) opens the console overlay — a Lua REPL with `game`, `data` and `mods` in scope. Press `` ` `` again to close it. diff --git a/docs/preparing-your-mod-for-gen2.md b/docs/preparing-your-mod-for-gen2.md index 382a3d21..70a63db1 100644 --- a/docs/preparing-your-mod-for-gen2.md +++ b/docs/preparing-your-mod-for-gen2.md @@ -768,7 +768,8 @@ the same: So: read the log for coverage problems, and the manager for load problems. `POKEPORT_IDENTITY=` sandboxes the save directory if you want a clean -profile to test in, and `POKEPORT_DEV=1` adds the console and `F5` hot reload. +profile to test in. `POKEPORT_DEV=1` makes `mod.developer` true for loader-gated +diagnostics; Gold does not add Gen 1's console or `F5` hot reload. ## What this guide does not promise diff --git a/docs/rfcs/0017-mod-developer-mode.md b/docs/rfcs/0017-mod-developer-mode.md new file mode 100644 index 00000000..7d564648 --- /dev/null +++ b/docs/rfcs/0017-mod-developer-mode.md @@ -0,0 +1,102 @@ +# RFC 0017: Public mod developer-mode signal + +## Status + +Proposed. + +## Motivation + +The loader already derives a boot-time developer-mode flag for its permission +diagnostics and headless test seam. Gen 1's `Game` independently derives a +similarly sourced flag for its console and hot reload. A sandboxed mod cannot +read either one. `mod.commands` can register a diagnostic command but cannot +say whether the current boot is a developer boot. `mod.exports` only publishes +values to other mods. `game.ready` fires after entry registration and carries +only the game. `mod.options` is player configuration, not engine mode, and +`mod.log` logs unconditionally. The pre-sandbox compatibility +`os.getenv("POKEPORT_DEV")` deliberately returns `nil`, because the process +environment is hidden from mods. + +The concrete consumer is **Adaptive Trainers**. Its approved Chapter 30 and +Phase H require trainer, boss, Rival, and League diagnostic views plus +seed-label tracing to exist only when `POKEPORT_DEV` is active. Without a +public signal, the mod must either ship those registrations in production, +misuse a player option, or import loader/Logger internals. All three violate +the approved observability boundary or the sandbox/public-API policy. + +## Decision and plan extended + +This implements **D-AT-005: diagnostics and seed tracing are admitted only by +the engine's developer-mode decision**. The consuming design is tracked in the +Adaptive Trainers implementation plan, +[`docs/superpowers/plans/2026-08-14-adaptive-trainers.md`](https://github.com/MaxTomahawk/gen1recomp-adaptive-trainers/blob/main/docs/superpowers/plans/2026-08-14-adaptive-trainers.md), +Task 9. The engine delta is generic and contains no trainer, balancing, +diagnostic-layout, seed-label, or Adaptive Trainers policy. + +## Exact API delta + +Every sandboxed mod object adds one field: + +```lua +mod.developer -- boolean +``` + +The loader copies its existing `dev` decision into this field before invoking +the mod's entry chunk. It is therefore available for load-time registration: + +```lua +if mod.developer then + mod.commands:register("my_mod:diagnostics", diagnostics_command) +end +``` + +The value is a plain boolean snapshot, not a loader reference or environment +facade. `false` is the normal player-build answer. `POKEPORT_DEV=1` and the +`--developer` command-line path make the loader flag true; on Gen 1 those inputs +separately make `Game`'s own developer flag true for its console and hot +reload. The loader's existing injected `opts.dev` test seam changes only the +loader flag and diagnostics, not `Game` or its hotkeys. It grants no permission +and does not expose environment variables. The answer is fixed for the life of +that loader; changing a field on a mod's own table cannot change engine mode. + +The field is generation-independent and has identical semantics on Red, Blue, +Yellow, Gold, and Silver. Gold and Silver do not gain Gen 1's developer console +or hot-reload hotkeys from this field. + +## Migration and compatibility + +Existing mods change nothing. `mod.developer` is additive, requires no +permission, and does not bump the integer mod API. Existing API-v1 and API-v2 +entry chunks receive one extra scalar field and retain all prior fields and +methods unchanged. No name is removed or shadowed. + +With no mods installed, `Loader:_api` is never called, so the delta allocates no +mod object and changes no data, save, options, event, hook, command, or file. +With mods installed in a normal boot, the new field is `false` unless an author +explicitly reads it. Existing registration and logging behavior is unchanged. + +An adopting mod should gate developer-only registrations and verbose logging +directly on `mod.developer`. Player-facing behavior belongs behind +`mod.options`, not this signal. + +## Verification + +- `tests/engine/mod_developer_mode_test.lua` loads a real sandboxed mod through + the public SDK with developer mode both on and off. It proves the boolean is + available during entry execution and that the same source registers its + diagnostic command only for the developer load. It also covers the + command-line global path and a Gen 2 load. +- `tests/engine/mod_developer_mode_parity_test.lua` is the separate no-mod + parity suite. It proves both developer answers discover no mods, create no + files, and leave injected vanilla data unchanged. It also loads an unchanged + API-v1 probe and verifies identity, `mod:read`, exports, and options behavior. +- The full ROM-free engine and modkit tiers remain the compatibility proof for + `content.X:register/override/get`, `events:on`, `hooks:wrap`, `mod.log`, + `mod:read`, manifest v1 fields, and `pokemon.before_give`. + +No registry or schema changes are involved, so generated registry documentation +is unaffected. + +## Deprecation etiquette + +Nothing is removed, renamed, superseded, or deprecated. diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index 84b087cd..5a060b4f 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -270,7 +270,7 @@ function Loader.new(opts) modSave = {}, modOptions = {}, optionSchemas = {}, imageCache = {}, modInput = {}, modEnv = {}, stepsQueues = {}, fs = (opts and opts.fs) or (love and love.filesystem), - dev = dev, + dev = dev == true, safeMode = false, -- Which generation this boot is (1 or 2). Fixed at construction: the -- active version is set once in main.lua's bootGame before anything @@ -980,6 +980,10 @@ function Loader:_api(mod) id = modId, version = mod.manifest.version, path = mod.path, + -- Fixed at Loader construction and copied as plain data: a sandboxed + -- entry chunk can decide whether to register developer-only diagnostics + -- without receiving the process environment or the loader itself. + developer = loader.dev == true, -- a deep copy: what a mod does to its own view never reaches the loader manifest = Merge.deepCopy(mod.manifest), content = {}, diff --git a/tests/engine/mod_developer_mode_parity_test.lua b/tests/engine/mod_developer_mode_parity_test.lua new file mode 100644 index 00000000..7876d93c --- /dev/null +++ b/tests/engine/mod_developer_mode_parity_test.lua @@ -0,0 +1,66 @@ +-- No-mod and API-v1 parity for the additive mod.developer surface. +-- +-- The production break this catches is a developer-mode loader path that +-- mutates vanilla data, creates mod state, or changes existing API-v1 +-- behavior merely because the new public signal exists. + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") + +local function pristine() + return { + pokemon = { KEEP = { hp = 7 } }, + moves = {}, + } +end + +for _, dev in ipairs({ false, true }) do + local data = pristine() + local files = {} + local run = T.sdk.loadNone({ + data = data, + fs = T.sdk.memfs(files), + dev = dev, + }) + T.eq(#run.errors, 0, + "no-mod load stays clean with developer=" .. tostring(dev)) + T.eq(next(run.loader.mods), nil, + "no-mod load discovers nothing with developer=" .. tostring(dev)) + T.eq(data.pokemon.KEEP.hp, 7, + "no-mod load preserves vanilla data with developer=" .. tostring(dev)) + T.eq(next(files), nil, + "no-mod load creates no files with developer=" .. tostring(dev)) + run.release() +end + +local V1 = { + ["mods/v1_probe/manifest.json"] = [[{ + "id": "v1_probe", + "name": "V1 Probe", + "version": "1.0.0", + "entry": "main.lua", + "api": 1 + }]], + ["mods/v1_probe/main.lua"] = [[ + local mod = ... + mod.exports.identity = mod.id .. "@" .. mod.version + mod.exports.payload = mod:read("payload.txt") + mod.options:define({ { key = "enabled", type = "toggle", default = true } }) + mod.exports.defaultOption = mod.options:get("enabled") + ]], + ["mods/v1_probe/payload.txt"] = "unchanged-v1", +} + +local legacy = T.sdk.loadMods({ "mods/v1_probe" }, { + fs = T.sdk.memfs(V1), + dev = false, +}) +T.eq(#legacy.errors, 0, "existing API-v1 mod loads unchanged") +local out = legacy.loader.exports.v1_probe +T.eq(out.identity, "v1_probe@1.0.0", "API-v1 identity stays unchanged") +T.eq(out.payload, "unchanged-v1", "API-v1 mod:read stays unchanged") +T.eq(out.defaultOption, true, "API-v1 options stay unchanged") +legacy.release() + +T.finish("mod developer mode parity") diff --git a/tests/engine/mod_developer_mode_test.lua b/tests/engine/mod_developer_mode_test.lua new file mode 100644 index 00000000..982f9237 --- /dev/null +++ b/tests/engine/mod_developer_mode_test.lua @@ -0,0 +1,102 @@ +-- Public load-time developer-mode signal for sandboxed mods. +-- +-- The production break this catches is a loader that computes dev mode but +-- does not expose the same fixed answer to the public mod object before the +-- entry chunk runs. It also protects the data-only contract: the public +-- value is a boolean snapshot, not a live loader or environment handle. + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") + +local FILES = { + ["mods/dev_probe/manifest.json"] = [[{ + "id": "dev_probe", + "name": "Developer Mode Probe", + "version": "1.0.0", + "entry": "main.lua", + "api": 2, + "games": ["all"] + }]], + ["mods/dev_probe/main.lua"] = [[ + local mod = ... + mod.exports.seenAtLoad = mod.developer + mod.exports.kind = type(mod.developer) + if mod.developer then + mod.commands:register("dev_probe:diagnostics", function() return true end) + end + ]], +} + +local function load(dev, generation) + return T.sdk.loadMods({ "mods/dev_probe" }, { + fs = T.sdk.memfs(FILES), + dev = dev, + generation = generation, + }) +end + +do + local run = load(true) + T.eq(#run.errors, 0, "developer-mode public probe loads clean") + local out = run.loader.exports.dev_probe + T.eq(out.seenAtLoad, true, + "sandboxed entry code sees developer mode at load time") + T.eq(out.kind, "boolean", "developer mode is exposed as plain data") + T.check(run.loader.content.commands:get("dev_probe:diagnostics") ~= nil, + "entry code can register diagnostics only in developer mode") + run.release() +end + +do + local run = load(false) + T.eq(#run.errors, 0, "production-mode public probe loads clean") + local out = run.loader.exports.dev_probe + T.eq(out.seenAtLoad, false, + "sandboxed entry code sees production mode at load time") + T.eq(out.kind, "boolean", "production mode is exposed as plain data") + T.eq(run.loader.content.commands:get("dev_probe:diagnostics"), nil, + "production load does not register developer diagnostics") + run.release() +end + +for _, provided in ipairs({ "yes", 1 }) do + local run = load(provided) + T.eq(#run.errors, 0, + "non-boolean developer-mode probe loads clean: " .. tostring(provided)) + local out = run.loader.exports.dev_probe + T.eq(out.seenAtLoad, false, + "non-boolean opts.dev is false in mod.developer: " .. tostring(provided)) + T.eq(out.kind, "boolean", + "non-boolean opts.dev stays a strict public boolean: " .. tostring(provided)) + T.eq(run.loader.dev, false, + "non-boolean opts.dev is false in loader.dev: " .. tostring(provided)) + T.eq(run.loader.content.commands:get("dev_probe:diagnostics"), nil, + "non-boolean opts.dev cannot register developer diagnostics: " .. tostring(provided)) + run.release() +end + +do + local run = load(true, 2) + T.eq(#run.errors, 0, "Gen 2 developer-mode public probe loads clean") + local out = run.loader.exports.dev_probe + T.eq(out.seenAtLoad, true, + "Gen 2 entry code sees the same developer-mode answer") + T.check(run.loader.content.commands:get("dev_probe:diagnostics") ~= nil, + "Gen 2 entry code can gate diagnostics on the same signal") + run.release() +end + +do + local saved = _G.POKEPORT_DEV_MODE + _G.POKEPORT_DEV_MODE = true + local ok, run = pcall(load, nil) + _G.POKEPORT_DEV_MODE = saved + if not ok then error(run, 0) end + T.eq(#run.errors, 0, "command-line developer-mode probe loads clean") + T.eq(run.loader.exports.dev_probe.seenAtLoad, true, + "the --developer boot decision reaches the public signal") + run.release() +end + +T.finish("mod developer mode public API")