-- accessibility/init.lua — mod entry point. -- -- This is the ONLY thing the game calls into. main.lua gains exactly one line: -- -- require("accessibility").initialize() -- -- placed after console.prepare() and before the first states.switch(). By that -- point templates are preprocessed, keybindings are loaded, states.setup() has -- run, and the console canvas exists — everything we patch is ready. -- -- Everything here is wrapped so that a bug in the mod degrades to "no speech" -- rather than taking the game down with it. local accessibility = {} local speech = require("accessibility.speech") local stateNames = require("accessibility.stateNames") local hotkeys = require("accessibility.hotkeys") local walltones = require("accessibility.walltones") local menus = require("accessibility.menus") local testbed = require("accessibility.testbed") local testbedActive = false -- States we ship focus descriptors for. Also the list the self-test walks. local describedStates = { "main_menu", "settings_menu", "load_game", "new_game", "narration", "ingame_dialogue", "ingame_observe", "actions_menu", "worldmap", "alchemy_table", "journal", "character", "menu_text", "ingame_message", "menu_message", "inventory", "craft", "refine", "trade", "config_menu", } local initialized = false -------------------------------------------------------------------------------- -- Patch registry -- -- Later phases register their patches here. Each entry is {name, fn}; fn is called -- inside a pcall during initialize(), and a failure disables only that patch. -------------------------------------------------------------------------------- local patches = {} function accessibility.registerPatch(name, fn) patches[#patches + 1] = {name = name, fn = fn} end local patchResults = {} local function applyPatches() for _, patch in ipairs(patches) do local ok, err = pcall(patch.fn) patchResults[patch.name] = ok and "ok" or tostring(err) if not ok then print("[accessibility] patch '" .. patch.name .. "' FAILED: " .. tostring(err)) end end end -- Startup self-test output. This is the version-bump canary: when the game -- updates and something we hook has moved, it shows up here immediately rather -- than as silence three screens into a playthrough. function accessibility.selfTest() print("[accessibility] --- self test ---") print("[accessibility] TTS: " .. (speech.isAvailable() and "available" or ("UNAVAILABLE (" .. tostring(speech.getFailureReason()) .. ")"))) if speech.isAvailable() then -- Distinguishes "DLL loaded" from "actually reached a screen reader or SAPI". print("[accessibility] speech channel: " .. (speech.hasSpeech() and "yes" or "NO — nothing will be audible")) end for name, result in pairs(patchResults) do print("[accessibility] patch " .. name .. ": " .. result) end -- Menu focus probes. A renamed local in a future game version surfaces here at -- startup rather than as silence mid-playthrough. local ok, results = pcall(menus.selfTest, describedStates) if ok then for _, stateName in ipairs(describedStates) do print("[accessibility] menu " .. stateName .. ": " .. tostring(results[stateName])) end end print("[accessibility] --- end self test ---") end -------------------------------------------------------------------------------- -- Core hooks into states.lua -------------------------------------------------------------------------------- local function hookStates() local states = require("states") -- Screen changes: announce the new screen. local realSwitch = states.switch states.switch = function(newState, params) local result = realSwitch(newState, params) -- After the switch, so the state's open() has already set up its focus -- -- main_menu, for one, only picks selectedOption inside open(). pcall(function() -- Guard against NESTED switches. open() can itself switch state: -- ingame.open() sees world.pendingDialogue and goes straight to -- ingame_dialogue. Because we announce after realSwitch returns, the -- inner announcement ("Conversation") happens first and the outer one -- ("Game") then interrupts and clobbers it. If we are no longer the -- current state, the inner switch already announced -- stay quiet. if states.getCurrent() ~= newState then return end local descriptor = menus.loadDescriptor(newState) if descriptor then menus.announceScreen(newState) if descriptor.onOpen then speech.say(descriptor.onOpen, false) end else speech.say(stateNames[newState] or newState, true) end end) return result end -- Per-frame speech flush. main.lua calls states.update(dt) via a table lookup, -- so wrapping the field here avoids editing love.update. local realUpdate = states.update states.update = function(dt) local result = realUpdate(dt) -- Poll focus rather than relying on keypress alone. -- -- Focus can move with no key involved: main_menu.mousemoved assigns -- selectedOption straight from the cursor position, so wherever the mouse -- happens to rest silently overrides the selection -- including at startup, -- before the player has touched anything. Polling also closes the race -- where a state's open() sets focus after we have already announced. -- -- Cheap: two debug.getupvalue lookups, and only for states we describe. pcall(function() menus.announceFocusIfChanged(states.getCurrent()) end) pcall(walltones.update) pcall(speech.flush) if testbedActive then pcall(testbed.update, dt) end return result end -- Global hotkey layer. Ours are consumed; everything else falls through to the -- game untouched. local realKeypressed = states.keypressed states.keypressed = function(key, scancode) local consumed = false local ok, result = pcall(hotkeys.keypressed, key, scancode) if ok then consumed = result end if consumed then return end pcall(hotkeys.onGameKey) local keyResult = realKeypressed(key, scancode) -- After the game handler, so we read the focus it just moved to. pcall(function() menus.announceFocusIfChanged(states.getCurrent()) end) return keyResult end -- Text entry (character naming) arrives via textinput, not keypressed. local realTextinput = states.textinput states.textinput = function(text) local result = realTextinput(text) pcall(function() menus.announceFocusIfChanged(states.getCurrent()) end) return result end -- CRITICAL: re-point the love callbacks at our wrappers. -- -- states.setup() runs before us and does: -- love[callback] = love[callback] or states[callback] -- which captures the function BY VALUE. main.lua defines love.load, update, -- mousepressed, mousereleased and quit itself, but NOT keypressed/textinput -- -- so those were bound to the original states.keypressed at setup time. -- Replacing the states table field alone leaves love.keypressed pointing at -- the old function and our wrapper never runs. -- -- states.switch and states.update are unaffected because they are looked up on -- the table at call time (from state files and from main.lua's love.update). love.keypressed = states.keypressed love.textinput = states.textinput end -------------------------------------------------------------------------------- -- Entry point -------------------------------------------------------------------------------- function accessibility.initialize() if initialized then return end initialized = true accessibility.registerPatch("console", require("accessibility.patches.console")) accessibility.registerPatch("popups", require("accessibility.patches.popups")) accessibility.registerPatch("slider", require("accessibility.patches.slider")) pcall(function() require("accessibility.surroundings").registerKeys(hotkeys) require("accessibility.tracker").registerKeys(hotkeys) end) local ok, err = pcall(function() speech.initialize() walltones.load() hookStates() applyPatches() end) if not ok then print("[accessibility] initialisation FAILED, game continues unmodified: " .. tostring(err)) return end accessibility.selfTest() if testbed.isEnabled() then testbedActive = true testbed.start() end end return accessibility