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>
11 KiB
Game architecture — findings
Target: Sulphur Memories: Alchemist 0.4.2 "sanctified", LÖVE 11.5, Lua 5.1/LuaJIT.
1. Top-level layout
| Path | Role |
|---|---|
main.lua |
love.load, requires everything, runs preprocess*() for all templates, switches to main_menu. |
states.lua |
Tiny state machine. The single most important hook point. |
states/*.lua (28 files) |
One file per screen. Each returns a table of LÖVE callbacks. |
modules/ |
Reusable objects: console, level, tile, item, popups. |
models/ |
Game data + singletons: world, player, keybindings, config, journal, actor. |
views/ |
Pure drawing: levelWindow, inventoryWindow, *CursorPopup, hotbar, hpbar. |
actions/, animations/, generators/, shaders/ |
Gameplay verbs, tweens, map gen, GLSL. |
Global variables (declared in .luacheckrc): width, height, tileSize, UIScale, TEsound, and all the font objects (mediumfont, smallfont, …). No module system beyond require, so every module is a singleton in package.loaded — this is what makes monkey-patching viable.
2. states.lua — the master hook
function states.setup()
for _,callback in pairs(love2dCallbacksList) do
states[callback] = function(...) stateFiles[currentState][callback](unpack({...})) end
love[callback] = love[callback] or states[callback]
end
end
function states.switch(newState, params) ... end
function states.getCurrent() ... end
Every LÖVE callback (keypressed, draw, update, mousepressed, …) funnels through states[callback]. State files are lazily required into stateFiles[stateName] on first switch.
Consequences:
- Wrapping
states.switchgives us a screen-change event for free (announce the new screen). - Wrapping
states.keypressedgives us a global hotkey layer that can consume a key before the game sees it, in any screen. - Because state tables are plain Lua tables held in
stateFiles, we can wrap individual state callbacks after they are loaded.
3. Console — already a single chokepoint
modules/console.lua exposes exactly:
console.print(message, colorTemplate) -- colorTemplate is "flavor" or nil (combat/positive/... are commented out)
console.clear()
console.draw() / console.update(dt) / console.prepare() / console.refreshOutput()
buffer is a file-local list of {message, times, textHeight, lifetime, colorTemplate}. Repeated identical messages coalesce into one entry with times incremented — the drawn text becomes "msg (x3)".
Lines expire (lifetime = 3 s) and scroll away, so the visual buffer is not a usable history. We need our own unbounded history list, appended from a wrapper around console.print.
Grepping shows console.print is called from models/actor.lua, modules/tile/templates.lua, actions/*, etc. — all of them go through this one function. Nothing else writes game text to the log.
4. The level model
modules/level.lua (1807 lines) — newLevel(params) returns a closure table self. Relevant public surface:
Data
self.grid[x][y]→ array (stack) of tile objects, bottom-to-top. Top-most non-hidden tile is what the player "sees".self.actors→ flat array of actor objects.self.FoV[x][y],self.rememberedFoV[x][y]→ booleans (currently visible / previously seen).self.xMax,self.yMax,self.cursorX,self.cursorY,self.playerActor,self.name.
Queries we will use
| Function | Use for accessibility |
|---|---|
getCursorMessage() |
Returns the description string for the tile under the cursor (actor first, then top visible tile). Already the exact text we want to speak. |
getLevelDescriptionMessage() |
Level name + in-game datetime. |
getActorOnCoordinates(x,y,cond) |
Actor lookup. |
getItemOnCoordinates(x,y) |
Item lookup. |
getTile(x,y,templateId,fromBottom) |
Tile lookup. |
getListByTag(tag) |
Returns { {x,y}, … } of all seen tiles matching a tag, already sorted by distance to player. This is the seed of the object tracker. |
getClosestActorOfType(x,y,templateId), countActorsById, getAnyActorById |
Actor tracking. |
isVisible(x,y), isNearVisible(x,y), isPassable(x,y,…), isWaterTile(x,y) |
Tile probing for the surroundings explorer. |
calculatePathFromPlayer(x,y) |
Returns a {dx,dy} step list — used for "how do I get there". |
calculateStepFromPlayer(x,y) |
Single next step. |
setCursor(x,y) / moveCursor(dx,dy,…) / resetCursor() / getCursorPosition() |
Drive the examine cursor programmatically. |
playerInput(cmd,x,y,state) / bufferInput(cmd,array,state) |
Issue player actions. |
Important caveat: getListByTag reads templatesByTag, and modules/tile/templatesByTag.lua currently defines only one tag (exit, containing way_out, building_door, horse, ladder_up, ladder_down). The object tracker will need its own, much richer category table — see 03-implementation-plan.md.
5. Actors, tiles, items
models/actor.lua — actor objects carry templateId, x, y, name, nameObserved, nameAsSubject, nameAsOwner, description, hp, maxhp, visibilityAlpha, isPlayerControlled, parent (the level).
Template tables are large data files: modules/tile/templates.lua (6745 lines), models/actor/templates.lua (2887), modules/item/templates.lua (7152). They are preprocessed at load. Tiles carry flags we can categorise on: blocksMovement, blocksVision, tryInteract, loot, unlootable, isHidden, description, plus templateId.
modules/item/categories.lua already defines 7 player-facing item categories (All / Potions / Ingredients / Raw materials / Consumables / Tools / Texts). Reuse these names verbatim for consistency.
6. Keyboard vs. mouse — better than expected
Every one of the 28 states already implements keypressed. The game is a keyboard-first roguelike with mouse as an alternative, not a mouse-only game. Verified by reading inventory, craft, ingame, ingame_observe, ingame_dialogue, main_menu.
models/keybindings.lua is a full rebindable-keys system with contexts (ingame, aiming, menu, menuGlobal, menuHotkeys, hotbar, with a parents inheritance chain), persisted to keybindings.json. Public API:
keybindings.getCommand(scancode, filter) -- scancode -> command name
keybindings.scancodeToDirection(scancode, filter) -- -> dx, dy
keybindings.isCommandDown(command, filter)
keybindings.getKeyForCommand(command, filter)
keybindings.checkForConflicts(filter, newScancode)
So the real accessibility gap is not "can't reach it with the keyboard" — it is "nothing tells you what is currently focused." That reframes the whole job from reimplementing UIs to narrating existing focus state. Much smaller, much more mod-friendly.
The narration chokepoint
Menu-ish states converge on a updateCursorPopup()-style helper that ends in:
cursorPopup.set(inventoryWindow.getCursorPopupStats()) -- craft, inventory, refine, trade
cursorPopup.set(message, x, y, alignX, alignY) -- ingame_observe (levelCursorPopup)
views/inventoryCursorPopup.lua→set(lines, x, y, alignX, alignY)wherelinesis{ {text=…, icon=…}, … }.views/levelCursorPopup.lua→ same signature,linesfromlevelWindow.getCursorPopupStats()which just forwardslevelModel.getCursorMessage().
Patching set on these two view modules narrates the focused item in inventory, craft, refine, trade, ingame_observe at once. Five screens, two wrappers, zero game edits.
The states that don't use cursorPopup
main_menu, journal, character, research, worldmap, settings_menu, config_menu, keybindings_menu, load_game, new_game, actions_menu, alchemy_table, ingame_dialogue, menu_text, narration, ingame_message.
These hold focus in file-local variables — typically selectedOption, plus optionsList / options / activeTabIndex / selectedPage. Example, states/main_menu.lua:
local selectedOption
local optionsList = { {id=1, title="Continue", isEnabledFunction=…, callback=…}, … }
File-locals are not reachable through the returned state table — but they are upvalues of the state's own functions, and debug.getupvalue reaches them. Verified working on this Lua:
1 selectedOption 3
2 optionsList <table>
So we can read (and even debug.setupvalue) menu focus without editing a single game file. See 02-hooking-strategy.md for how far to push this.
7. Popups and other text surfaces
views/popupStack.lua+modules/{loot,recipe,lore,map,journal,tutorial,bestiary}Popup.lua— transient popups pushed viapopupStack.add{…}.states/ingame.luaexposesingame.lootPopup,ingame.recipePopup,ingame.lorePopup,ingame.mapPopup,ingame.journalPopup,ingame.tutorialPopup,ingame.displayMessage— all good wrap points.views/descriptionWindow.lua— the persistent level/status panel.views/hpbar.lua,views/hotbar.lua,views/statusGrid.lua— player status, needs an explicit "read my status" key.states/ingame_message.lua,states/menu_message.lua,states/menu_text.lua,states/narration.lua— full-screen text.
8. TTS backend — d:\code\love2talk
tts = require "Love2talk"
tts.say(text, interrupt) -- interrupt defaults to false
tts.isSpeaking() -- unreliable under screen readers
Windows path uses tolk.lua → ffi.load("Tolk") → Tolk.dll, with trySAPI(true) so it falls back to SAPI 5 when no screen reader is running. encoding.lua converts to UTF-16.
Deployment constraint: ffi.load("Tolk") resolves through the OS loader, so Tolk.dll and its screen-reader client DLLs (nvdaControllerClient64.dll, SAAPI64.dll, …) must sit next to love.exe (or in a directory on PATH) — not inside the .love archive, and not inside the game source folder unless the game is launched with that folder as CWD. Copies of these DLLs already exist elsewhere on this machine (D:\code\cafe, D:\code\dragonrpg-mapmaker, D:\code\DragonWarriorAccess). This is an open question — see 04-open-questions.md Q1.
Also note Love2talk.lua assigns backend as a global, and tolk.lua calls Tolk_Load() at require time with no error handling. We should wrap the require in pcall so a missing DLL degrades to silence rather than crashing the game.
9. Existing accessibility assets
accessibility/assets/walltones/ — walltone_{north,south,east,west}.wav. Directional wall tones, presumably for the surroundings explorer. LÖVE + the game's TEsound wrapper (tesound.lua) can play these; love.audio.setDistanceModel("inverse") is already set in main.lua and TEsound.playPositioned exists (used for positional SFX), so spatialised cues are available if we want them.
10. Build / distribution
build.lua is a love-build config: name sulphur-memories-alchemist, version 0.4.2, LÖVE 11.5, output dist, with an ignore list. Adding accessibility/ to the game is compatible with this build path. LÖVE 11.5 is installed locally at C:\Program Files\LOVE\love.exe (and lovec.exe, which gives a console window — useful for debugging).
.luacheckrc uses std = "min+alch" with a custom globals list; new accessibility globals would need adding there if we want lint to stay clean.