Phase 0: accessibility mod bootstrap and TTS

Establishes the mod skeleton and gets speech working end to end.

Game-file footprint: one line in main.lua.

- accessibility/init.lua: sole entry point. Patch registry, hooks states.switch
  (announces screen changes) and states.update (per-frame speech flush, avoiding
  an edit to love.update). Startup self-test prints what resolved -- the canary
  for when a future game version moves something we hook.
- accessibility/speech.lua: TTS wrapper. pcall-guarded so a missing DLL degrades
  to silence instead of crashing. Queues utterances within a frame and joins them,
  since one game turn emits several console.print calls and speaking each with
  interrupt would leave only the last audible.
- accessibility/stateNames.lua: spoken names for the 28 states.
- accessibility/vendor/love2talk: vendored TTS library, see VENDORED.md.
  Loads Tolk.dll by absolute path via love.filesystem.getSource() and adds that
  directory via SetDllDirectory, so the DLLs live in the project instead of
  needing an elevated copy into Program Files.

Verified with lovec against NVDA: TTS available, speech channel yes,
'Main menu' spoken on startup.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Accessibility Mod
2026-08-01 20:28:30 +02:00
co-authored by Claude Opus 5
commit e57350ded3
23 changed files with 1461 additions and 0 deletions
+179
View File
@@ -0,0 +1,179 @@
-- speech.lua — the mod's only route to text-to-speech.
--
-- Invariant: speech must NEVER throw. If the TTS backend is missing or broken,
-- every function here becomes a no-op and the game runs exactly as vanilla.
--
-- Utterances are queued within a frame rather than spoken immediately. A single
-- game turn routinely emits several console.print calls ("You hit the rat.",
-- "The rat dies.", "You feel cold."), and speaking each one with interrupt would
-- leave you hearing only the last. flush() joins the frame's queue into one
-- utterance, in order.
local speech = {}
local tts
local available = false
local failureReason
local queue = {}
local pendingInterrupt = false
-- Mirror every utterance to stdout. Worth keeping on by default during
-- development: it makes speech verifiable from the lovec console log rather than
-- only by ear, which matters when diagnosing whether a hook fired at all versus
-- fired with empty text.
speech.logToConsole = true
--------------------------------------------------------------------------------
-- Initialisation
--------------------------------------------------------------------------------
function speech.initialize()
-- tolk.lua calls Tolk_Load() at require time and raises if the DLL is absent,
-- so this pcall is load-bearing, not defensive garnish.
local ok, result = pcall(require, "accessibility.vendor.love2talk.Love2talk")
if not ok then
available = false
failureReason = tostring(result)
print("[accessibility] TTS unavailable, running silent: " .. failureReason)
return false
end
tts = result
available = true
print("[accessibility] TTS initialised")
return true
end
function speech.isAvailable()
return available
end
function speech.getFailureReason()
return failureReason
end
-- Whether Tolk found a real speech channel (running screen reader, or SAPI
-- fallback). isAvailable() only means the library loaded.
function speech.hasSpeech()
if not available or not tts.hasSpeech then
return false
end
local ok, result = pcall(tts.hasSpeech)
return ok and result or false
end
--------------------------------------------------------------------------------
-- Text normalisation
--------------------------------------------------------------------------------
-- Screen readers handle a single clean line far better than raw game strings,
-- which arrive with newlines (the game wraps its own text) and padding.
local function normalise(text)
if text == nil then
return nil
end
if type(text) ~= "string" then
text = tostring(text)
end
text = text:gsub("[\r\n]+", ". ")
text = text:gsub("%s+", " ")
text = text:gsub("^%s+", ""):gsub("%s+$", "")
if text == "" then
return nil
end
return text
end
speech.normalise = normalise
--------------------------------------------------------------------------------
-- Speaking
--------------------------------------------------------------------------------
-- Queue text to be spoken at the end of this frame.
-- interrupt=true discards anything already queued this frame AND cuts off whatever
-- the screen reader is currently saying. Use it for keypress-driven output, where
-- the user expects an immediate answer to the key they just pressed.
function speech.say(text, interrupt)
text = normalise(text)
if not text then
return
end
if interrupt then
queue = {text}
pendingInterrupt = true
else
queue[#queue + 1] = text
end
end
-- Convenience: the common case for hotkeys.
function speech.interrupt(text)
speech.say(text, true)
end
-- Speak immediately, bypassing the queue. Reserve this for urgent alerts (taking
-- damage) where waiting for end-of-frame is wrong.
function speech.immediate(text, interrupt)
text = normalise(text)
if not text or not available then
return
end
pcall(tts.say, text, interrupt ~= false)
end
-- Called once per frame from init.lua's update hook.
function speech.flush()
if #queue == 0 then
return
end
local text = table.concat(queue, ". ")
local shouldInterrupt = pendingInterrupt
queue = {}
pendingInterrupt = false
if speech.logToConsole then
print("[speech]" .. (shouldInterrupt and "!" or " ") .. " " .. text)
end
if not available then
return
end
pcall(tts.say, text, shouldInterrupt)
end
function speech.silence()
queue = {}
pendingInterrupt = false
if not available or not tts.silence then
return
end
pcall(tts.silence)
end
-- Unreliable under screen readers (they don't report speaking state); only
-- meaningful with the SAPI fallback. Do not use for timing.
function speech.isSpeaking()
if not available then
return false
end
local ok, result = pcall(tts.isSpeaking)
return ok and result or false
end
return speech