Files
alchemist-accessibility/accessibility/docs/01-architecture.md
T
Accessibility ModandClaude Opus 5 e57350ded3 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>
2026-08-01 20:28:30 +02:00

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.switch gives us a screen-change event for free (announce the new screen).
  • Wrapping states.keypressed gives 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) where lines is { {text=…, icon=…}, … }.
  • views/levelCursorPopup.lua → same signature, lines from levelWindow.getCursorPopupStats() which just forwards levelModel.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 via popupStack.add{…}. states/ingame.lua exposes ingame.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.