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:
+21
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2021 pitermach
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
-- love2talk — vendored from d:\code\love2talk
|
||||
-- See VENDORED.md for the list of local modifications.
|
||||
|
||||
local os = love.system.getOS()
|
||||
|
||||
local backend -- upstream leaked this as a global; scoped to the module here
|
||||
|
||||
if os == "Windows" then
|
||||
backend = require "accessibility.vendor.love2talk.tolk"
|
||||
backend.trySAPI(true)
|
||||
elseif os == "OS X" then
|
||||
backend = require "accessibility.vendor.love2talk.macspeech"
|
||||
end
|
||||
|
||||
local function say(text, interrupt)
|
||||
if not backend then
|
||||
return
|
||||
end
|
||||
interrupt = interrupt or false
|
||||
if os == "Windows" then
|
||||
backend.output(text, interrupt)
|
||||
else
|
||||
backend.output(text)
|
||||
end
|
||||
end
|
||||
|
||||
local function isSpeaking()
|
||||
if not backend then
|
||||
return false
|
||||
end
|
||||
return backend.isSpeaking()
|
||||
end
|
||||
|
||||
local function silence()
|
||||
if not backend or not backend.silence then
|
||||
return
|
||||
end
|
||||
backend.silence()
|
||||
end
|
||||
|
||||
-- LOCAL ADDITION: exposed for diagnostics. Tells us whether Tolk actually found a
|
||||
-- speech channel (a running screen reader, or the SAPI fallback) as opposed to
|
||||
-- merely having loaded the DLL successfully.
|
||||
local function hasSpeech()
|
||||
if not backend or not backend.hasSpeech then
|
||||
return false
|
||||
end
|
||||
return backend.hasSpeech()
|
||||
end
|
||||
|
||||
return {say = say, isSpeaking = isSpeaking, silence = silence, hasSpeech = hasSpeech}
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
# Vendored: love2talk
|
||||
|
||||
Source: `d:\code\love2talk` (by Piotr, MIT — see `LICENSE`).
|
||||
Vendored 2026-08-01 for the Sulphur Memories accessibility mod.
|
||||
|
||||
## What was copied
|
||||
|
||||
`Love2talk.lua`, `tolk.lua`, `encoding.lua`, `macspeech.lua`, `LICENSE`.
|
||||
|
||||
## What was dropped
|
||||
|
||||
- `objc/` — Mac-only submodule, ~4 files. Re-add if a macOS build is ever wanted;
|
||||
`macspeech.lua` is kept so that only the submodule is missing.
|
||||
- `main.lua` — upstream's standalone demo, would collide with the game's own `main.lua`.
|
||||
- `readme.md`, `.gitmodules`, `.gitignore` — not needed here.
|
||||
|
||||
## Local modifications
|
||||
|
||||
| File | Change | Why |
|
||||
|---|---|---|
|
||||
| `Love2talk.lua` | `require "Tolk"` → `require "accessibility.vendor.love2talk.tolk"` | Upstream assumes it sits at the project root. Also fixes a latent case bug: it required `"Tolk"` but the file is `tolk.lua` — works on case-insensitive Windows, would fail elsewhere. |
|
||||
| `Love2talk.lua` | `backend` made `local` | Upstream leaked it as a global. The game's `.luacheckrc` uses a strict globals list, and a stray global risks colliding with game code. |
|
||||
| `Love2talk.lua` | `say`/`isSpeaking` guard on `backend == nil` | On an unsupported OS upstream sets no backend, then dereferences it. Now degrades to silence. |
|
||||
| `Love2talk.lua` | added `silence()` passthrough | `tolk.lua` already exposes `Tolk_Silence`; we need it to cut speech on interrupt. |
|
||||
| `tolk.lua` | `require "encoding"` → `require "accessibility.vendor.love2talk.encoding"` | Same path reason. |
|
||||
|
||||
Nothing else was touched. `encoding.lua` and `macspeech.lua` are byte-identical to upstream.
|
||||
|
||||
## Runtime requirement — DLLs
|
||||
|
||||
`tolk.lua` does `ffi.load("Tolk")`, which goes through the **Windows loader**, not LÖVE's
|
||||
virtual filesystem. The DLLs therefore **cannot live inside a `.love` archive or in this
|
||||
folder** — they must sit next to the executable (`love.exe`) or on `PATH`.
|
||||
|
||||
Required next to `love.exe`:
|
||||
|
||||
- `Tolk.dll`
|
||||
- `nvdaControllerClient64.dll` (NVDA — our target screen reader)
|
||||
- `SAAPI64.dll` (System Access)
|
||||
- plus any other screen-reader clients Tolk should support
|
||||
|
||||
`Love2talk.lua` calls `trySAPI(true)`, so with no screen reader running it falls back to
|
||||
SAPI 5 and still speaks.
|
||||
|
||||
Also note `tolk.lua` calls `Tolk_Load()` **at require time** with no error handling — a
|
||||
missing DLL raises immediately. Always `pcall` the require; `accessibility/speech.lua`
|
||||
does this and degrades to a silent no-op.
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
local ffi = require "ffi"
|
||||
local kernel32 = ffi.load("kernel32")
|
||||
ffi.cdef[[
|
||||
int Beep(int x, int y);
|
||||
typedef unsigned int UINT;
|
||||
typedef unsigned int DWORD;
|
||||
typedef const char * LPCSTR;
|
||||
typedef char * LPSTR;
|
||||
typedef wchar_t * LPWSTR;
|
||||
typedef const wchar_t *LPCWSTR;
|
||||
typedef int *LPBOOL;
|
||||
int WideCharToMultiByte(UINT CodePage,
|
||||
DWORD dwFlags,
|
||||
LPCWSTR lpWideCharStr, int cchWideChar,
|
||||
LPSTR lpMultiByteStr, int cbMultiByte,
|
||||
LPCSTR lpDefaultChar,
|
||||
LPBOOL lpUsedDefaultChar
|
||||
);
|
||||
int MultiByteToWideChar(UINT CodePage,
|
||||
DWORD dwFlags,
|
||||
LPCSTR lpMultiByteStr, int cbMultiByte,
|
||||
LPWSTR lpWideCharStr, int cchWideChar);
|
||||
]]
|
||||
|
||||
local CP_UTF8 = 65001
|
||||
local function to_utf16(s)
|
||||
local needed = kernel32.MultiByteToWideChar(CP_UTF8, 0, s, -1, NULL, 0)
|
||||
local buf = ffi.new("wchar_t[?]", needed)
|
||||
local written = kernel32.MultiByteToWideChar(CP_UTF8, 0, s, -1, buf, needed)
|
||||
return ffi.string(buf, written*2)
|
||||
end
|
||||
|
||||
local function to_utf8(s)
|
||||
local needed = kernel32.WideCharToMultiByte(CP_UTF8, 0, s, -1, nil, 0, nil, nil)
|
||||
local buf = ffi.new("char[?]", needed)
|
||||
local written = kernel32.WideCharToMultiByte(CP_UTF8, 0, s, -1, buf, needed, nil, nil)
|
||||
return ffi.string(buf, written - 1)
|
||||
end
|
||||
|
||||
return {to_utf8=to_utf8, to_utf16=to_utf16}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
local objc = require("objc.objc")
|
||||
|
||||
objc.load("AVFoundation")
|
||||
local backend = {}
|
||||
backend.synth = objc.AVSpeechSynthesizer:alloc():init()
|
||||
-- Because AVSpeechSynthesizer doesn't give us the proper default voice (it returned ALbert on my system for some reason), we have to do this bit of uglyness with NSSpeechSynthesizer.
|
||||
backend.voice = objc.NSSpeechSynthesizer:alloc():init():defaultVoice()
|
||||
|
||||
function backend.output(text, interrupt)
|
||||
text = tostring(text)
|
||||
interrupt = interrupt or false
|
||||
if interrupt then
|
||||
backend.synth:stopSpeakingAtBoundary(0)
|
||||
end
|
||||
local utterance = objc.AVSpeechUtterance:alloc():initWithString(text)
|
||||
utterance.voice = objc.AVSpeechSynthesisVoice:voiceWithIdentifier(backend.voice)
|
||||
backend.synth:speakUtterance(utterance)
|
||||
end
|
||||
|
||||
function backend.isSpeaking()
|
||||
return backend.synth:isSpeaking()
|
||||
end
|
||||
|
||||
return backend
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
local ffi = require "ffi"
|
||||
local encoding = require "accessibility.vendor.love2talk.encoding"
|
||||
ffi.cdef[[
|
||||
void Tolk_Load();
|
||||
void Tolk_Output(const char *s, bool interrupt);
|
||||
void Tolk_Silence();
|
||||
void Tolk_Speak(const char *s, bool interrupt);
|
||||
void Tolk_Braille(const char *s, bool interrupt);
|
||||
void Tolk_TrySAPI(bool try);
|
||||
void Tolk_PreferSAPI(bool prefer);
|
||||
const wchar_t * Tolk_DetectScreenReader();
|
||||
bool Tolk_HasSpeech();
|
||||
bool Tolk_HasBraille();
|
||||
bool Tolk_IsSpeaking();]]
|
||||
|
||||
-- LOCAL MODIFICATION: load Tolk from the mod's own lib/ directory.
|
||||
--
|
||||
-- ffi.load() goes through the Windows loader, which does not know about LOVE's
|
||||
-- virtual filesystem. Upstream relies on Tolk.dll sitting next to love.exe, which
|
||||
-- needs admin rights to arrange. Instead we resolve the real OS path via
|
||||
-- love.filesystem.getSource() and load by absolute path.
|
||||
--
|
||||
-- Tolk.dll in turn loads the screen-reader clients (nvdaControllerClient64.dll,
|
||||
-- SAAPI64.dll) itself, and those go through the normal search order — so we must
|
||||
-- also add our directory to the DLL search path via SetDllDirectory, or Tolk would
|
||||
-- load but find no screen reader and silently fall back to SAPI.
|
||||
local function loadTolk()
|
||||
local ok, source = pcall(function()
|
||||
return love.filesystem.getSource()
|
||||
end)
|
||||
|
||||
if ok and source then
|
||||
local libDir = source .. "\\accessibility\\lib"
|
||||
|
||||
pcall(function()
|
||||
ffi.cdef [[ int SetDllDirectoryA(const char *lpPathName); ]]
|
||||
ffi.C.SetDllDirectoryA(libDir)
|
||||
end)
|
||||
|
||||
local loaded, handle = pcall(ffi.load, libDir .. "\\Tolk.dll")
|
||||
if loaded then
|
||||
return handle
|
||||
end
|
||||
end
|
||||
|
||||
-- Fall back to the normal search order: next to love.exe, or on PATH.
|
||||
return ffi.load("Tolk")
|
||||
end
|
||||
|
||||
local tolk = loadTolk()
|
||||
tolk.Tolk_Load()
|
||||
local function output(s, interrupt)
|
||||
interrupt=interrupt or false
|
||||
|
||||
tolk.Tolk_Output(encoding.to_utf16(s), interrupt)
|
||||
end
|
||||
|
||||
local function speak(s, interrupt)
|
||||
interrupt=interrupt or false
|
||||
|
||||
tolk.Tolk_Speak(encoding.to_utf16(s), interrupt)
|
||||
end
|
||||
|
||||
local function braille(s)
|
||||
tolk.Tolk_Braille(encoding.to_utf16(s))
|
||||
end
|
||||
|
||||
local function silence()
|
||||
tolk.Tolk_Silence()
|
||||
end
|
||||
|
||||
local function trySAPI(try)
|
||||
tolk.Tolk_TrySAPI(try)
|
||||
end
|
||||
|
||||
local function preferSAPI(prefer)
|
||||
tolk.Tolk_PreferSAPI(prefer)
|
||||
end
|
||||
local function isSpeaking()
|
||||
return tolk.Tolk_IsSpeaking()
|
||||
end
|
||||
|
||||
local function detectScreenReader()
|
||||
--todo, need to convert the returned value to something Lua likes
|
||||
end
|
||||
|
||||
local function hasSpeech()
|
||||
return tolk.Tolk_HasSpeech()
|
||||
end
|
||||
|
||||
local function hasBraille()
|
||||
return tolk.Tolk_HasBraille()
|
||||
end
|
||||
|
||||
|
||||
return {
|
||||
output=output,
|
||||
speak=speak,
|
||||
braille=braille,
|
||||
silence=silence,
|
||||
isSpeaking=isSpeaking,
|
||||
trySAPI=trySAPI,
|
||||
preferSAPI=preferSAPI,
|
||||
detectScreenReader=detectScreenReader,
|
||||
hasSpeech=hasSpeech,
|
||||
hasBraille=hasBraille}
|
||||
Reference in New Issue
Block a user