2026-08-01 20:28:30 +02:00
|
|
|
-- 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")
|
2026-08-01 20:38:28 +02:00
|
|
|
local hotkeys = require("accessibility.hotkeys")
|
|
|
|
|
local walltones = require("accessibility.walltones")
|
2026-08-01 20:53:29 +02:00
|
|
|
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",
|
2026-08-01 20:59:04 +02:00
|
|
|
"ingame_dialogue",
|
2026-08-01 21:26:35 +02:00
|
|
|
"ingame_observe",
|
2026-08-01 22:00:52 +02:00
|
|
|
"actions_menu",
|
|
|
|
|
"worldmap",
|
|
|
|
|
"alchemy_table",
|
|
|
|
|
"journal",
|
|
|
|
|
"character",
|
|
|
|
|
"menu_text",
|
|
|
|
|
"ingame_message",
|
|
|
|
|
"menu_message",
|
|
|
|
|
"inventory",
|
2026-08-01 22:12:56 +02:00
|
|
|
"craft",
|
|
|
|
|
"refine",
|
|
|
|
|
"trade",
|
|
|
|
|
"config_menu",
|
2026-08-01 20:53:29 +02:00
|
|
|
}
|
2026-08-01 20:28:30 +02:00
|
|
|
|
|
|
|
|
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
|
2026-08-01 20:53:29 +02:00
|
|
|
|
|
|
|
|
-- 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
|
|
|
|
|
|
2026-08-01 20:28:30 +02:00
|
|
|
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)
|
|
|
|
|
|
2026-08-01 20:53:29 +02:00
|
|
|
-- After the switch, so the state's open() has already set up its focus --
|
|
|
|
|
-- main_menu, for one, only picks selectedOption inside open().
|
2026-08-01 20:28:30 +02:00
|
|
|
pcall(function()
|
2026-08-01 20:59:04 +02:00
|
|
|
-- 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
|
|
|
|
|
|
2026-08-01 20:53:29 +02:00
|
|
|
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
|
2026-08-01 20:28:30 +02:00
|
|
|
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)
|
2026-08-01 20:53:29 +02:00
|
|
|
|
|
|
|
|
-- 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)
|
|
|
|
|
|
2026-08-01 20:38:28 +02:00
|
|
|
pcall(walltones.update)
|
2026-08-01 20:28:30 +02:00
|
|
|
pcall(speech.flush)
|
2026-08-01 20:53:29 +02:00
|
|
|
if testbedActive then
|
|
|
|
|
pcall(testbed.update, dt)
|
|
|
|
|
end
|
2026-08-01 20:28:30 +02:00
|
|
|
return result
|
|
|
|
|
end
|
2026-08-01 20:38:28 +02:00
|
|
|
|
|
|
|
|
-- 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)
|
2026-08-01 20:53:29 +02:00
|
|
|
|
|
|
|
|
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
|
2026-08-01 20:38:28 +02:00
|
|
|
end
|
2026-08-01 20:53:29 +02:00
|
|
|
|
|
|
|
|
-- 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
|
2026-08-01 20:28:30 +02:00
|
|
|
end
|
|
|
|
|
|
|
|
|
|
--------------------------------------------------------------------------------
|
|
|
|
|
-- Entry point
|
|
|
|
|
--------------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
function accessibility.initialize()
|
|
|
|
|
if initialized then
|
|
|
|
|
return
|
|
|
|
|
end
|
|
|
|
|
initialized = true
|
|
|
|
|
|
2026-08-01 20:38:28 +02:00
|
|
|
accessibility.registerPatch("console", require("accessibility.patches.console"))
|
2026-08-01 21:26:35 +02:00
|
|
|
accessibility.registerPatch("popups", require("accessibility.patches.popups"))
|
2026-08-01 22:12:56 +02:00
|
|
|
accessibility.registerPatch("slider", require("accessibility.patches.slider"))
|
2026-08-01 21:26:35 +02:00
|
|
|
|
|
|
|
|
pcall(function()
|
|
|
|
|
require("accessibility.surroundings").registerKeys(hotkeys)
|
2026-08-01 21:34:13 +02:00
|
|
|
require("accessibility.tracker").registerKeys(hotkeys)
|
2026-08-01 21:26:35 +02:00
|
|
|
end)
|
2026-08-01 20:38:28 +02:00
|
|
|
|
2026-08-01 20:28:30 +02:00
|
|
|
local ok, err = pcall(function()
|
|
|
|
|
speech.initialize()
|
2026-08-01 20:38:28 +02:00
|
|
|
walltones.load()
|
2026-08-01 20:28:30 +02:00
|
|
|
hookStates()
|
|
|
|
|
applyPatches()
|
|
|
|
|
end)
|
|
|
|
|
|
|
|
|
|
if not ok then
|
|
|
|
|
print("[accessibility] initialisation FAILED, game continues unmodified: " .. tostring(err))
|
|
|
|
|
return
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
accessibility.selfTest()
|
2026-08-01 20:53:29 +02:00
|
|
|
|
|
|
|
|
if testbed.isEnabled() then
|
|
|
|
|
testbedActive = true
|
|
|
|
|
testbed.start()
|
|
|
|
|
end
|
2026-08-01 20:28:30 +02:00
|
|
|
end
|
|
|
|
|
|
|
|
|
|
return accessibility
|