Merge upstream dev into dataset view API

This commit is contained in:
MaxTomahawk
2026-08-25 11:54:22 +00:00
346 changed files with 74098 additions and 2755 deletions
-10
View File
@@ -55,16 +55,6 @@ In-game controls use the normal PortMaster / SDL pad map, rebindable under
## Notes
**GBC FX is off on this device.** The launcher exports `POKEPORT_GBCFX=0`,
which hides the GBC FX row from OPTIONS, pins the level to OFF, and clears a
level carried over in an `options.lua` from another machine. The H700's Mali
GPU is in the same class as the phone GPUs that compile that present pass and
then show a black frame (issue #136), and `love.system.getOS()` reports
`"Linux"` here, so the Android gate would not have caught it. Every other
display option — COLORS, TILT, ZOOM, VOID FILL, MAX FPS — works normally. If
your device turns out to handle the pass, launch with `POKEPORT_GBCFX=1` to
put the row back.
**PERFORMANCE defaults to LOW here.** The OPTIONS → PERFORMANCE tier defaults
to AUTO, which reads this device as an ARM Linux handheld and resolves to
**LOW**: the 3D tilt and survey zoom stay off and the frame rate is capped,
+13 -7
View File
@@ -55,10 +55,10 @@ The short version, for an author deciding what to write:
```
`games` is an optional array of version ids (`"red"`, `"blue"`, `"yellow"`,
`"gold"`, `"silver"`), generations (`"gen1"`, `"gen2"`, case-insensitive) or
`"all"`. `src/mods/ModTargets.lua` resolves the tokens off `GameVersion.ORDER`
and `GameVersion.generation`, so nothing anywhere restates the game list.
`"gen2"` now expands to both Gold and Silver.
`"gold"`, `"silver"`, `"crystal"`), generations (`"gen1"`, `"gen2"`,
case-insensitive) or `"all"`. `src/mods/ModTargets.lua` resolves the tokens off
`GameVersion.ORDER` and `GameVersion.generation`, so nothing anywhere restates
the game list. `"gen2"` now expands to Gold, Silver and Crystal.
`Manifest.validate` stores the resolved, ORDER-sorted ids on `manifest.games`
and **derives** `manifest.gen2compat` from them, which is the one field the
loader's gate reads.
@@ -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
@@ -573,7 +576,7 @@ gains a field instead of the name gaining a prefix.
at the same moment `src/core/Game.lua` and `src/render/Renderer.lua` raise it
-- the logic tick before the pad is read, a pointer the touch overlay gets
first refusal on, the palette zone list handed to the present pass, the
composed frame before GBCFX, the letterbox, and the finished playfield rect
composed frame before ShaderFX, the letterbox, and the finished playfield rect
-- and carries the same payload.
`render.hud`'s `gameX` / `gameY` really is where Gold's dialogue boxes and
menus land, because `Chrome.fitScale` / `fitOrigin` and `World:fitScale`
@@ -783,6 +786,9 @@ name and the existing payload, plus fields where Gen 2 genuinely carries more
The list is much shorter than it was. What is outstanding, in descending value:
- `battle.field_residual`: the first guarded call site is in Gen 1 end-of-round
processing. Gold already has a native weather/between-turn pipeline but does
not yet expose the shared data-only descriptor hook.
- `trainer.before_battle`: Gold constructs and pushes its trainer battle in
`src/world/gen2/World.lua:startBattle`, which does not yet expose a deferred
preparation boundary or a battle-local player-party view. Gen 1 mods can use
+203 -5
View File
@@ -282,6 +282,66 @@ optional visual `tileRows` at 2x resolution, and optional `tileDetailRows` at
read-only snapshots; mods choose which layers to render. Red and Gold expose
the same contract while applying their own object and event visibility rules.
### Active Gen 1 block checks
Red, Blue, and Yellow expose
`mod.world:activeBlockAt(mapId, blockX, blockY)`. It returns the numeric block
ID at one zero-based block coordinate only when `mapId` is the active map.
The value is a scalar snapshot: changing it cannot change the map. This lets a
mod compare a small runtime map signature before it applies a lawful authored
replacement, without reading the mutable map or ROM cache through engine
internals.
The method fails closed. Before an overworld exists it returns
`nil, "no overworld"`; for a different active map it returns
`nil, "map is not active"`; non-numeric, non-finite, or fractional coordinates
return `nil, "invalid block coordinates"`; and negative or out-of-range
coordinates return `nil, "block coordinates out of bounds"`. An unavailable
or malformed active block returns `nil, "block unavailable"`. The caller must
require every expected cell to match before changing presentation. This method
is Gen 1-only; Gold callers receive no parity promise for it.
The same unavailable result covers missing or sparse active block storage and
an accessor result that does not match its validated active block slot.
### Conditional map occupancy
`map.occupancy_allowed` is a narrow Gen 1 hook around a map script's vanilla
decision to eject the player from an otherwise valid loaded map. Its first
call site is the post-departure `VERMILION_DOCK` branch. The ship has already
been erased when the hook runs, and the hook does not replace or suppress any
base or peer map handler.
The wrapper receives `(next, game, context)`. The dock context is a copied
`{ mapId = "VERMILION_DOCK", reason = "ss_anne_departed", gameVersion, x, y }`
record. Vanilla returns `false`. Return exactly `true` to allow the player to
remain; every other value denies occupancy and preserves the normal message
and warp. A composable wrapper calls downstream first and only adds its own
permission:
```lua
mod.hooks:wrap("map.occupancy_allowed", function(next, game, ctx)
local allowed = next(game, ctx)
local mine = ctx.mapId == "VERMILION_DOCK"
and ctx.reason == "ss_anne_departed"
and myPublicEligibilityCheck(game)
return allowed == true or mine == true
end)
```
With no wrapper, the hook allocates no context and vanilla behavior is
unchanged. A throwing wrapper is isolated by the normal hook bus. A nil,
string, number, table, or other malformed final answer fails closed. Disabling
or uninstalling the permitting mod therefore restores vanilla ejection without
changing the S.S. Anne story flag or restoring the ship.
Normal hook-chain ownership applies: a wrapper that does not call `next`
intentionally owns the final answer and does not run lower-priority wrappers.
Permission wrappers must call `next` as shown above to compose. A noncompliant
wrapper that returns false without calling `next` safely denies occupancy and
can suppress downstream permission by this standard rule. A malformed answer
also fails closed and cannot force occupancy.
## Party ordering
Companion UIs and alternate party screens can call
@@ -319,6 +379,80 @@ Red exposes FLY separately because it requires a destination picker:
and `mod.world:flyTo(mapId)` accepts only a visited destination from the native
Fly town list. Gold does not expose these two methods yet.
## Self-driven world actors
`mod.world:spawnNpc()` returns a handle whose `scriptMove` queues onto the
overworld's scripted-movement list. A non-empty list is how the overworld
knows a cutscene is running, so it gates player input for as long as the
actor walks -- right for Oak marching to his lab, wrong for an actor that
moves on its own schedule (a networked player's ghost, an ambient walker).
Five handle methods drive one without that lockout:
```lua
local ghost = mod.world:spawnNpc({ map = "ROUTE_1", x = 5, y = 7,
sprite = "SPRITE_RED" })
if ghost:canStep("up") then ghost:stepNow("up") end -- one tile, now
if not ghost:isMoving() then ghost:placeAt(9, 3, "down") end -- snap, no walk
ghost:setPassable(true) -- walk-through
```
`stepNow(dir)` sets the same per-tile state `scriptMove` does, minus the
queue. It deliberately does **not** check collision: a caller replaying a
move that was already decided elsewhere (validated on a peer's machine, or
authored) would let the two copies disagree about where the actor is if this
re-judged it. Ask `canStep(dir)` first when you do want the map's opinion.
`placeAt(x, y, facing)` snaps with no animation and clears any step in
flight, for a warp arrival or a resync too far gone to walk off.
`isMoving()` lets a driver pace itself instead of stomping a move already
running. `setPassable(flag)` is the flag `Collision.occupied` skips (the
engine's own user is Yellow's companion Pikachu); a passable object still
draws and can still be talked to.
An object spawned this way carries no `TEXT_*` id, so the vanilla talk path
has nothing to say for it. The **`world.talk`** hook is the A press on an
object, raised before the map's text tables get it:
```lua
mod.hooks:wrap("world.talk", function(next, ow, target)
if mine(target) then
say(target) -- the mod answers for an object it owns
return -- ...by not calling next()
end
return next(ow, target) -- everything else falls through unchanged
end)
```
With no subscriber the A press reaches `talkTo` exactly as before. An object
mid-step raises no hook, matching the vanilla gate.
## Adopting an already-paired link session
`LinkState.newFromSession(game, transport, mode, isHost, opts)` starts a link
session on a transport that is *already* paired, skipping the address/code
entry UI while keeping the hello and fingerprint compatibility exchange
intact. `transport` is anything `Session` accepts, which is what lets a mode
tunnel a battle through its own connection rather than opening a second one.
When the battle finishes, **`link.battle_ended`** reports the outcome:
```lua
mod.events:on("link.battle_ended", function(ev)
-- ev = { result, myParty, theirParty, peerName, role }
end)
```
The party copies are the point. Cable rules leave the real party untouched,
so a mode built on link battles -- a tournament ladder, a battle royale --
has no other way to learn what the fight cost, and by the time the state
unwinds the battle object is gone. `role` is `"host"` or `"guest"`.
Two smaller pieces support the same shape of mode. `Game:startNewGame(opts)`
is the title screen's NEW GAME closure made callable, with `opts.intro =
false` to land straight in the world -- a mode that hands out its own starting
state has no use for Oak's speech. `CodeEntry.new` takes an optional
`{ length = , charset = }`, so the slot-scrub widget that enters a link code
can also carry a room code or an address.
## Read-only battle snapshots
`mod.battle:snapshot()` returns `nil` outside a battle and a copied battle
@@ -741,15 +875,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.
@@ -860,7 +1017,7 @@ contract, so a mod does not need a desktop-specific rendering path.
`render.output_enabled` and `render.output` are the later, whole-window seam
for mods that need the engine's normal composite rather than its separate
layers. It runs after registered present pipelines and before GBCFX,
layers. It runs after registered present pipelines and before ShaderFX,
`render.hud`, and touch controls. A mod wraps both hooks: the first returns
`true` only while output ownership is needed, and the second receives
`(next, ctx)` with `canvas`, `width`, `height`, `gameX`, `gameY`, `gameWidth`,
@@ -911,6 +1068,47 @@ which stay unconditional and are never visible to a subscriber.
Developer mode also arms the mod loader's dev tripwire, which flags mods
that reach outside their permission set.
## Battle field residual hook
`battle.field_residual` lets a Gen 1 battle-rule mod request end-of-round
damage without mutating live battlers. It is guarded and runs after vanilla
status residuals, before field-token expiry and `battle.turn_ended`. The
wrapper receives `(next, context)`, calls `next(context)` for the existing
descriptor list, and appends data-only rows:
```lua
mod.hooks:wrap("battle.field_residual", function(next, context)
local rows = next(context)
rows[#rows + 1] = {
side = "enemy", amount = 7,
message = context.battlers.enemy.name .. " is buffeted!",
}
return rows
end)
```
`context.field` is a detached, data-only view with the same
`{ weather, tokens }` shape that battle checkpoints capture; it does not expose
`field.sides` or any live battler aliases. The projection recursively retains
raw tables and finite numbers, strings, and booleans under scalar keys. It
strips metatables and omits functions, userdata, threads, unsupported keys, and
cyclic edges. Consequently a wrapper cannot obtain or invoke an engine callback
even if a live field token uses one internally, and changing any nested view
value cannot change live field state. `context.battlers.player` and `.enemy` are
detached `{ side, name, hp, maxHp, types, vanished }` views, and `context.turn`
is the current turn number. A descriptor accepts `side`
(`player` or `enemy`), a positive, finite integer number `amount`, and an
optional string `message`.
Numeric strings and invalid rows are ignored; damage is clamped to current HP.
The engine retains HP-bar, faint, experience, and replacement authority. If
both active battlers take terminal residual damage together and the player has
no healthy reserve, this hook batch queues only the player faint authority and
resolves as a blackout loss without an enemy-faint EXP award or replacement.
That precedence is local to accepted rows from this hook; native faint paths
are unchanged when no hook is active. Hook callbacks remain process-local;
checkpoints serialize only field data. Gold does not yet raise this hook; its
native weather pipeline is documented in `docs/mod-api-gen2-compat.md`.
## Process-lifecycle hooks
These exist so a platform-specific launcher integration (a native shell
+4
View File
@@ -15,9 +15,13 @@ Features intentionally added beyond the original Pokémon Red, Blue, and Yellow
* **Touch skins** in RetroArch overlay format and Delta `.deltaskin` (including PDF-wrapped bezel art), with per-button press states and Super Game Boy borders
* **Pokédex diploma and printer image exports**
* **Shareable mod lists** over save sync, optionally carrying the options set for those mods, which the receiving device is asked about before anything is changed
* **Custom carts**, a named mod set saved from the mods tab and picked from a game's page, with its own shell colour, label art, save slots and export file
* **Install required mods**, one press on a cart that will not start, fetching every pinned mod at the pinned version and refusing any archive whose hash is not the one the cart recorded
* **Browse carts in Find mods**, a Mods / Carts switch on the same community index, searched and filtered by base game, installing the cart file straight into that game's cart list
## Gen 2 Specifics
* **Pokémon Silver** as an importable, launcher-selectable version alongside Gold
* **Pokémon Crystal** as an importable, launcher-selectable version alongside Gold and Silver
* **Mod manager** with Gen 1 mod adapters, per-game targeting, and `modkit gen2check`
* **Followers** for mods, plus Gen 2-only registries and hooks
+3 -2
View File
@@ -176,7 +176,7 @@ something the filesystem encodes.
| token | means |
| --- | --- |
| `"red"`, `"blue"`, `"yellow"`, `"gold"`, `"silver"` | that one game (a version id from `GameVersion.ORDER`) |
| `"red"`, `"blue"`, `"yellow"`, `"gold"`, `"silver"`, `"crystal"` | that one game (a version id from `GameVersion.ORDER`) |
| `"gen1"`, `"gen2"` | every game of that generation (case-insensitive; `"gen 2"` also parses) |
| `"all"` | every game this engine has |
@@ -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
@@ -0,0 +1,142 @@
# RFC 0013: Conditional map occupancy and active-block reads
## Status
Proposed.
## Motivation
The Gen 1 Vermilion Dock script correctly ejects a player who enters after the
S.S. Anne has departed. A content mod can add a city-side route back to that
empty harbor, but it cannot preserve the dock visit: replacing the complete
dock handler would discard vanilla behavior and peer handlers, while an added
handler cannot cancel the base handler's ejection.
A mod that changes one active map block also needs to prove that it is looking
at the expected Red, Blue, or Yellow layout before it acts. `mapOverview()` is
intentionally presentation-oriented and does not expose block identity.
Requiring internal `Map` state or generated ROM data would cross the public mod
boundary and make a wrong-version edit difficult to fail closed.
## Decision and plan extended
This extends Route B in `CONTRIBUTING-mods.md`: new behavior is additive,
ordinary hook composition remains the authority, an empty hook chain is a
provable no-op, and mods receive copied or scalar data rather than mutable
engine state. It supports the approved Mew-under-the-truck implementation plan
without adding any Mew-specific rule, asset, flag, or content to the engine.
## Exact API delta
### `map.occupancy_allowed`
The post-departure `VERMILION_DOCK` script calls this hook after it replaces the
ship blocks with water and immediately before it would display the departure
message and warp the player to Vermilion City.
A wrapper has this shape:
```lua
function(next, game, context) -> boolean
```
The context is a new table with these fields:
| Field | Meaning |
|---|---|
| `mapId` | `"VERMILION_DOCK"` at this call site |
| `reason` | Stable reason key `"ss_anne_departed"` |
| `gameVersion` | Active save version (`red`, `blue`, or `yellow`) when present |
| `x`, `y` | Current player cell coordinates when present |
Vanilla returns `false`. The player remains only when the final chain result is
exactly `true`. Absent, throwing, or malformed wrappers therefore preserve
ejection. A wrapper composes by calling `next(game, context)` and returning
true when either downstream or its own narrow rule permits occupancy. The hook
does not replace `MapScripts` registration, merging, or dispatch, and does not
change the departure flag or reconstruct the ship.
As with every wrapper hook, a callback that does not call `next` intentionally
owns the final answer and does not run lower-priority callbacks. Permission
wrappers must call downstream to compose. A false, non-forwarding wrapper
safely denies occupancy and can suppress downstream permission by this normal
rule. A malformed final result also fails closed and cannot permit occupancy.
The call is guarded by `Runtime.wantsHook`, so an empty chain allocates no
context and follows the prior branch exactly.
### `WorldAPI:activeBlockAt`
Gen 1's public `mod.world` facade adds:
```lua
activeBlockAt(mapId, blockX, blockY) -> blockId
| nil, reason
```
`mapId` must equal the active map ID. Coordinates are finite, integral,
zero-based block coordinates. A successful result is a numeric scalar copied
from the active runtime map. The method never returns the map's mutable block
array and never writes game or save state.
Failure reasons are stable:
| Condition | Reason |
|---|---|
| No active overworld map | `no overworld` |
| `mapId` differs from the active map | `map is not active` |
| Coordinate has the wrong type, is non-finite, or is fractional | `invalid block coordinates` |
| Coordinate is negative or outside the active map | `block coordinates out of bounds` |
| Active block data is absent or malformed | `block unavailable` |
The block slot and the active map accessor must both contain the same valid
nonnegative integer. Missing or sparse storage and inconsistent accessor data
return `block unavailable`.
Requiring the expected map ID and rejecting all ambiguous input lets a mod
compare every cell in its version-specific signature before it calls an
existing mutation API. Red, Blue, and Yellow each use their own loaded map
data. Gold does not gain this method in this RFC.
## Migration and compatibility
Existing mods change nothing. No hook, event, registry, manifest field, save
field, map handler, or WorldAPI method is removed or renamed. With no hook
subscriber the departed-dock behavior is unchanged. Existing callers cannot
invoke the new WorldAPI method accidentally.
The occupancy answer is not persisted by the engine. Disabling or uninstalling
a mod removes its wrapper through normal owner cleanup, so a later dock entry
uses vanilla ejection. The engine writes no new save state and neither API
returns or serializes ROM or save data.
## Verification requirements
The parity gate must prove:
- vanilla departed-dock message and warp remain with no subscriber;
- one permission wrapper can allow occupancy without removing the ship-erasure
work or any map handler;
- multiple cooperative wrappers preserve downstream permission;
- absent, throwing, nil, false, malformed, and non-forwarding hook answers fail
closed according to the normal wrapper-chain rule;
- Red, Blue, and Yellow contexts keep their version identity separate;
- disabling or removing the owner restores vanilla behavior without a save
migration;
- `activeBlockAt` accepts only the active map and valid in-range integral
coordinates, returns a scalar, and never mutates map or save state; and
- the test fixtures and resulting changes contain no ROM or save payload.
The hook must be driven through a real public `hooks:wrap` chain. The block API
must be exercised through a public `WorldAPI` instance. Tests must not replace
the production map handler with a test-only implementation.
## Docs with the change
`docs/modding.md` documents both contracts, their failure behavior, the
cooperative wrapper pattern, and the Gen 1-only block method. No registry or
schema changes occur, so generated registry documentation is unchanged.
## Deprecation etiquette
Nothing is removed, superseded, or deprecated.
@@ -0,0 +1,158 @@
# RFC 0014: Mod-driven world actors and adopted link sessions
## Status
Proposed.
## Motivation
A mod can already spawn a runtime object with `mod.world:spawnNpc` and can
already start a link battle. It cannot make either of them behave like
something the mod itself owns.
Three walls, each of which stops a mode rather than inconveniencing it:
**An actor cannot move on its own schedule.** The only public way to animate a
spawned object is `Handle:scriptMove`, which queues onto
`OverworldState.scriptMoves`. A non-empty `scriptMoves` is how the overworld
knows a cutscene is running -- `handleInput` gates on it -- so anything
animated that way freezes the player's controls for as long as it walks. That
is correct for Oak marching into his lab and wrong for an ambient walker or a
networked player's ghost, which move continuously and must not lock anyone out.
**A spawned object cannot answer the A press.** Talking is resolved from the
map's text tables by `TEXT_*` id. A runtime object has no id, so the vanilla
path has nothing to say for it, and the mod that created it has no way to say
anything either.
**A link battle cannot ride a connection the mod already has.** `LinkState`
owns pairing, so a mode that already has a socket to its peer must either open
a second connection for the battle or reimplement lockstep. And when the
battle ends, cable rules leave the real party untouched, so the damage exists
only in the battle's own copies -- which are gone by the time the state
unwinds. A mode built on link battles cannot learn what the fight cost.
The immediate consumer is an overworld multiplayer mode, but nothing here is
specific to it: the first two are wanted by any mod with an actor that moves
itself, and the third by any mode that runs battles over its own transport --
a tournament ladder, a draft, a gauntlet.
## The decision it extends
This adds nothing to the compatibility surface's shape; it extends the
**additive, guarded seam convention** that Route B in `CONTRIBUTING-mods.md`
documents, and is gated by the parity guarantee `tests/engine/gate_meta_coverage.lua`
enforces ("21-testing-and-ci: a parity gate for every extension point"; M14).
There is no in-repo D-number registry to amend; the consuming design lives
outside this repository, as it did for RFC 0011.
## Exact API delta
### New hook: `world.talk`
```lua
mod.hooks:wrap("world.talk", function(next, ow, target)
-- ow = the OverworldState raising it
-- target = the object on the faced cell
if mine(target) then
say(target)
return -- the mod answered; the text path is skipped
end
return next(ow, target) -- anything else falls through unchanged
end)
```
Call site: `OverworldState:interact`, on the branch that has already resolved
an object on the faced cell (including across a counter) and confirmed it is
not mid-step and not the Pikachu follower. It runs before the map's text
tables are consulted. With no subscriber, `Runtime.call` invokes the vanilla
fallthrough, which is a file-local function rather than a per-press closure,
so an unhooked A press allocates nothing it did not allocate before.
### New event: `link.battle_ended`
```lua
mod.events:on("link.battle_ended", function(ev)
-- ev = {
-- result = "win" | "lose" | "draw" | "ended",
-- myParty = lockstep copy of our party,
-- theirParty = lockstep copy of theirs,
-- peerName = string,
-- role = "host" | "guest",
-- }
end)
```
Call site: `LinkState:update`, in the `battleRunning` stage, once the battle
state has been popped and before `exitWith` unwinds the link stack. Guarded by
`Runtime.wants("link.battle_ended")`, so with no subscriber no payload table
is allocated and the branch runs exactly as before.
The party copies are the point of the event. They are the lockstep records the
battle actually fought with, which is where the damage lives under cable rules.
### New `WorldAPI` handle methods
```lua
handle:stepNow(dir) --> true when the step started
handle:canStep(dir) --> would stepNow land somewhere legal?
handle:placeAt(x, y, dir) --> snap, no animation, clears a step in flight
handle:isMoving() --> true while a step is animating
handle:setPassable(flag) --> may the player walk through this object?
```
`stepNow` sets the same per-tile state `scriptMove` does, without the queue and
therefore without the input lockout. It does **not** consult collision: the
intended caller is replaying a move already decided elsewhere (validated on a
peer, or authored), and re-judging it locally would let two copies of the same
actor disagree about where it is. `canStep` is the separate opinion for callers
that want it. `setPassable` sets the flag `Collision.occupied` already honours
for Yellow's companion Pikachu; a passable object still draws and still talks.
### Two supporting changes
`Game:startNewGame(opts)` -- the title screen's NEW GAME closure, made callable,
with `opts.intro = false` to land directly in the world. A mode that issues its
own starting state has no use for the intro cutscene.
`CodeEntry.new(shape)` -- accepts an optional `{ length =, charset = }`, so the
slot-scrub widget that enters a link code can also carry a room code or a
host:port address. Called with no argument it is byte-for-byte the previous
widget.
## Migration and compatibility
**Existing mods change nothing.** Every item above is a new name or a new
optional argument; no existing name, payload, signature, or default changes.
The v1 surface is unaffected: `content.X:register/override/get`, `events:on`,
`hooks:wrap`, `mod.log`, `mod:read`, the manifest v1 fields and
`pokemon.before_give` all behave as before. `mods/example_mew_starter` -- api 1,
`category = "GAMEPLAY"`, whole-species copy -- loads unchanged, which
`tests/run_modkit.lua` proves on every run.
With no subscriber to either seam, Red, Blue, Yellow, Gold and Silver behave
exactly as they did: the A press reaches `talkTo`, the link battle unwinds
without building an event payload, and no handle method is reachable unless a
mod calls it.
## Verification
- `tests/modkit/cases/world_talk.lua` drives the real `OverworldState:interact`
path. It asserts the unhooked build first (the A press reaches `talkTo`),
then that a mod owning the object suppresses the text path by not calling
`next`, that an object the mod ignores still falls through, and that an
object mid-step raises no hook at all.
- `tests/modkit/cases/link_battle_ended.lua` drives the real `LinkState:update`
path. It asserts that with nothing subscribed the event is not wanted (so no
payload is built) and the battle still unwinds, then that a subscriber
receives the result, the role, the peer name and both party copies including
the damage the real party never took, from both sides of the cable.
- `tests/engine/gate_hooks.lua` and `tests/engine/gate_events.lua` walk the live
catalog, so both seams are parity-gated structurally; `gate_meta_coverage`
passes 208/208 with both names covered and no DEBT entry added.
## Deprecation etiquette
Nothing is removed, renamed, superseded or deprecated.
+2 -2
View File
@@ -118,7 +118,7 @@ it, performs no cross-version cache reads.
Opening a view does not change `GameVersion`, `CacheFs.prefix`, the active
`Data` table, PhysFS mounts, save state, or the selected game's behavior.
Red, Blue, Yellow, Gold, and Silver keep their existing active data paths.
Red, Blue, Yellow, Gold, Silver, and Crystal keep their existing active data paths.
The completion marker and per-version required-file rules live in the pure,
injected `CacheContract` shared by the importer and dataset service. It also
@@ -131,7 +131,7 @@ evicts the previous semantic view.
## Verification
- `tests/modkit/cases/dataset_views.lua` loads sandboxed fixture mods through
the public API and covers Red, Blue, Yellow, Gold, and Silver independently.
the public API and covers Red, Blue, Yellow, Gold, Silver, and Crystal independently.
- The test proves semantic registry normalization, deterministic iteration,
detached records, read-only facades, cross-mod facade isolation,
version-prefixed generated assets,
+123
View File
@@ -0,0 +1,123 @@
# RFC 0016: Engine-owned field residual descriptors
## Status
Proposed.
## Motivation
Battle-rule mods can keep deterministic data-only state in the public battle
field and observe `battle.turn_ended`, but that event fires after the engine's
residual and faint pipeline. A listener cannot safely deal end-of-round field
damage: directly changing live HP bypasses bar drains, faint messages,
experience, replacements, double-faint resolution, and checkpoint continuation.
Putting callbacks into `battle.field` is also rejected by the checkpoint
serializer, correctly, because executable state is not save-safe.
## Decision and plan extended
This implements **D-AT-004: public engine-owned field residual execution**, the
consuming decision required by Adaptive Trainers capability `ENGINE-FIELD-
RESIDUALS`. The plan is
[`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 8. The engine delta defines only a generic end-of-round extension point;
it contains no weather names, immunities, damage formula, trainer identity, or
Adaptive Trainers policy.
## Exact API delta
Add the guarded Gen 1 hook:
```lua
mod.hooks:wrap("battle.field_residual", function(next, context)
local rows = next(context)
rows[#rows + 1] = {
side = "enemy",
amount = 7,
message = context.battlers.enemy.name .. " is buffeted!",
}
return rows
end)
```
The hook runs once during an undecided battle's end-of-round processing, after
vanilla status residuals and before field/side token expiry and
`battle.turn_ended`. With no subscriber, the guarded site builds no context and
changes nothing. Vanilla contributes an empty list.
`context` is `{ field, battlers, turn }`. `field` is a strictly data-only view
with the same `{ weather, tokens }` shape captured by battle checkpoints. Its
recursive projection retains raw tables and finite numbers, strings, and
booleans under scalar keys. It strips metatables and omits functions, userdata,
threads, unsupported keys, and cyclic edges. Thus it exposes neither
`field.sides` nor a graph or executable callback back into live engine state.
`battlers.player` and `battlers.enemy` are detached snapshots with `{ side,
name, hp, maxHp, types, vanished }`. Changing either detached view cannot
change the live battle. `turn` is the current Gen 1 turn counter.
A result row is `{ side = "player"|"enemy", amount =
positive_finite_integer_number, message = optional_string }`. Numeric strings,
zero, negatives, fractions, NaN, infinities, malformed sides, and non-string
messages fail closed. Damage is clamped to current HP. The engine owns
mutation, HP-bar drain rows, and its existing faint pipeline. It applies every
accepted row before scheduling newly fainted battlers. If this hook batch
terminally faints the player with no healthy reserve, it queues only the player
faint authority, so descriptor order cannot race a blackout against an enemy
EXP/replacement path. Otherwise it schedules newly fainted battlers in fixed
player/enemy order. This precedence is scoped to this hook response; native
faint paths, including `enemyMonFainted`, keep their existing no-hook behavior.
Wrappers compose by calling `next(context)` and appending their own rows.
Callbacks are not part of the descriptor contract and hook functions are never
stored in the battle.
Gold already owns native weather and a generation-specific between-turn order;
this first additive call site is Gen 1-only. A future Gold site must keep the
same context and descriptor contract and choose its native ordering explicitly.
## Migration and compatibility
Existing mods change nothing. No hook name or payload changes. With no wrapper,
Gen 1 performs the same residual, token, event, and faint work as before,
including native simultaneous-faint resolution, and allocates no context. Gen
2 is unchanged. Existing and new checkpoints keep
serializing only the data stored in `battle.field`; hook callbacks remain
process-local loader state and are never serialized.
The v1 surface remains unchanged: `content.X:register/override/get`,
`events:on`, `hooks:wrap`, `mod.log`, `mod:read`, manifest v1 fields, and
`pokemon.before_give` keep their existing behavior.
## Verification
- The catalog hook parity gate proves null and live-empty buses return the
vanilla list unchanged.
- A sandboxed fixture mod exercises the seam through `mod.hooks`, verifies the
detached checkpoint-shaped context, applies damage, and reaches the engine
faint pipeline.
- Engine validation tests cover strict number validation (including numeric
strings, NaN, infinities, zero, and negatives), nested mutation isolation,
omission of functions/userdata/threads/cycles and metatables from the public
field projection, optional messages, non-table results, clamping, and
settled-battle suppression.
- Both descriptor orders are driven through queue completion for a simultaneous
terminal residual. Each proves player blackout loss with no EXP event,
enemy replacement, or replacement UI.
- A disabled-bus sentinel proves the guard performs no `Runtime.call` or field
context construction. An ordering probe proves the enabled hook runs after
vanilla status residuals and before token expiry and `battle.turn_ended`.
- A no-hook native regression proves a simultaneous zero-HP state still enters
the pre-existing enemy-faint EXP and win authority outside this hook batch.
- Capture/restore/capture evidence proves checkpointed field state round-trips
while the enabled process-local hook remains installed and callable.
## Docs with the change
`docs/modding.md` documents the Gen 1 timing, detached payload, descriptor
validation, simultaneous-terminal result, and checkpoint boundary.
`docs/mod-api-gen2-compat.md` records that Gold does not yet expose the hook.
No registry or schema changes are involved, so generated registry docs do not
change.
## Deprecation etiquette
Nothing is deprecated. The hook is additive.
+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.
+673
View File
@@ -0,0 +1,673 @@
# ShaderFX: runtime slang shader presets
ShaderFX plays real libretro `.slangp` shader presets over the finished frame.
It replaced `src/render/GBCFX.lua`, a hand-ported fixed four-level effect, with
a picker: any preset the player drops in a folder, or any preset pulled from
the RetroArch buildbot, can be selected and run. Engine:
`src/render/ShaderFX.lua` (discovery, download, translation call, pass-graph
runtime, render entry point), `src/render/ShaderFixup.lua` (GLSL rewrites),
`src/render/ShaderSourcePatches.lua` (pre-translation source patches),
`src/core/Sensors.lua` (accelerometer and gyroscope), `src/ui/ShaderFXScreen.lua`
(the picker), `src/ui/ShaderFXParamsScreen.lua` (per-preset parameter editor),
`tools/shaderfx-bridge/` (the Rust translator). Call sites:
`src/render/Renderer.lua` (Gen 1) and `src/core/Game2.lua` (Gen 2), both at the
end of the frame. Drivers: `tests/drivers/gold_shaderfx_zoom_sizing_test.lua`,
`tests/drivers/gold_shaderfx_menu_black_crop_test.lua`.
This is first-party engine code, not a mod. It calls the native translator
directly through `ffi.load`, with no `Sandbox.lua`, no `native` permission
declaration, and no mod boundary. Mods do not get that, and the distinction is
deliberate: the upstream maintainer will not accept `native` in mods.
## What a player sees
**OPTIONS** carries two rows, `SHADER FX` and `SHADER FX 2`. Each opens the
same pushed list screen (`ShaderFXScreen`) on a different slot. The list is
`OFF`, then every `.slangp` found on disk, then a permanent `DOWNLOAD SHADERS`
action row at the bottom.
A preset that has never been translated draws muted with a `CONVERT` hint on
the right. `A` on that row translates it in place and stays open: converting is
a preparation step, not a selection. `A` on a converted row activates it,
persists the choice, and closes. `SELECT` on a converted row opens
`ShaderFXParamsScreen`, which lists every `#pragma parameter` the preset
declares and lets the player step each one (`A` wraps, Left/Right clamp,
`SELECT` resets one row, `START` resets all behind a confirm).
Presets live in a plain OS folder, `shaders/` under the portable base directory
when running portable (`SaveData.portableBaseDir()`, the SD-card convention the
Anbernic pack uses, see `docs/anbernic-rg34xxsp.md`) and otherwise under LOVE's
save directory. `ShaderFX.list()` scans it recursively, so a shader pack keeps
whatever nested layout it shipped with. These are real filesystem paths rather
than `love.filesystem` virtual paths on purpose: the native translator does
plain `std::fs` reads and knows nothing about LOVE's mounts, and neither do the
LUT loads.
Persisted state, all in `save.options`:
| Key | Meaning |
| --- | --- |
| `shaderfx` | main slot's preset name, or absent for OFF |
| `shaderfxSecondary` | secondary slot's preset name |
| `shaderfxParams[name][paramId]` | one preset's edited pragma values |
`POKEPORT_SHADERFX=<name>` activates a preset in the main slot for scratch
harnesses that never call `ShaderFX.applyOptions`. It is a stand-in that
predates the real OPTIONS row; `applyOptions` marks itself as having run so the
env var can never later override a real player choice, including a real choice
of OFF.
One quiet behavior worth knowing about: if a slot wants a real preset while
PERFORMANCE is still on AUTO, and AUTO would resolve to a tier that caps
ShaderFX off, `ShaderFX.applyOptions` pins PERFORMANCE to `HIGH`. Without it
the saved choice was force-deactivated a few lines later on every boot, which
looks identical to "the setting does not save" from the player's side. On
Android and iOS that is the common case, because AUTO always resolves to
`balanced` there. It never touches an already-explicit performance choice and
never un-escalates.
## The five stages
| Stage | Owner |
| --- | --- |
| Fetch | `ShaderFX.list`, `ShaderFX.downloadPresets`, `ShaderFX.installDownloaded` |
| Translate | `tools/shaderfx-bridge/` via `ShaderFX.translate` |
| Fixup | `src/render/ShaderFixup.lua` |
| Cache | `ShaderFX.convert` writes, `ShaderFX.load` reads |
| Run | `ShaderFX.runChain` / `runPass` / `ShaderFX.render` |
### Fetch
Two acquisition paths, and they land in the same place. A player can copy a
shader pack into `shaders/` by hand, or press `DOWNLOAD SHADERS`, which fetches
`https://buildbot.libretro.com/assets/frontend/shaders_slang.zip` (the same
~54 MB archive RetroArch's own "Update Shaders" entry pulls) through
`src/net/Fetch.lua`, the curl-on-a-`love.thread` transport the self-updater and
mod index already use.
Downloaded presets are *not* pre-converted. The buildbot is RetroArch's asset
mirror and has no notion of this project's cache format, so a downloaded preset
goes through the same `CONVERT` row a hand-copied one does. Every platform
ships both convert and use; there is no asymmetry to work around.
Repeat downloads are conditional. The zip itself is deleted right after
extraction, so what is cached instead is the buildbot's own ETag
(`shaderfx_buildbot.etag`), replayed as `If-None-Match`. The server answers a
match with 304 and no body, and curl writes no file at all in that case, so
`installDownloaded(notModified)` short-circuits before touching the filesystem
and reports "already up to date" rather than "FAILED".
The interesting part is what gets extracted. `handheld/` is not self-contained:
its 78 presets carry 101 references that escape the folder (color-mod LUTs
into `../shaders/color/`, console-border helpers into `../../reshade/`, shared
motion-blur and misc helpers, a shared `stock.slang`) across roughly 40 of
them. Extracting `handheld/` alone silently breaks about a third of its own
list; extracting the whole zip drags in ~5600 files of CRT, arcade and console
content nobody asked for. So `extractClosure` walks the real file-level
dependency closure in Lua before a single file is copied, the same
`#reference`-closure idea librashader applies internally, done up front because
deciding what to copy has to happen before the translator ever sees these
files. Against this zip that is 207 files and about 9.5 MB with zero broken
references. The closure seeds from `KEPT_PRESETS`, a curated shortlist rather
than all 78, after most of `color-mod/` and `console-border/` turned out either
irrelevant (color-only, no LCD effect) or broken for this project. That list
is a temporary trim pending wider testing and is expected to change.
Two mechanical details in that walk that are easy to get wrong a second time.
`extractRefs` tries a quoted `key = "path"` match per line first, with an
unrestricted `[^"]+` capture, because the closing quote is an unambiguous
delimiter and real packs ship paths with spaces and parentheses in them
(`"shaders/handheld/color-mod/Game Boy (Color).slang"`); only a line with no
quoted match falls back to a conservative character class, since an unquoted
path has no delimiter to trust past. And `love.filesystem.write` does not
create intermediate directories, so each destination directory is created once
before anything is written into it; without that, every file under a subfolder
`handheld/` never had before was silently dropped while flat writes succeeded.
Cleanup is equally literal: `love.filesystem.unmount()` takes the archive path
originally passed to `mount()`, not the mountpoint. Called with the mountpoint
it returns false, leaves the zip's handle open, and the following `remove()`
silently fails too, so every download used to leave 54 MB on disk forever.
### Translate: the native bridge
`tools/shaderfx-bridge/` is a small Rust crate (`spike`) that builds a cdylib
named `librashader_bridge`. It wraps `librashader-presets`,
`librashader-preprocess` and `librashader-reflect` behind a two-function C ABI:
```
char* librashader_translate_preset(const char* preset_path, int es);
void librashader_free_string(char* s);
```
It returns a JSON `TranslateResult`: `pass_count`, a `passes` array (each with
its emitted `vertex`/`fragment` GLSL, `filter`, `wrap_mode`, `scale_x`/
`scale_y`, its own `#pragma parameter` declarations, a classified `samplers`
list and a classified `size_uniforms` list), the preset's `textures` (LUTs,
with resolved absolute paths and filter/wrap settings), its
`parameter_overrides`, and an `error` field.
**What the bridge is not.** No librashader runtime backend is linked in, for
any API: no GL, Vulkan, D3D or Metal crate is a dependency, and no live
graphics context is ever touched. It is the translation step only. LOVE still
owns every draw call, every canvas and every shader object. The bridge hands
back text and metadata and nothing else.
**When it is called.** Only from `ShaderFX.convert()`. Translation is ahead of
time, not just in time. `ShaderFX.load()`, `ShaderFX.activate()`, boot-time
reactivation of a saved choice and every frame of `ShaderFX.render()` read the
cached artifact and never call the library. An entry with no cached artifact
fails `activate()` loudly instead of silently live-translating.
The classification is done with librashader's real semantics resolution rather
than name matching on the Lua side, and that matters for correctness, not just
tidiness. Sampler classification uses `ShaderSemantics::create_pass_semantics`
plus the `TextureSemanticMap` lookup (explicit alias and LUT-name entries
first, then the built-in `Source`/`Original`/`OriginalHistoryN`/`PassOutputN`/
`PassFeedbackN` conventions), so a pass reachable only by its real `.slangp`
alias resolves. The per-pass convenience API only registers the alias of the
pass being compiled, so the bridge mirrors upstream's `insert_pass_semantics`
loop and builds a preset-wide alias map first; without that,
`ds-hybrid-scalefx.slangp`'s pass 2 sampling `scalefx_pass0` by alias, with no
`PassOutput1`-shaped name anywhere, can never resolve. Size-uniform
classification runs the same resolution over the pass's `uniform_semantics`
map, which also covers shapes (`PassFeedbackSizeN`, `UserSizeN`) that no
present preset uses but that the convention allows.
The `es` flag picks the emitted dialect: 1 for GLSL ES 1.00 (mobile, LOVE's ES
dialect), 0 for GLSL 1.20 (LOVE's desktop dialect). `ShaderFX` picks it from
`love.system.getOS()`, and the same function decides which dialect
`validateShader` is asked about at run time, so the two always agree. Convert
and render always happen on the same device; **artifacts are not portable
across platforms**.
One class of fixup has to happen in the bridge rather than in `ShaderFixup.lua`,
on the raw `.slang` text before SPIR-V compilation: `textureSize`,
`texelFetchOffset` and `textureOffset` are all ES 3.00+ only, and spirv-cross
refuses to *emit* them for an ES 1.00 target, failing the whole pass with
`UnsupportedSpirv("textureSize is not supported in ESSL 100.")`. No GLSL text
is ever produced for a later pass to patch, so `rewrite_essl100_gaps` rewrites
the source first, and only for the ES target:
- `textureSize(Tex, lod)` becomes a literal `ivec2(w, h)` when `Tex` is one of
the preset's declared static textures, with dimensions read straight out of
each PNG's IHDR chunk. A texture that is not one of those, or a non-literal
`lod`, is left alone so it fails as loudly as before instead of guessing.
- `texelFetchOffset` and `textureOffset` become ordinary `texture()` calls at
the equivalent texel-centre UV, using the texture's own `<Tex>Size.zw`
reciprocal-size uniform. The containing block instance (`params`, `global`,
whatever) is discovered by scanning the source's own uniform block bodies,
never assumed. When a pass samples another pass purely through these calls it
may never declare that `<Tex>Size` uniform at all, so one is injected first,
before any byte offsets are computed.
That last rewrite has an honest limit: `texture()` honours the sampler's wrap
mode at out-of-range coordinates, which is not necessarily identical to
`texelFetch`'s implementation-defined out-of-bounds behavior. Any edge-of-image
discrepancy for passes whose offsets can leave the image is unmeasured.
**Building it.** `cargo build --release` inside `tools/shaderfx-bridge/`.
`ShaderFX` looks for the library, most specific first: the
`LIBRASHADER_BRIDGE_DLL` environment variable, the source directory,
`<source>/tools/shaderfx-bridge/target/release/`, the save directory, and
finally the bare name handed to the system loader. Per-OS names are
`librashader_bridge.dll` (Windows), `liblibrashader_bridge.dylib` or
`librashader_bridge.dylib` (macOS), and `liblibrashader_bridge.so` or
`librashader_bridge.so` (Linux and Android). Android resolves the bare name
because the `.so` ships as an ordinary `jniLibs` entry, so `dlopen` finds it
without a path. The desktop path is still a developer build sitting in cargo's
output directory; nothing packages it next to a shipped game yet.
`ShaderFX.canConvert()` reports whether the library resolved on this machine,
and `ShaderFX.bridgeError()` says why not. Activating an already-converted
preset never needs any of this.
### Fixup
`ShaderFixup.lua` mechanically rewrites the emitted GLSL into something LOVE
will accept. librashader emits a standalone `void main()` /`gl_FragData[0]` /
`gl_Position` shape (translated Vulkan GLSL); LOVE requires the `effect()` and
`position()` convention and refuses a raw `main()`-shaped source outright. This
is a targeted rewriter, not a GLSL parser, and every rule below exists because a
real preset in the corpus failed without it. This list is the least guessable
part of the whole feature.
**`#version` line.** Stripped; LOVE prepends its own.
**Array constructors.** SPIR-V Cross emits ES 3.0 array-constructor syntax
(`const float _17[5] = float[](0.0, 1.0, ...)`) for compile-time array
literals, which validation rejects with "arrayed constructor: not supported for
this version". GLSL ES 1.00 has no array-constructor syntax at all. There are
three real shapes in the corpus and each is handled: a `const` global (declared
without an initializer at global scope, with per-element assignments relocated
to the top of `main()`), a non-const local declaration with initializer
(rewritten in place, since a function body can hold assignment statements where
the literal was), and a bare reassignment of an array declared elsewhere (also
in place). Splitting the element list needs `splitTopLevelCommas`, because a
naive comma split breaks on any element containing its own parentheses
(`vec2(-1.0, 0.0)`), and the outer capture needs `%b()` rather than a
`[^%)]-` class for the same reason: the class stops at the first inner `)`, the
whole match fails, and the literal passes through completely untouched.
**Whole-array copies.** `float param_1[7] = coeffs;` is how SPIR-V Cross clones
a function-parameter array before passing it on, since GLSL array arguments are
by value. ES 1.00 has no whole-array assignment either, so it becomes a bare
declaration plus an element-by-element copy. The size is known from the
declaration, so no comma splitting is involved. This one only became reachable
once the other array shapes stopped masking it in the same file.
**Integer modulo.** ES 1.00 has no `%` operator, and SPIR-V Cross emits it
anyway for an upstream integer-modulo op. `%` in GLSL is only defined for
integer operands, so routing through float `mod()` and back is exact for the
non-negative operands this shader family uses (rotation and orientation enum
indices). Four patterns are tried in order (paren/paren, paren/bare,
bare/paren, bare/bare) because SPIR-V Cross fully parenthesizes a compound
operand and leaves a simple one bare, and the balanced form must be tried
before a plain identifier can partially match. Seen live on
`authentic_gbc`'s subpixel-rotation math.
**Precision.** SPIR-V Cross hardcodes an unguarded `precision highp float;` /
`precision highp int;` pair with no toggle. That is removed and replaced with
the same guard the old hand-written `DotMatrix` port used: claim `highp` only
where `GL_FRAGMENT_PRECISION_HIGH` says the driver actually offers fragment
highp, and fall through to the stage default otherwise.
**Struct flattening.** This is the big one. LOVE's `Shader:send` cannot address
a member of a custom struct-typed uniform: neither an `INSTANCE.member` dot path
nor sending the whole struct as a table works, both raise "Shader uniform '...'
does not exist." librashader emits every pass's `#pragma parameter`s and size
uniforms as exactly that kind of struct, so as shipped the output is unusable
from LOVE, not merely inefficient. `flattenStruct` deletes the struct and its
instance uniform and re-declares the members at top level. Scalar members are
*packed* four at a time into synthetic `uniform vec4 LIBRA_PACKED_N;` slots,
because GLSL ES 1.00 guarantees only 16 fragment uniform *vectors* and every
scalar costs a whole one; `gb-pass4`'s pass 0 alone has 14 scalars, already over
budget unpacked. Non-scalar members keep their own uniform, since packing an
existing `vec4` saves nothing. Every `instance.member` reference is rewritten to
`LIBRA_PACKED_N.x` (or `.y`/`.z`/`.w`), and a member whose original declared
type was `int` or `bool` gets an explicit cast back on every read, since a
packed slot only stores floats. Vertex and fragment declare identically ordered
structs for the same parameters, so packing both with the same prefix assigns
the same slot and component to the same parameter in both stages, and one
`shader:send` reaches whichever stage uses it.
Two ordering constraints inside that function are load bearing and look
arbitrary from the outside. Packing must walk the members in the struct's own
declaration order, because that order is what keeps the scalars in one
contiguous run; an earlier version sorted them longest-name-first before
packing and produced six packed vec4s instead of four on `gb-pass4`, blowing the
budget. Substitution, separately, must go longest-name-first, so a replacement
can never land as a substring inside a still-pending member name that shares a
prefix. The two orders are separate copies of the list for exactly that reason.
`Fixup.packValues` turns a flat `{name = value}` table back into the
`{uniform = value_or_vec4}` shape the packed shader expects, per the manifest
`flattenStruct` returned. `Fixup.countUniformSlots` counts declared uniform
slots against that same 16-vector budget, samplers excluded. Its pattern uses
`[%w_]+` rather than `%w+` because Lua's `%w` does not include underscore
unlike regex `\w`, and every generated name here is full of underscores.
**UBO blocks.** `LIBRA_UBO_FRAGMENT` and `LIBRA_UBO_VERTEX` have the identical
problem and are flattened the same way, under a distinct `LIBRA_UBO_PACKED_`
prefix so their groups cannot collide with the push block's numbering. `MVP` is
the one special member: it is substituted directly to `transform_projection`
instead of becoming a uniform, because "multiply the incoming vertex by it" is
exactly what LOVE's `transform_projection` already is on a full-screen draw, and
nothing would ever supply a value for it. An earlier version assumed the UBO
block only ever carried `MVP` and deleted the whole declaration after
substituting it. That is true only for presets that declare their parameters in
a push-constant block; presets that use a UBO instead (many real handheld and
console-border presets do) had every other member reference left dangling, and
the driver then read `INSTANCE.PAR` as a swizzle, which is where the "undeclared
identifier" and "unknown swizzle selection" errors on real Android hardware came
from.
**Fragment entry point.** `void main()` becomes LOVE's `effect()` signature.
The parameter list is qualified by an `EFFECT_PREC` define rather than a literal
precision, because LOVE forward-declares `effect()`'s prototype under its own
header's precision default before this source runs, which can mismatch whatever
the precision guard above raises the default to. `Fixup.PREC_HEADS` holds the
two variants (`mediump`, then unqualified) and the caller tries them in order
against `validateShader`, taking the first that passes.
**Fragment output.** `gl_FragData` does not exist in LOVE's `effect()`
convention, so every `gl_FragData[0]` occurrence is rewritten to a local
`gbFragColor`, declared at the top of the function, with a single `return`
appended before the closing brace. Rewriting *every* occurrence regardless of
the operator that follows is necessary, not just symmetric with the vertex side:
an earlier assign-then-return pair assumed one write at the very end of
`main()`, which holds for most presets but not for ones like `ds-hybrid-sabr`
that write once with `=` and later accumulate with `+=`. The `+=` statement
passed through unconverted and collided with LOVE's own `gl_FragColor` write
("Cannot use both gl_FragColor and gl_FragData"). A bare early `return;` is
rewritten to `return gbFragColor;`, which holds the value assigned just before
it on every real shape seen.
**Vertex entry point.** `void main()` becomes `position(mat4
transform_projection, vec4 vertex_position)`, the source's own `attribute`
redeclarations of `Position`/`TexCoord` are dropped since LOVE supplies them,
`gl_Position = X;` becomes `gbClipPos = X;` (named to share no substring with
`Position`, or the next step would mangle it), and a `return gbClipPos;` is
appended. The `Position` and `TexCoord` substitutions are frontier-matched
whole identifiers (`%f[%w]...%f[%W]`), not blind substring replacements: real
presets declare their own unrelated locals such as `vec2 vTexCoord;`, and a
blind `gsub` turned that declaration into the invalid `vec2 vVertexTexCoord.xy;`
(`dot.slangp` pass 0, a real driver "unexpected DOT" error).
### Cache
`ShaderFX.convert(entry)` is the only path that calls the bridge. It runs
`ShaderSourcePatches.apply` first, translates, then serializes the decoded
result to `ShaderFX.artifactPath(entry)`: the source `.slangp`'s own absolute
path with the extension swapped to `.lua`, so the artifact sits next to the
preset it came from. The file is a plain `return { ... }` chunk written by
`serializeLua`, which handles the string/number/boolean/nested-table shape
`Json.decode` produces. Array detection walks every key rather than trusting
`#t`, since `#t` counts a trailing nil as absent and a sparse table can pass a
naive length check by accident.
`ShaderFX.load(entry)` `loadfile`s that chunk and builds a chain state. On
failure it says "convert this preset first" rather than falling back to a live
translation.
**AOT rather than JIT** is the whole point of this stage. Translation is a rare,
explicit, user-initiated action whose result is stable for a given preset and
dialect, so paying for it once and writing the answer to disk keeps `ffi.load`
and the native call off every activation, every boot and every frame. The cost
is a staleness gap: `ShaderFX.isConverted()` is a plain "does the artifact file
exist" check with no version or content stamp, and there is no explicit
"reconvert" action in the UI. A preset converted by an older build never picks
up a later translator fix on its own. This was seen on a real device, where
`sunlight_shimmer.slangp`'s `Accelerometer` uniform never reached the shader
because that device's cache predated the fix while `pixel_transparency`'s
happened to be fresher. Two places compensate by reconverting unconditionally:
`ShaderFXScreen`'s explicit selection of an already-converted row, and
`ShaderFX.applyOptions` on every boot and options save. Both are human-paced,
CPU-only work with no GPU compile, and neither is on the per-frame path.
`ShaderSourcePatches.lua` sits just before translation and patches the raw
`.slang`/`.inc` files *on disk*, because only librashader's own preset parser,
reading the real files, discovers `#pragma parameter` lines and struct members;
nothing downstream can add one. Patches are small, explicit, per-preset literal
find/replace pairs (plain `find`, not `gsub`, since GLSL source is full of Lua
pattern magic), re-applied idempotently on every convert so a buildbot
re-download that replaces the upstream file wholesale does not quietly undo
them. **The patch table ships empty on purpose and nothing registers one.** Its
original use case, wiring gyroscope yaw into `sunlight_shimmer.slangp` as new
`PT_YAW_*` pragma parameters, was reverted precisely because of the staleness
gap above: a new pragma can only reach an artifact that gets reconverted, and at
the time nothing forced one. The mechanism is kept for a future preset that
genuinely needs a new declaration, but an already-wired engine-side channel is
preferred whenever one exists.
### Run: the pass graph
`ShaderFX.activate(slot, entry, paramOverrides)` loads the artifact, layers the
player's edited parameters over the artifact's own defaults, loads the preset's
LUTs once, and snapshots the accelerometer rest pose. `ShaderFX.render` then
runs the chain each frame.
`newChainState` builds one instance of chain-local state per loaded preset,
never module-global, so switching presets cannot leak a previous preset's
canvases or dimensions. `ALL_DEFAULTS` is built in layers: each pass's declared
`initial`, then the preset's own `parameter_overrides`, then (in `activate`) the
player's `shaderfxParams` edits.
Sizes resolve through `resolveScale`, which handles all four slang scale types
(`absolute`, `viewport`, `source`, `original`) against the viewport, the pass's
input dimensions and the original frame. Size uniforms are packed as
`{w, h, 1/w, 1/h}`, the slang convention.
`runPass` caches two things per `(state, pass index)`. The **shader** is
compiled once for the state's lifetime, along with the fragment manifest it was
compiled against, since a pass's GLSL depends only on the preset and never on
per-frame input. The **canvas** is reallocated only when the pass's resolved
size actually changes, a window resize or a different preset. Every harness this
runtime was ported from ran the chain once and quit, so allocating a fresh
canvas and compiling a fresh shader on every call was invisible there. On a real
per-frame render path it is one GPU allocation per pass per frame, and a shader
recompile on top. The same discipline applies to the crop canvas in
`cropToGbSource`, which is called once per frame and whose size grows with the
world canvas as the player zooms out; leaving it uncached was a real cost that
scaled with zoom level even with a single preset active.
Sampler binding is by semantic, from the bridge's classification, never by a
hardcoded per-preset name check: `Source` is the previous pass's output (or the
input frame for pass 0), `Original` and `OriginalHistory` are the input frame,
`PassOutput` indexes an earlier pass's canvas, and `User` resolves a LUT by the
real name the translation reported. A sampler that resolves to nothing asserts
rather than drawing garbage.
**LUTs** are loaded once per `activate`. `ShaderFX.loadImageFromPath` reads the
bytes with plain `io.open` and goes through `love.data.newByteData` and
`love.image.newImageData`, because a preset's texture paths are arbitrary
absolute OS paths outside any LOVE mount and `love.graphics.newImage` refuses
those outright ("Could not open file ... Does not exist") even when the file is
real. Wrap modes are mapped from librashader's names to LOVE's
(`clamp_to_border` to `clampzero`, `clamp_to_edge` to `clamp`, `repeat`,
`mirrored_repeat` to `mirroredrepeat`). A LUT that fails to load is logged and
left nil; the fail-loud point is the sampler assertion in `runPass` that
actually needed it, not the loader.
**History ring.** `OriginalHistoryN` currently resolves to a steady state: every
history slot reads the current frame, both for the sampler binding and for the
size uniform. Real per-frame history rotation has been proven out in a desktop
harness but is not wired into this path.
**Feedback.** `PassFeedback` is not implemented. A `PassFeedback` or `User` size
uniform raises an explicit "not yet supported" error, and a `PassFeedback`
sampler resolves to nothing and trips the binding assertion. No preset in the
shipped shortlist uses it.
**Blending.** Every pass draws with `replace`, and the chain's final pass uses
`replace, premultiplied`. Intermediate canvases are `nearest` filtered.
`ShaderFX.render(canvas, rect, source, dpiX, dpiY)` is the entry point
`Renderer:endFrame` and `Game2` call. `canvas` is the finished window-sized
composite (world, UI, and any post-process pipeline that already ran); `rect` is
this frame's real playfield rectangle in physical framebuffer pixels and
`source` is the real pixel size of the content it frames. The sequence is: crop
`rect` out of the composite, run whichever slots are active over that crop, draw
the untouched composite, then stretch the chain output back over `rect`. UI and
letterbox bars outside the playfield pass through untouched. If the chain throws,
the frame still shows the unprocessed composite; a broken preset degrades to
"shader off", never to a crash or a blank frame.
Three details in that path are non-obvious:
- **DPI.** `love.graphics.newCanvas` and `draw` work in LOVE's DPI-aware
logical units, not raw pixels, so the viewport handed to the pass graph and
the final draw-back position are both converted from `rect`'s physical pixels
first. On a `dpiscale = 1` desktop the two are numerically identical and the
bug is invisible; at dpiscale 3 on real Android hardware the chain output
rendered about three times too large and at a pixel-valued offset in unit
space.
- **Draw color.** `cropToGbSource` sets `setColor(1, 1, 1, 1)` explicitly. The
caller can leave the draw color dirty (a menu's black text leaves it at
`(0,0,0,x)`), the crop draw multiplies the canvas texels by the active color,
and `push("all")` saves state for `pop()` without resetting it. That was the
root cause of the Gen 2 blank-menu bug, confirmed on a desktop repro where
`getColor()` read `0,0,0,1` here exactly when a menu was on the stack.
`flushBatch()` on the line above is cheap insurance against a read-after-write
ordering hazard between this draw and whatever last rendered into the canvas;
it was never confirmed to fix anything on its own.
- **The final blit stretches.** A slang chain's last pass is not required to
land on the viewport size, and most presets (21 of the 78 in the corpus)
declare their last pass `scale_type = "source"` and stay at native Game Boy
resolution, relying on the frontend's blit exactly as RetroArch does.
Requiring an exact size match here used to skip the draw outright for every
such preset on every frame, which is a silent total no-op rather than a sizing
quirk. The stretch uses the last-run chain's own final-pass `filter` to pick
nearest or linear.
### What this engine feeds shaders that a libretro core does not
A stock libretro core hands its frontend a raw framebuffer and a frame count.
This engine has more context available and passes some of it through.
| Context | How it reaches the shader |
| --- | --- |
| Playfield rect and true source size | `rect`/`source` per frame from `Renderer:endFrame` or `Game2`, so the chain sees real on-screen geometry at any survey zoom or Faithful Ratio state rather than a fixed 160x144 assumption that then gets stretched |
| Blit scale | `rect.scale`, the crisp integer scale the composite was built at, used to derive the crop's own draw scale |
| SGB zone coloring and palette | Baked into the input frame. `PaletteFX` zone passes run before the composite reaches ShaderFX, so a preset shades an already-zone-tinted image |
| Performance tier | `chainRenderScale()` reads `Performance.CAPS[tier].shaderfx`, a chain-resolution multiplier; the viewport and the cropped source both shrink by it and the final blit upscales |
| Accelerometer | `Sensors.read("accelerometer")`, bound to the `Accelerometer` unique semantic |
| Gyroscope | `Sensors.read("gyroscope")`, bound to `Gyroscope`, plus the integrated yaw twist below |
Two motion semantics are deliberately pinned rather than guessed. `Rotation` is
bound to 0 because librashader's own documentation is explicit that it is
`retroarch_get_rotation()`, the *content's* requested rotation (a vertically
oriented arcade core, say), not device orientation. Nothing here ever rotates
Game Boy content, so 0 is the correct answer, not a placeholder.
`AccelerometerRest` is bound to `{0, 0, 0}`: it is librashader's "reading at
rest" calibration reference, no preset in the corpus reads it, and a fixed
placeholder beats an invented value.
`src/core/Sensors.lua` is what makes the two real motion semantics work.
`love.sensor` does not exist in LOVE 11.5, the version this project ships, on
any platform including Android; it is a LOVE 12 addition. The working path is
raw FFI into the SDL2 that LOVE already links, the same technique
`src/core/Orientation.lua` uses, opening the first `SDL_SENSOR_ACCEL` or
`SDL_SENSOR_GYRO` device via `SDL_NumSensors`/`SDL_SensorGetDeviceType`/
`SDL_SensorOpen`. The `love.sensor` path is kept above it and simply stops being
dead code after a future LOVE 12 upgrade. Loading order matters:
`ffi.load("SDL2")` first, needed on desktop where SDL2 is a separate DLL, then
bare `ffi.C`, needed on Android where love-android links SDL2 statically into
`libmain.so` and there is no `libSDL2.so` for `ffi.load` to find by name. A
device with no sensor is probed once and then permanently reports zeros, so a
desktop run does not pay for it every frame.
SDL keeps sensor readings in the device's fixed chassis frame regardless of
screen orientation, so `rotateForScreen` remaps x and y into
"as currently displayed" terms using `SDL_GetDisplayOrientation`. That
compensation is mobile-only: a desktop monitor is legitimately and permanently
"landscape" to that query, which says something about the monitor's shape and
nothing about how a player is holding anything.
The accelerometer path in `sizeTable` does three things to the raw reading
before it becomes a uniform, all of them driven by real on-device data:
1. **Rest-pose subtraction.** `activate()` snapshots whatever pose the player is
actually holding the device in and every later reading is measured relative
to that, rather than to an assumed idealized vertical. The shipped tilt maths
(`pt_base.inc`'s `getOrientedTilt`) was authored assuming gravity sits almost
entirely on one axis at rest; a natural, comfortable hold already puts 56 to
66 percent of gravity's magnitude on the axis the shader reads as tilt, so
the effect sat near-saturated all the time instead of starting near neutral.
2. **Axis swap.** The tilt maths assumes a device resting flat, with gravity
dominant on Z, the one axis it never reads. This engine's rest pose is
upright portrait, where Y is gravity-dominant, so y and z are swapped to put
gravity back on the ignored axis.
3. **Denominator stabilization.** `getOrientedTilt` normalizes by the full
vector's magnitude. Before calibration that magnitude was a stable ~9.8 that
quietly damped tilt and noise alike by the same factor; calibration correctly
zeroes x and y at neutral but also shrinks the magnitude near rest, and real
logs showed it swinging between 0.65 and 11.4 second to second on ordinary
hand jitter, which reads as wildly bouncing. A fixed constant is re-injected
on the ignored axis to keep the denominator stable, but only when there is a
genuine live reading to calibrate against. An all-zero raw read is
`Sensors.lua`'s explicit "no hardware at all" sentinel, never a real value on
Earth, and injecting into that case would make the shader believe it had
sensor data and silently replace its own static fallback with fake motion.
Yaw is a separate mechanism. A raw gyroscope reading is angular velocity, not an
angle, so it only becomes a usable on-screen offset by integrating over time,
and only the per-slot state persists frame to frame to do that. `updateYawTwist`
is deliberately a decaying spring rather than a true integrated heading:
gyro-only integration drifts without a magnetometer to correct it, so this
settles back toward neutral and stays bounded by construction. It is folded onto
the accelerometer's x component before the shader's own normalize and clamp,
because that is the only already-compiled channel the stock upstream maths
reads, and reaching an already-converted artifact with no reconvert was worth
the tradeoff that the twist reads as an added simulated tilt rather than a
cleanly separate motion. It applies to `sunlight_shimmer.slangp` only, the one
preset in the shortlist with a twist-reactive channel. `YAW_GAIN = 0.6` and a
clamp of +/-2 were tuned against real device data (a moderate real yaw turn
peaks around 1.5 to 1.7 rad/s); a much larger gain was tried on-device and
looked worse, because overshooting a comfortable range reads worse than being
subtle. Retune in small steps with real device checks, not big jumps.
## Two slots
`ShaderFX.SLOTS` is `{"main", "secondary"}` and `ShaderFX.OPTION_KEY` maps each
to its save key. The slots are activated and persisted independently, and the
same `ShaderFXScreen` serves both, opened with the slot as its argument. Pragma
parameter edits are keyed by *preset name*, not by slot, because a preset's
values are a property of the preset the same way its cached artifact is;
editing them re-activates every slot currently showing that preset and persists
for the next load in either.
When both slots are active, `render` runs main's chain first and hands its
finished output to secondary as secondary's own input frame, along with its
dimensions, so a secondary preset that scales off its input sees main's real
output size rather than the original crop. Either slot alone behaves exactly as
a single-preset path; neither active is a plain passthrough.
**This is not how RetroArch composes multiple presets.** RetroArch merges
presets into a *single* pass list through `#reference` and `Append`, producing
one pass graph with one shared semantics map, where a later pass can reference
an earlier one's output by alias and the whole thing resolves as one unit. Two
slots here are two independent librashader chains run back to back, which is
what stacking two separate preset chains would give you, not what merging them
gives you. Presets that assume merged semantics will not behave the same way.
## Test seams
`ShaderFX` exposes a few fields purely so a headless harness can assert on real
per-frame values without taking a screenshot: `_lastRect` and `_lastSource`
(the rect and source dimensions a caller handed in), `_lastCrop` (the exact crop
canvas, which the later unconditional draw-back would otherwise mask),
`_lastYawTwist` and `_lastAccelPacked` (the integrated twist and the values that
actually reached the packed uniform, per slot). `Sensors.setOverride`,
`Sensors.clearOverride` and `Sensors.setOrientationOverride` inject synthetic
readings on a machine with no hardware.
## Limitations
None of these are theoretical.
- **Tested on very little real hardware.** Essentially one Android phone, one
desktop, and the automated harnesses. Anything about how a preset actually
looks or performs elsewhere is unverified.
- **No performance tier is actually tuned.** The chain-resolution multiplier in
`Performance.CAPS` is a working mechanism, but every tier that permits
ShaderFX at all sets it to 1.0. Nothing runs at reduced chain resolution
today. Picking a real value for weak hardware needs a device this project does
not have.
- **The dual-slot design does not match RetroArch.** See above. Two chains in
sequence is not one merged pass list.
- **`OriginalHistoryN` is a steady state.** Real per-frame history rotation
falls back to "every slot is the current frame" in the live render path, and
is unverified there.
- **`PassFeedback` is unimplemented.** Its size uniform raises an explicit
error and its sampler trips an assertion.
- **`ShaderFXScreen` has a known text-overlap bug on long preset names.**
`ListMenu`'s `fitLabel` truncation covers the ordinary case, but a long enough
player-supplied filename still collides with the row's right-hand hint.
- **`ShaderSourcePatches` ships with an empty patch table and nothing uses it.**
Intentional, for the reason given above, but it means the mechanism has no
live coverage.
- **Cached artifacts have no staleness detection.** Existence is the only check.
The two unconditional reconvert points paper over it; anything that does not
go through them can be running a stale translation.
- **Artifacts are per-device.** The GLSL dialect is baked in at convert time.
Copying a converted preset folder between a phone and a desktop copies a
wrong artifact along with it.
- **The bundled bridge is only as good as the build machine.**
`scripts/build.sh` bundles the cdylib for mac, win and linux via
`bundle_shader_bridge`, building it with cargo when a prebuilt one is not
supplied through `SHADERFX_BRIDGE`. A build host without cargo produces a
package that can run converted presets but cannot CONVERT new ones, and says
so rather than failing. Android ships the `.so` via `jniLibs`.
- **The buildbot shortlist is a temporary trim.** `KEPT_PRESETS` reflects one
manual pass over `handheld/` and is expected to change, most likely to shrink.
- **Tilt direction is unverified.** Which way forward and back rocking moves the
effect was never confirmed on a device; if it feels backwards the fix is a
sign flip on the swapped axis, not a deeper bug. Likewise, whether the
landscape rotation compensation matches real RetroArch is genuinely unknown:
RetroArch's Android input driver computes a screen rotation but does not
visibly apply it to the accelerometer values that reach shader uniforms, so
this project's compensation may be an improvement over upstream rather than a
match to it.
- **`texelFetch` wrap behavior at image edges may differ.** The ES 1.00 rewrite
in the bridge turns those calls into `texture()`, which honours the sampler's
wrap mode out of range where `texelFetch`'s out-of-bounds behavior is
implementation defined. Unmeasured.
-1
View File
@@ -183,7 +183,6 @@ hotkeys (`2`/`3`/`5` are claimed before any mod pipeline hotkey runs).
| ----- | -------------- | ------------------- |
| Select + **A** | `2` | COLORS |
| Select + **B** | `3` | TILT |
| Select + **Y** | `5` | GBC FX |
| Select + **X** | `6` | Mod pipeline hotkey (if a mod registers `6`) |
| Select + **L** | `7` | Mod pipeline hotkey (if a mod registers `7`) |
+4 -4
View File
@@ -22,8 +22,8 @@ Player install (what to download, title override) stays in
| Loose iteration pair | `sdmc:/switch/gen1recomp/gen1recomp.nro` **and** `game.love` beside it |
| ROM inbox | LÖVE save dir → `imports/` (launcher shows the live `getSaveDirectory()` path; under MTP often `1: SD Card/<save identity>/imports/`) |
| Mod zip inbox | Same save dir → `imports/mods/` then MODS → **Scan again** |
| Save `.sav` inbox | Same save dir → `imports/saves/red\|blue\|yellow\|gold\|silver/` then that game's SAVE FILES → **Import save** (Gold / Silver cart `.sav` not supported yet) |
| Save exports | Same save dir → `exports/red\|blue\|yellow\|gold\|silver/` (pull after **Export save**; Gold / Silver cart `.sav` not supported yet) |
| Save `.sav` inbox | Same save dir → `imports/saves/red\|blue\|yellow\|gold\|silver\|crystal/` then that game's SAVE FILES → **Import save** (Gen 2 cart `.sav` not supported yet, on Gold, Silver or Crystal) |
| Save exports | Same save dir → `exports/red\|blue\|yellow\|gold\|silver\|crystal/` (pull after **Export save**; Gen 2 cart `.sav` not supported yet, on Gold, Silver or Crystal) |
| Opt-in diagnostics | Empty `switch-debug.txt` in the save dir → `switch.log` |
| Lua error log | `lua-error.log` in the save dir |
@@ -54,8 +54,8 @@ macOS, not a Mac-only requirement.
3. Create `switch/gen1recomp/` if needed; extract the release zip at SD root
(or copy NRO / `game.love` for loose).
4. For ROMs/mods/saves, open the save-dir `imports/`, `imports/mods/`,
`imports/saves/<red|blue|yellow|gold|silver>/`, or
`exports/<red|blue|yellow|gold|silver>/`
`imports/saves/<red|blue|yellow|gold|silver|crystal>/`, or
`exports/<red|blue|yellow|gold|silver|crystal>/`
path the launcher prints.
5. Wait for the queue; refresh; exit MTP responder; title-override launch.