2026-08-01 20:53:29 +02:00
|
|
|
-- menus.lua — narrates focus in the game's menu screens.
|
|
|
|
|
--
|
|
|
|
|
-- Most menu states keep their focus in file-local variables (`selectedOption`,
|
|
|
|
|
-- `optionsList`, ...) which are not reachable through the table the state returns.
|
|
|
|
|
-- They ARE reachable as upvalues of the state's own functions, via
|
|
|
|
|
-- debug.getupvalue.
|
|
|
|
|
--
|
|
|
|
|
-- Per docs/05-decisions.md (D3) this driver supports two interchangeable
|
|
|
|
|
-- backends, so we can move between them without rewriting per-screen logic:
|
|
|
|
|
--
|
|
|
|
|
-- mode "probe" -- read upvalues. Zero edits to game files. Depends on
|
|
|
|
|
-- internal variable names, so it is checked by the self-test.
|
|
|
|
|
-- mode "patched" -- game files call accessibility.announce() directly. Robust,
|
|
|
|
|
-- but must be re-applied on each game version.
|
|
|
|
|
--
|
|
|
|
|
-- The `render` function in each descriptor -- which holds all the per-screen
|
|
|
|
|
-- knowledge -- is shared by both. Switching modes is a config flag.
|
|
|
|
|
|
|
|
|
|
local speech = require("accessibility.speech")
|
|
|
|
|
local stateNames = require("accessibility.stateNames")
|
|
|
|
|
|
|
|
|
|
local menus = {}
|
|
|
|
|
|
|
|
|
|
menus.mode = "probe"
|
|
|
|
|
|
|
|
|
|
--------------------------------------------------------------------------------
|
|
|
|
|
-- Upvalue access
|
|
|
|
|
--------------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
local function getUpvalue(fn, name)
|
|
|
|
|
if type(fn) ~= "function" then
|
|
|
|
|
return nil
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
local i = 1
|
|
|
|
|
while true do
|
|
|
|
|
local n, v = debug.getupvalue(fn, i)
|
|
|
|
|
if not n then
|
|
|
|
|
return nil
|
|
|
|
|
end
|
|
|
|
|
if n == name then
|
|
|
|
|
return v, i
|
|
|
|
|
end
|
|
|
|
|
i = i + 1
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
menus.getUpvalue = getUpvalue
|
|
|
|
|
|
2026-08-01 22:00:52 +02:00
|
|
|
-- Find a file-local by name across ALL of a state's exported functions, and one
|
|
|
|
|
-- level into any inner functions they close over.
|
|
|
|
|
--
|
|
|
|
|
-- Safe because a Lua file-local is a single shared upvalue: every closure in that
|
|
|
|
|
-- file that references it sees the same variable, so whichever function we reach
|
|
|
|
|
-- it through yields the same value. This saves hand-picking a probe function per
|
|
|
|
|
-- descriptor, and survives the common case where the local we want is touched by
|
|
|
|
|
-- draw() or an inner helper rather than by keypressed().
|
|
|
|
|
function menus.getAnyUpvalue(stateTable, name)
|
|
|
|
|
if type(stateTable) ~= "table" then
|
|
|
|
|
return nil
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
local nested = {}
|
|
|
|
|
|
|
|
|
|
for _, fn in pairs(stateTable) do
|
|
|
|
|
if type(fn) == "function" then
|
|
|
|
|
local value = getUpvalue(fn, name)
|
|
|
|
|
if value ~= nil then
|
|
|
|
|
return value
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
local i = 1
|
|
|
|
|
while true do
|
|
|
|
|
local upName, upValue = debug.getupvalue(fn, i)
|
|
|
|
|
if not upName then
|
|
|
|
|
break
|
|
|
|
|
end
|
|
|
|
|
if type(upValue) == "function" then
|
|
|
|
|
nested[#nested + 1] = upValue
|
|
|
|
|
end
|
|
|
|
|
i = i + 1
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
for _, fn in ipairs(nested) do
|
|
|
|
|
local value = getUpvalue(fn, name)
|
|
|
|
|
if value ~= nil then
|
|
|
|
|
return value
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
return nil
|
|
|
|
|
end
|
|
|
|
|
|
2026-08-01 20:53:29 +02:00
|
|
|
-- IMPORTANT: states.lua loads states with require("states/"..name) -- a SLASH.
|
|
|
|
|
-- require() caches on the exact string, so require("states.main_menu") would load
|
|
|
|
|
-- a SECOND, independent copy of the file with its own upvalues, and we would be
|
|
|
|
|
-- reading focus from a state the player is not interacting with. Always match the
|
|
|
|
|
-- game's spelling.
|
|
|
|
|
local function getStateTable(stateName)
|
|
|
|
|
local ok, stateTable = pcall(require, "states/" .. stateName)
|
|
|
|
|
if not ok then
|
|
|
|
|
return nil
|
|
|
|
|
end
|
|
|
|
|
return stateTable
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
menus.getStateTable = getStateTable
|
|
|
|
|
|
2026-08-01 22:00:52 +02:00
|
|
|
-- Does a file-local of this name EXIST, regardless of its current value?
|
|
|
|
|
--
|
|
|
|
|
-- getUpvalue returns the value, so it cannot tell "no such local" from "local
|
|
|
|
|
-- exists but is currently nil". That distinction matters for the self-test:
|
|
|
|
|
-- several states declare `local options` at the top and only assign it in
|
|
|
|
|
-- open(), so before the screen is first visited the value is legitimately nil.
|
|
|
|
|
-- Treating that as a broken probe produced a false alarm on worldmap.
|
|
|
|
|
function menus.hasUpvalue(stateTable, name)
|
|
|
|
|
if type(stateTable) ~= "table" then
|
|
|
|
|
return false
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
local function scan(fn)
|
|
|
|
|
local i = 1
|
|
|
|
|
while true do
|
|
|
|
|
local upName = debug.getupvalue(fn, i)
|
|
|
|
|
if not upName then
|
|
|
|
|
return false
|
|
|
|
|
end
|
|
|
|
|
if upName == name then
|
|
|
|
|
return true
|
|
|
|
|
end
|
|
|
|
|
i = i + 1
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
local nested = {}
|
|
|
|
|
|
|
|
|
|
for _, fn in pairs(stateTable) do
|
|
|
|
|
if type(fn) == "function" then
|
|
|
|
|
if scan(fn) then
|
|
|
|
|
return true
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
local i = 1
|
|
|
|
|
while true do
|
|
|
|
|
local upName, upValue = debug.getupvalue(fn, i)
|
|
|
|
|
if not upName then
|
|
|
|
|
break
|
|
|
|
|
end
|
|
|
|
|
if type(upValue) == "function" then
|
|
|
|
|
nested[#nested + 1] = upValue
|
|
|
|
|
end
|
|
|
|
|
i = i + 1
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
for _, fn in ipairs(nested) do
|
|
|
|
|
if scan(fn) then
|
|
|
|
|
return true
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
return false
|
|
|
|
|
end
|
|
|
|
|
|
2026-08-01 20:53:29 +02:00
|
|
|
--------------------------------------------------------------------------------
|
|
|
|
|
-- Descriptors
|
|
|
|
|
--------------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
local descriptors = {}
|
|
|
|
|
|
|
|
|
|
local function loadDescriptor(stateName)
|
|
|
|
|
if descriptors[stateName] ~= nil then
|
|
|
|
|
return descriptors[stateName] or nil
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
local ok, descriptor = pcall(require, "accessibility.states." .. stateName)
|
|
|
|
|
descriptors[stateName] = ok and descriptor or false
|
|
|
|
|
return descriptors[stateName] or nil
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
menus.loadDescriptor = loadDescriptor
|
|
|
|
|
|
|
|
|
|
--------------------------------------------------------------------------------
|
|
|
|
|
-- Reading focus
|
|
|
|
|
--------------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
-- Returns: text to speak, focus key (for change detection).
|
|
|
|
|
local function readFocus(stateName)
|
|
|
|
|
local descriptor = loadDescriptor(stateName)
|
|
|
|
|
if not descriptor then
|
|
|
|
|
return nil, nil
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
local stateTable = getStateTable(stateName)
|
|
|
|
|
if not stateTable then
|
|
|
|
|
return nil, nil
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
-- A descriptor may take full control, for screens that aren't a simple list
|
|
|
|
|
-- (text entry, tabbed pages).
|
|
|
|
|
if descriptor.read then
|
|
|
|
|
local ok, text, key = pcall(descriptor.read, stateTable, menus)
|
|
|
|
|
if ok then
|
|
|
|
|
return text, key
|
|
|
|
|
end
|
|
|
|
|
return nil, nil
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
local probe = descriptor.probe
|
|
|
|
|
if not probe then
|
|
|
|
|
return nil, nil
|
|
|
|
|
end
|
|
|
|
|
|
2026-08-01 22:00:52 +02:00
|
|
|
-- Try the named probe function first, then fall back to searching every
|
|
|
|
|
-- function in the state. Several states (actions_menu, worldmap) build their
|
|
|
|
|
-- list in open() and never mention it in keypressed, so the named function
|
|
|
|
|
-- alone finds nothing.
|
2026-08-01 20:53:29 +02:00
|
|
|
local probeFn = stateTable[probe.fn or "keypressed"]
|
2026-08-01 22:00:52 +02:00
|
|
|
|
2026-08-01 20:53:29 +02:00
|
|
|
local index = getUpvalue(probeFn, probe.focus)
|
2026-08-01 22:00:52 +02:00
|
|
|
if index == nil then
|
|
|
|
|
index = menus.getAnyUpvalue(stateTable, probe.focus)
|
|
|
|
|
end
|
|
|
|
|
|
2026-08-01 20:53:29 +02:00
|
|
|
local list = getUpvalue(probeFn, probe.list)
|
2026-08-01 22:00:52 +02:00
|
|
|
if list == nil then
|
|
|
|
|
list = menus.getAnyUpvalue(stateTable, probe.list)
|
|
|
|
|
end
|
2026-08-01 20:53:29 +02:00
|
|
|
|
|
|
|
|
if type(index) ~= "number" or type(list) ~= "table" then
|
|
|
|
|
return nil, nil
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
local entry = list[index]
|
|
|
|
|
if entry == nil then
|
|
|
|
|
return nil, nil
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
local total = #list
|
|
|
|
|
local ok, text = pcall(descriptor.render, entry, index, total)
|
|
|
|
|
if not ok or not text then
|
|
|
|
|
return nil, nil
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
return text, tostring(index) .. "/" .. tostring(total)
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
menus.readFocus = readFocus
|
|
|
|
|
|
|
|
|
|
--------------------------------------------------------------------------------
|
|
|
|
|
-- Announcing
|
|
|
|
|
--------------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
local lastKey
|
|
|
|
|
local lastState
|
|
|
|
|
local justSwitched = false
|
|
|
|
|
|
|
|
|
|
-- Called after a state switch: announce the screen name only.
|
|
|
|
|
--
|
|
|
|
|
-- Focus is deliberately left to the poll in the update hook rather than read
|
|
|
|
|
-- here. A state's open() may set focus after the switch returns, and
|
|
|
|
|
-- mousemoved can override it on the very first frame -- so reading now would
|
|
|
|
|
-- often announce the wrong entry. The poll picks it up a frame later, and
|
|
|
|
|
-- `justSwitched` makes that first announcement non-interrupting so it appends
|
|
|
|
|
-- to the screen name ("Main menu. Continue, 1 of 7") instead of cutting it off.
|
|
|
|
|
function menus.announceScreen(stateName)
|
|
|
|
|
lastState = stateName
|
|
|
|
|
lastKey = nil
|
|
|
|
|
justSwitched = true
|
|
|
|
|
|
2026-08-02 01:04:03 +02:00
|
|
|
-- Descriptors that suppress repetition across reads (journal only leads with
|
|
|
|
|
-- the tab or the entry title when it CHANGED) keep that memory in file-locals,
|
|
|
|
|
-- which outlive the screen. Without this, reopening the journal on the entry
|
|
|
|
|
-- you left it on would read the page text with no title in front of it.
|
|
|
|
|
local descriptor = loadDescriptor(stateName)
|
|
|
|
|
if descriptor and descriptor.reset then
|
|
|
|
|
pcall(descriptor.reset)
|
|
|
|
|
end
|
|
|
|
|
|
2026-08-01 20:53:29 +02:00
|
|
|
speech.say(stateNames[stateName] or stateName, true)
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
-- Called after a keypress: announce the focused entry if it changed.
|
|
|
|
|
function menus.announceFocusIfChanged(stateName)
|
|
|
|
|
if stateName ~= lastState then
|
|
|
|
|
lastState = stateName
|
|
|
|
|
lastKey = nil
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
local text, key = readFocus(stateName)
|
|
|
|
|
if not text then
|
|
|
|
|
return
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
if key ~= nil and key == lastKey then
|
|
|
|
|
return
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
lastKey = key
|
|
|
|
|
|
|
|
|
|
-- First announcement after a switch appends to the screen name; later ones
|
|
|
|
|
-- interrupt, so holding an arrow key doesn't queue a backlog of options.
|
|
|
|
|
speech.say(text, not justSwitched)
|
|
|
|
|
justSwitched = false
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
-- Force a re-read, e.g. for a "where am I" key.
|
|
|
|
|
function menus.repeatFocus(stateName)
|
|
|
|
|
local text = readFocus(stateName)
|
|
|
|
|
if text then
|
|
|
|
|
speech.say(text, true)
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
function menus.resetFocusTracking()
|
|
|
|
|
lastKey = nil
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
--------------------------------------------------------------------------------
|
|
|
|
|
-- Self test — the version-bump canary
|
|
|
|
|
--------------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
-- Reports, for every descriptor we ship, whether its probe still resolves against
|
|
|
|
|
-- the current game code. A renamed local shows up here at startup instead of as
|
|
|
|
|
-- silence three screens into a playthrough.
|
|
|
|
|
function menus.selfTest(stateList)
|
|
|
|
|
local results = {}
|
|
|
|
|
|
|
|
|
|
for _, stateName in ipairs(stateList) do
|
|
|
|
|
local descriptor = loadDescriptor(stateName)
|
|
|
|
|
if not descriptor then
|
|
|
|
|
results[stateName] = "no descriptor"
|
|
|
|
|
elseif descriptor.read then
|
2026-08-03 16:05:15 +02:00
|
|
|
-- A read() descriptor has no probe table, so it would otherwise lose
|
|
|
|
|
-- the version-bump canary entirely -- yet those are exactly the
|
|
|
|
|
-- screens that reach for the most upvalues. `probeNames` lets one
|
|
|
|
|
-- declare which game locals it depends on and get the same check.
|
|
|
|
|
local names = descriptor.probeNames
|
|
|
|
|
if type(names) ~= "table" then
|
|
|
|
|
results[stateName] = "custom read()"
|
|
|
|
|
else
|
|
|
|
|
local stateTable = getStateTable(stateName)
|
|
|
|
|
if not stateTable then
|
|
|
|
|
results[stateName] = "STATE NOT LOADABLE"
|
|
|
|
|
else
|
|
|
|
|
local missing
|
|
|
|
|
for _, name in ipairs(names) do
|
|
|
|
|
if not menus.hasUpvalue(stateTable, name) then
|
|
|
|
|
missing = name
|
|
|
|
|
break
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
if missing then
|
|
|
|
|
results[stateName] = "PROBE BROKEN: no upvalue '" .. tostring(missing) .. "'"
|
|
|
|
|
else
|
|
|
|
|
local first = menus.getAnyUpvalue(stateTable, names[1])
|
|
|
|
|
results[stateName] = first == nil
|
|
|
|
|
and "custom read(), ok (unset until opened)"
|
|
|
|
|
or "custom read(), ok"
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
end
|
2026-08-01 20:53:29 +02:00
|
|
|
else
|
|
|
|
|
local stateTable = getStateTable(stateName)
|
|
|
|
|
if not stateTable then
|
|
|
|
|
results[stateName] = "STATE NOT LOADABLE"
|
|
|
|
|
else
|
2026-08-01 22:00:52 +02:00
|
|
|
-- Check that the NAMES exist, not that they currently hold values.
|
|
|
|
|
-- Most of these are assigned in open(), so before the screen has
|
|
|
|
|
-- been visited they are legitimately nil.
|
|
|
|
|
local hasFocus = menus.hasUpvalue(stateTable, descriptor.probe.focus)
|
|
|
|
|
local hasList = menus.hasUpvalue(stateTable, descriptor.probe.list)
|
2026-08-01 20:53:29 +02:00
|
|
|
|
2026-08-01 22:00:52 +02:00
|
|
|
if not hasList then
|
2026-08-01 20:53:29 +02:00
|
|
|
results[stateName] = "PROBE BROKEN: no upvalue '" .. tostring(descriptor.probe.list) .. "'"
|
2026-08-01 22:00:52 +02:00
|
|
|
elseif not hasFocus then
|
|
|
|
|
results[stateName] = "PROBE BROKEN: no upvalue '" .. tostring(descriptor.probe.focus) .. "'"
|
2026-08-01 20:53:29 +02:00
|
|
|
else
|
2026-08-01 22:00:52 +02:00
|
|
|
local list = menus.getAnyUpvalue(stateTable, descriptor.probe.list)
|
|
|
|
|
results[stateName] = list == nil and "ok (unset until opened)" or "ok"
|
2026-08-01 20:53:29 +02:00
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
return results
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
return menus
|