-- 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 -- 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 -- 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 -- 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 -------------------------------------------------------------------------------- -- 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 -- 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. local probeFn = stateTable[probe.fn or "keypressed"] local index = getUpvalue(probeFn, probe.focus) if index == nil then index = menus.getAnyUpvalue(stateTable, probe.focus) end local list = getUpvalue(probeFn, probe.list) if list == nil then list = menus.getAnyUpvalue(stateTable, probe.list) end 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 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" else local stateTable = getStateTable(stateName) if not stateTable then results[stateName] = "STATE NOT LOADABLE" else -- 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) if not hasList then results[stateName] = "PROBE BROKEN: no upvalue '" .. tostring(descriptor.probe.list) .. "'" elseif not hasFocus then results[stateName] = "PROBE BROKEN: no upvalue '" .. tostring(descriptor.probe.focus) .. "'" else local list = menus.getAnyUpvalue(stateTable, descriptor.probe.list) results[stateName] = list == nil and "ok (unset until opened)" or "ok" end end end end return results end return menus