Add graphics performance tier for low-end devices

Introduce an OPTIONS -> PERFORMANCE setting that scales the port's
optional presentation extras down for weaker hardware, so older/lower-end
devices can run the game smoothly.

The tier governs the three heaviest non-faithful extras -- the 3D TILT,
the GBC FX post-process shader, and survey ZOOM (which renders connected
neighbor maps) -- plus a hard FPS ceiling. It never touches game logic,
which is fixed-step off dt, so every tier plays identically.

- src/core/Performance.lua: tiers (auto/high/balanced/low), a conservative
  device auto-detect (ARM handhelds -> low, phones -> balanced, normal
  desktops -> high), per-tier caps, and the option-row cycle. Zero
  requires, like GameVersion.
- Game:applyOptions clamps the *live* presentation state against the tier
  without rewriting stored options, so a lower tier hides the player's
  TILT/GBC FX/ZOOM/FPS choices and a higher tier restores them exactly.
- Zoom.offsetRange floors the range at FIT when survey is disallowed, so
  the option row, hotkey, and mouse wheel all stop at close-up on LOW.
- New save.options.performance default "auto"; OPTIONS row heads the
  display group and re-applies live.
- Tests: tests/engine/performance_tiers.lua (ROM-free); mod_ui_tests row
  golden updated for the spliced row.

AUTO resolves to HIGH on a normal desktop and on every options.lua that
predates the option, so the common case is unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q6bFAiQyZ5jDmewsbB4LG9
This commit is contained in:
Claude
2026-07-31 01:44:17 +00:00
parent e58929428c
commit c8e035d332
10 changed files with 386 additions and 14 deletions
+11
View File
@@ -108,6 +108,17 @@ supported out of the box.
COLORS, TILT, ZOOM, GBC FX, and VOID FILL are also in the Options menu
and persist in `options.lua`.
### Low-end devices
**OPTIONS → PERFORMANCE** scales the port's optional extras for weaker
hardware: **HIGH** (everything on), **BALANCED** (no 3D tilt or GBC FX),
**LOW** (also no survey zoom, FPS capped), or **AUTO** — the default, which
picks a tier from your device (ARM handhelds → LOW, phones → BALANCED,
normal desktops → HIGH, unchanged). It only scales presentation; the
fixed-step game logic is identical on every tier, and a lower tier hides
your tilt/zoom/GBC-FX preferences without forgetting them. Details in
[docs/new-features.md](docs/new-features.md#performance-tier-low-end-devices).
### Rulesets
**OPTIONS → RULESET** picks which set of Gen 1 battle behaviors to run.
+7
View File
@@ -65,6 +65,13 @@ 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,
so the overworld runs smoothly on the H700 out of the box. Bump it to
BALANCED or HIGH from OPTIONS if you want the extras and your device keeps
up; see [Performance tier](new-features.md#performance-tier-low-end-devices).
The pack bundles the LÖVE 11.5 aarch64 runtime from
[PortMaster](https://portmaster.games/), so the device does not need a
separate `love_11.5` runtime download on first launch. The launcher resolves
+35
View File
@@ -137,6 +137,41 @@ effect, `=1` forces it available. The Anbernic handheld pack exports `0` from
its launcher because the device reports `"Linux"` while its GPU is in the
phone class (see [Anbernic RG34XXSP](anbernic-rg34xxsp.md)).
## Performance tier (low-end devices)
The Options **PERFORMANCE** row scales the port's optional presentation
extras down for weaker hardware. The extras it governs are the three
heaviest things the port adds on top of the original -- the whole-screen 3D
**TILT** (transforms the entire map as a ground plane), the **GBC FX**
post-process shader (a fullscreen pass), and survey **ZOOM** (zooming out
renders the connected neighbor maps, a lot of extra overdraw) -- plus a hard
FPS ceiling. None of this touches game logic, which is fixed-step off `dt`
(`src/core/FixedStep.lua`), so every tier plays identically; they differ
only in how much eye-candy the renderer is allowed to do.
| Tier | TILT | GBC FX | Survey ZOOM | Extra FPS ceiling |
| ------------ | ---- | ------ | ----------- | ----------------- |
| **HIGH** | on | on | on | none |
| **BALANCED** | off | off | on | none |
| **LOW** | off | off | off | 60 |
| **AUTO** | picks a default from the device (below) |||
- **AUTO** (the default) reads the device once at boot: ARM Linux handhelds
(e.g. the RG34XXSP) resolve to **LOW**, phones/tablets and very-low-core
desktops to **BALANCED**, and everything else -- a normal desktop, and
every existing `options.lua` that predates this option -- to **HIGH**,
so the common case is unchanged. See `src/core/Performance.detect`.
- AUTO only chooses the *default*; all four tiers are selectable, so a
wrong guess is one row away from being overridden.
- The clamps are applied **live** against your stored options and never
rewrite them (`Game:applyOptions`), so a lower tier hides your TILT / GBC
FX / ZOOM without forgetting them -- raising the tier restores exactly
what you had. (This is why the TILT / GBC FX / ZOOM rows still show your
saved choice on a clamped tier: it's your preference, waiting for a tier
that can afford it.)
- Persisted as `save.options.performance` (`auto` | `high` | `balanced` |
`low`); unit-tested in `tests/engine/performance_tiers.lua`.
## Peer-to-peer link play (lua-enet)
Trades and link battles connect two copies of the game directly over
+17
View File
@@ -618,6 +618,23 @@ function Game:applyOptions(opts)
-- normalizes a nil/garbage cap to the 60 default, so old saves with no
-- fpsCap key pace at the standard rate (issue #88)
require("src.core.FrameCap").applyOptions(opts)
-- Scale the optional presentation extras to the device's performance
-- tier. Every heavy feature was just applied from the stored options
-- above; here we clamp the *live* state down for a weaker device without
-- rewriting what the player saved, so raising the tier later restores
-- their exact TILT / GBC FX / ZOOM / MAX FPS choices. A HIGH tier (the
-- default on a normal desktop, and every options.lua predating this
-- option) clamps nothing, so it is a no-op for the common case.
local caps = require("src.core.Performance").applyOptions(opts)
if not caps.tilt then require("src.render.Tilt").setLevel(0) end
if not caps.gbcfx then require("src.render.GBCFX").setLevel(0) end
local Zoom = require("src.render.Zoom")
Zoom.allowSurvey = caps.survey
if not caps.survey and Zoom.offset < 0 then Zoom.offset = 0 end
if caps.fpsMax then
local FrameCap = require("src.core.FrameCap")
if FrameCap.current > caps.fpsMax then FrameCap.apply(caps.fpsMax) end
end
Input:applyBindings(opts.bindings)
-- heal soft-bricked APK installs that already saved gbcfx > 0 (#136)
if gbcCleared then self:writeOptions() end
+156
View File
@@ -0,0 +1,156 @@
-- Graphics performance tier: one knob that scales the port's optional,
-- non-faithful presentation extras down for weaker hardware.
--
-- The heavy extras are all things the Game Boy never had and this port
-- adds on top: the whole-screen 3D TILT (transforms the entire map as a
-- ground plane), the GBC FX post-process shader (a fullscreen pass), and
-- survey ZOOM (zooming out renders the connected neighbor maps -- a lot of
-- extra overdraw). A hard FPS ceiling caps present cost on top of that.
-- None of this touches game logic, which is fixed-step off dt
-- (src/core/FixedStep.lua), so every tier plays identically; they differ
-- only in how much optional eye-candy the renderer is allowed to do.
--
-- The tier is persisted as save.options.performance:
-- auto pick a default from the device (see detect())
-- high everything on -- the historical behavior
-- balanced no TILT, no GBC FX (kept: survey zoom, colors, uncapped FPS)
-- low no TILT, no GBC FX, no survey zoom, FPS capped
--
-- AUTO only chooses the *default* for the current device; every tier is
-- selectable in OPTIONS, so a heuristic that guesses wrong is one row away
-- from being overridden. The clamps are applied live in Game:applyOptions
-- against the stored options without rewriting them, so raising the tier
-- restores exactly the TILT / GBC FX / ZOOM / FPS the player had.
--
-- Zero requires: loads during love.conf and under plain Lua for tools and
-- tests, the same way src/core/GameVersion.lua does.
local Performance = {}
-- Option-row order (auto first, then most to least capable).
Performance.TIERS = { "auto", "high", "balanced", "low" }
Performance.LABELS = {
auto = "AUTO",
high = "HIGH",
balanced = "BALANCED",
low = "LOW",
}
-- What each concrete tier permits. `auto` is resolved to one of these
-- before caps are read, so it has no row here. fpsMax = false means no
-- extra ceiling (the player's own MAX FPS still applies).
Performance.CAPS = {
high = { tilt = true, gbcfx = true, survey = true, fpsMax = false },
balanced = { tilt = false, gbcfx = false, survey = true, fpsMax = false },
low = { tilt = false, gbcfx = false, survey = false, fpsMax = 60 },
}
-- Live resolved tier (never "auto"); Game:applyOptions sets it and the
-- renderer / options row read it back. Defaults to the unclamped tier so
-- a pre-boot launcher behaves exactly as it always did.
Performance.tier = "high"
local function loveOS()
if love and love.system and love.system.getOS then
return love.system.getOS()
end
return nil
end
local function processorCount()
if love and love.system and love.system.getProcessorCount then
return love.system.getProcessorCount()
end
return nil
end
-- CPU architecture via LuaJIT's jit.arch when present: "arm"/"arm64" on
-- phones and PortMaster handhelds, "x86"/"x64" on desktops. nil under a
-- plain-Lua tool or test with no jit table, which resolves to HIGH (no
-- clamping) so tooling is never surprised.
local function cpuArch()
return (jit and jit.arch) or nil
end
-- Default tier for the current device. Deliberately conservative: only
-- the platforms that are reliably weak drop below HIGH, and AUTO is always
-- overridable, so a wrong guess costs one OPTIONS row. A normal
-- multi-core desktop -- the common case, and every existing install whose
-- options.lua predates this option -- resolves to HIGH and behaves exactly
-- as before.
function Performance.detect()
local os = loveOS()
local arch = cpuArch()
local isArm = arch == "arm" or arch == "arm64"
local cores = processorCount()
-- PortMaster-style ARM Linux handhelds (e.g. the RG34XXSP the project
-- already ships a build for): the weakest target here.
if isArm and os ~= "Android" and os ~= "iOS" then
return "low"
end
-- Phones and tablets: GBC FX is already force-disabled here (issue #136);
-- balanced additionally drops the 3D tilt, the heaviest remaining extra.
if os == "Android" or os == "iOS" then
return "balanced"
end
-- Desktop: a dual-core (or single-core) box is the low-end line.
if cores and cores <= 2 then
return "balanced"
end
return "high"
end
-- Fold a stored option value to a concrete tier, resolving "auto" (and any
-- hand-edited garbage) through detect().
function Performance.resolve(value)
if value == "high" or value == "balanced" or value == "low" then
return value
end
return Performance.detect()
end
-- Normalize a stored value to a valid option id (keeps "auto"); a bad value
-- degrades to auto so a corrupt options.lua still lands on something sane.
function Performance.normalize(value)
for _, id in ipairs(Performance.TIERS) do
if value == id then return id end
end
return "auto"
end
-- Row label for a stored value: the tier's own name (AUTO / HIGH / ...).
function Performance.label(value)
return Performance.LABELS[Performance.normalize(value)]
end
-- Concrete caps for a stored option value (resolving auto).
function Performance.caps(value)
return Performance.CAPS[Performance.resolve(value)]
end
-- Resolve the tier for these options, record it live, and return its caps.
-- Called from Game:applyOptions, which clamps the live presentation modules
-- against the returned caps.
function Performance.applyOptions(opts)
local tier = Performance.resolve(opts and opts.performance)
Performance.tier = tier
return Performance.CAPS[tier]
end
-- Cycle the OPTIONS row: auto -> high -> balanced -> low -> auto (dir -1
-- reverses). Lua's `%` is non-negative for a positive modulus, so a
-- negative dir wraps correctly.
function Performance.cycle(value, dir)
value = Performance.normalize(value)
local i = 1
for idx, id in ipairs(Performance.TIERS) do
if id == value then i = idx break end
end
local n = #Performance.TIERS
local nextIdx = (i - 1 + (dir or 1)) % n + 1
return Performance.TIERS[nextIdx]
end
return Performance
+5
View File
@@ -241,6 +241,11 @@ function SaveData.defaultOptions()
videoMode = "windowed",
-- hard render frame-rate cap; render-only pacing (issue #88, FrameCap.lua)
fpsCap = 60,
-- graphics performance tier: auto | high | balanced | low. "auto"
-- picks a default from the device (ARM handhelds/phones drop the heavy
-- extras); scales TILT / GBC FX / survey ZOOM / FPS but never game
-- logic. See src/core/Performance.lua.
performance = "auto",
-- Per-pipeline display levels, keyed by render_pipelines id (see
-- src/render/Pipelines.lua). A level for a mod that is not installed
-- is kept rather than pruned, so re-enabling the mod restores the mode
+10
View File
@@ -10,6 +10,13 @@ local Zoom = {}
Zoom.offset = 0
-- Survey zoom (zooming out past FIT, which renders connected neighbor maps)
-- is the port's most expensive optional extra. The performance tier sets
-- this false on LOW hardware (Game:applyOptions); offsetRange then floors
-- the range at FIT so the option row, hotkey, and mouse wheel all stop at
-- close-up. Nil/true keeps the historical full range.
Zoom.allowSurvey = true
-- legal offset range for a given fit scale (vanilla: survey at 1 px/world
-- through 2× fit). zoom.range may widen or shrink the window.
function Zoom.offsetRange(S)
@@ -21,6 +28,9 @@ function Zoom.offsetRange(S)
hi = math.floor(tonumber(hi) or S)
if lo > hi then lo, hi = hi, lo end
end
-- LOW performance tier: no survey (negative offsets), even if a mod's
-- zoom.range widened it. == false so nil/true stays permissive.
if Zoom.allowSurvey == false and lo < 0 then lo = 0 end
return lo, hi
end
+16
View File
@@ -20,6 +20,7 @@ local GameSpeed = require("src.core.GameSpeed")
local GameVersion = require("src.core.GameVersion")
local VideoMode = require("src.core.VideoMode")
local FrameCap = require("src.core.FrameCap")
local Performance = require("src.core.Performance")
local Logger = require("src.core.Logger")
local Runtime = require("src.mods.Runtime")
local OptionRows = require("src.ui.OptionRows")
@@ -202,6 +203,21 @@ local function buildRows(game)
require("src.core.Music").setFilterLevel(o.musicFilter)
return true
end },
-- Heads the port's display group: one tier that scales the heavy extras
-- (TILT / GBC FX / survey ZOOM) and the FPS ceiling for weaker devices.
-- AUTO picks a default from the hardware; every tier is overridable.
-- Re-applies live so the extras clamp (or, on a higher tier, restore to
-- the player's stored TILT / GBC FX / ZOOM) the moment the row changes.
{ id = "performance", label = Strings("PERFORMANCE"),
value = function(g)
return Strings(Performance.label(g.save.options.performance))
end,
step = function(g, dir)
local o = g.save.options
o.performance = Performance.cycle(o.performance, dir)
g:applyOptions(o)
return true
end },
{ id = "colors", label = Strings("COLORS"),
value = function(g)
return PaletteFX.modeLabel(g.save.options.colors or "gbc")
+113
View File
@@ -0,0 +1,113 @@
-- Graphics performance tier (src/core/Performance.lua): the AUTO device
-- heuristic, tier normalization/labels, per-tier caps, the option-row
-- cycle, and applyOptions recording the live tier. Pure logic over
-- stubbed love/jit globals, so it needs no ROM and runs in the T2 tier.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local Performance = require("src.core.Performance")
-- detect() reads the live `love` and `jit` globals; swap them per scenario
-- and restore afterward so nothing leaks between checks (or into luajit).
local realLove, realJit = love, jit
local function device(os, arch, cores)
love = {
system = {
getOS = function() return os end,
getProcessorCount = function() return cores end,
},
}
jit = arch and { arch = arch } or nil
end
local function restore()
love, jit = realLove, realJit
end
-- ---------------------------------------------------------------- detect
device("Linux", "arm64", 4)
T.eq(Performance.detect(), "low", "ARM64 Linux handheld -> low")
device("Linux", "arm", 2)
T.eq(Performance.detect(), "low", "32-bit ARM Linux handheld -> low")
device("Android", "arm64", 8)
T.eq(Performance.detect(), "balanced", "Android phone -> balanced (not low)")
device("iOS", "arm64", 6)
T.eq(Performance.detect(), "balanced", "iOS -> balanced")
device("OS X", "x64", 8)
T.eq(Performance.detect(), "high", "8-core desktop -> high")
device("Windows", "x64", 2)
T.eq(Performance.detect(), "balanced", "dual-core desktop -> balanced")
device("Windows", "x86", 1)
T.eq(Performance.detect(), "balanced", "single-core desktop -> balanced")
-- No love, no jit (a plain-Lua tool/test): nothing looks weak -> high, so
-- tooling that runs applyOptions is never surprised by a clamp.
love, jit = nil, nil
T.eq(Performance.detect(), "high", "no love/jit -> high (no clamping)")
restore()
-- ---------------------------------------------------------------- resolve
T.eq(Performance.resolve("high"), "high", "resolve passes concrete high")
T.eq(Performance.resolve("balanced"), "balanced", "resolve passes balanced")
T.eq(Performance.resolve("low"), "low", "resolve passes low")
device("Linux", "arm64", 4)
T.eq(Performance.resolve("auto"), "low", "resolve auto -> detect (handheld)")
T.eq(Performance.resolve(nil), "low", "resolve nil -> detect")
T.eq(Performance.resolve("bogus"), "low", "resolve garbage -> detect")
restore()
-- --------------------------------------------------------------- normalize
T.eq(Performance.normalize("auto"), "auto", "normalize keeps auto")
T.eq(Performance.normalize("low"), "low", "normalize keeps low")
T.eq(Performance.normalize("xyz"), "auto", "normalize garbage -> auto")
T.eq(Performance.normalize(nil), "auto", "normalize nil -> auto")
-- ------------------------------------------------------------------- label
T.eq(Performance.label("low"), "LOW", "label low")
T.eq(Performance.label("balanced"), "BALANCED", "label balanced")
T.eq(Performance.label(nil), "AUTO", "label nil -> AUTO")
-- -------------------------------------------------------------------- caps
local hi, lo = Performance.CAPS.high, Performance.CAPS.low
T.check(hi.tilt and hi.gbcfx and hi.survey and not hi.fpsMax, "high caps: all on, no fps ceiling")
T.check((not lo.tilt) and (not lo.gbcfx) and (not lo.survey), "low caps: heavy extras off")
T.eq(lo.fpsMax, 60, "low caps: FPS ceiling of 60")
local bal = Performance.CAPS.balanced
T.check((not bal.tilt) and (not bal.gbcfx) and bal.survey and not bal.fpsMax,
"balanced caps: no tilt/gbcfx, survey kept, no fps ceiling")
device("Linux", "arm64", 4)
T.eq(Performance.caps("auto"), Performance.CAPS.low, "caps(auto) resolves through detect")
restore()
-- ------------------------------------------------------------- applyOptions
local caps = Performance.applyOptions({ performance = "low" })
T.eq(caps, Performance.CAPS.low, "applyOptions returns the resolved caps")
T.eq(Performance.tier, "low", "applyOptions records the live tier")
Performance.applyOptions({ performance = "high" })
T.eq(Performance.tier, "high", "applyOptions high tier")
device("Linux", "arm64", 4)
Performance.applyOptions(nil)
T.eq(Performance.tier, "low", "applyOptions(nil) resolves auto to the device tier")
restore()
-- ------------------------------------------------------------------- cycle
T.eq(Performance.cycle("auto", 1), "high", "cycle auto -> high")
T.eq(Performance.cycle("high", 1), "balanced", "cycle high -> balanced")
T.eq(Performance.cycle("balanced", 1), "low", "cycle balanced -> low")
T.eq(Performance.cycle("low", 1), "auto", "cycle low wraps to auto")
T.eq(Performance.cycle("auto", -1), "low", "cycle back auto -> low")
T.eq(Performance.cycle("high", -1), "auto", "cycle back high -> auto")
T.finish("performance_tiers")
+16 -14
View File
@@ -282,7 +282,8 @@ local function optGame()
end
local om = OptionsMenu.new(optGame())
local WANT_IDS = { "textSpeed", "animations", "battleStyle", "battleLayout",
"ruleset", "musicVol", "sfxVol", "musicFilter", "colors",
"ruleset", "musicVol", "sfxVol", "musicFilter",
"performance", "colors",
"tilt", "gbcfx", "zoom", "voidFill", "videoMode", "fpsCap",
"speed", "mods", "controls" }
check(#om.rows == #WANT_IDS, "vanilla options row count (plus MODS/CONTROLS)")
@@ -320,35 +321,36 @@ check(om.game.save.options.musicVol == 6, "music volume steps down")
for _ = 1, 10 do om.rows[6].step(om.game, -1) end
check(om.game.save.options.musicVol == 0, "music volume clamps at 0")
-- ZOOM / VOID FILL rows
-- ZOOM / VOID FILL rows (indices shifted +1 by the PERFORMANCE row spliced
-- in ahead of COLORS)
local Zoom = require("src.render.Zoom")
local TileRenderer = require("src.render.TileRenderer")
om.game.save.options.zoom = 0
Zoom.offset = 0
check(om.rows[12].value(om.game) == "FIT", "ZOOM row shows FIT at offset 0")
om.rows[12].step(om.game, 1)
check(om.rows[13].value(om.game) == "FIT", "ZOOM row shows FIT at offset 0")
om.rows[13].step(om.game, 1)
check(om.game.save.options.zoom == 1 and Zoom.offset == 1,
"ZOOM row steps to IN1")
om.rows[13].step(om.game, 1)
om.rows[14].step(om.game, 1)
check(om.game.save.options.voidFill == "water"
and TileRenderer.voidFill == "water",
"VOID FILL row cycles TREES → WATER")
om.rows[13].step(om.game, 1)
om.rows[14].step(om.game, 1)
check(om.game.save.options.voidFill == "black", "VOID FILL steps to BLACK")
om.rows[13].step(om.game, 1)
om.rows[14].step(om.game, 1)
check(om.game.save.options.voidFill == "trees", "VOID FILL wraps to TREES")
-- the MAX FPS row cycles the render-cap steps and shows the value plain
om.game.save.options.fpsCap = nil
check(om.rows[15].value(om.game) == "60",
check(om.rows[16].value(om.game) == "60",
"MAX FPS row defaults to 60 with no saved cap")
om.rows[15].step(om.game, 1)
om.rows[16].step(om.game, 1)
check(om.game.save.options.fpsCap == 75, "MAX FPS steps up from 60 to 75")
check(om.rows[15].value(om.game) == "75", "the MAX FPS row renders the cap")
check(om.rows[16].value(om.game) == "75", "the MAX FPS row renders the cap")
om.game.save.options.fpsCap = 160
om.rows[15].step(om.game, 1)
om.rows[16].step(om.game, 1)
check(om.game.save.options.fpsCap == 30, "MAX FPS wraps past the ceiling to 30")
om.rows[15].step(om.game, -1)
om.rows[16].step(om.game, -1)
check(om.game.save.options.fpsCap == 160, "MAX FPS wraps back down to the ceiling")
-- ------- FrameCap normalize / cycle (issue #88)
@@ -378,7 +380,7 @@ check(FrameCap.current == 60, "FrameCap.applyOptions defaults a missing key to 6
-- the MODS row is the manager's discoverable home
local mgGame = optGame()
om = OptionsMenu.new(mgGame)
om.rows[17].activate(mgGame)
om.rows[18].activate(mgGame)
check(getmetatable(mgGame.stack:top()) == ManagerState,
"the MODS row opens the manager")
check(mgGame.stack:top().screenId == "ManagerState",
@@ -388,7 +390,7 @@ check(mgGame.stack:top().screenId == "ManagerState",
local BindingsMenu = require("src.ui.BindingsMenu")
local cbGame = optGame()
om = OptionsMenu.new(cbGame)
om.rows[18].activate(cbGame)
om.rows[19].activate(cbGame)
local bm = cbGame.stack:top()
check(getmetatable(bm) == BindingsMenu,
"the CONTROLS row opens the rebind list")