Files

401 lines
12 KiB
Lua
Raw Permalink Normal View History

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
-- 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
-- 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
elseif not descriptor.probe then
-- Neither read() nor probe: a descriptor that only speaks on entry.
-- ingame_targeting_melee is one -- it has no focus to track because the
-- game keeps no target state at all. Nothing to canary here, but it must
-- be handled explicitly rather than falling through and indexing a nil
-- probe, which would throw and cost us the whole self test.
results[stateName] = descriptor.onOpen and "onOpen only" or "nothing to read"
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