-- 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 -- 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 -------------------------------------------------------------------------------- -- 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 local probeFn = stateTable[probe.fn or "keypressed"] local index = getUpvalue(probeFn, probe.focus) local list = getUpvalue(probeFn, probe.list) 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 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 results[stateName] = "custom read()" else local stateTable = getStateTable(stateName) if not stateTable then results[stateName] = "STATE NOT LOADABLE" else local probeFn = stateTable[descriptor.probe.fn or "keypressed"] local index = getUpvalue(probeFn, descriptor.probe.focus) local list = getUpvalue(probeFn, descriptor.probe.list) -- index is often nil until the screen is opened; the list existing -- is the real signal that the probe still points at something. if list == nil then results[stateName] = "PROBE BROKEN: no upvalue '" .. tostring(descriptor.probe.list) .. "'" elseif index == nil and not descriptor.probe.focusMayBeNil then results[stateName] = "probe partial: '" .. tostring(descriptor.probe.focus) .. "' nil (may be normal before open)" else results[stateName] = "ok" end end end end return results end return menus