Merge pull request #1769 from MaxTomahawk/adaptive-trainers/mod-developer-mode

feat(mod-api): expose loader developer mode
This commit is contained in:
bryanthaboi
2026-08-24 08:35:49 -04:00
committed by GitHub
7 changed files with 309 additions and 8 deletions
+5 -2
View File
@@ -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
+27 -4
View File
@@ -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.
+2 -1
View File
@@ -768,7 +768,8 @@ the same:
So: read the log for coverage problems, and the manager for load problems.
`POKEPORT_IDENTITY=<name>` 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
+102
View File
@@ -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.
+5 -1
View File
@@ -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 = {},
@@ -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")
+102
View File
@@ -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")