Files
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

9.0 KiB
Raw Permalink Blame History

Hooking strategy — how to stay decoupled

Goal: the mod must be re-appliable on top of a new game version with minimal effort, and must not change how the game behaves for sighted players.

The one edit we cannot avoid

Lua has no auto-loading. Something must run our code. The minimum footprint is one line in main.lua, at the very top of love.load():

require("accessibility")()   -- or: require("accessibility.init")

Everything else lives under accessibility/. That is a 1-line diff against the vanilla tree, which survives version bumps trivially (and is easy to re-apply by hand if the merge ever conflicts).

Placement matters. It must be after config.initialize() and the font setup (our code may want UIScale, mediumfont), and after the require block that loads world, console, states, keybindings — because we patch those modules by requiring them and replacing functions on the returned singleton tables. Concretely: insert immediately before states.setup() for the module patches, with a second phase after states.setup() for the state-callback wrappers.

Two-phase init keeps this clean:

local access = require("accessibility")
access.preInit()    -- patch modules (console, level factory, view popups)
...
states.setup()
access.postInit()   -- wrap states.switch / states.keypressed, install global hotkeys

That is 3 lines. If we want to hold to exactly one, accessibility/init.lua can do both phases itself and defer the second with a one-shot flag checked on the first update.

Patch techniques, in order of preference

1. Monkey-patch module singletons (best)

Every module is cached in package.loaded. Requiring it from our code returns the same table the game holds.

local console = require("modules.console")
local realPrint = console.print
console.print = function(msg, tpl)
  history.append(msg, tpl)
  speech.say(msg)
  return realPrint(msg, tpl)
end

Works for: modules/console.lua, views/inventoryCursorPopup.lua, views/levelCursorPopup.lua, views/popupStack.lua, models/keybindings.lua, states.lua.

Caveat: only works for module-level functions, not for functions captured as locals before we patch. E.g. states/ingame_observe.lua does local cursorPopup = require("views.levelCursorPopup") and calls cursorPopup.set(…) — that's a table field lookup at call time, so our patch is seen. But a pattern like local set = cursorPopup.set would bypass us. Spot-check per patch target; the code in this game consistently uses module.fn(…) so we're fine.

2. Wrap the factory, then wrap the instance

modules/level.lua returns newLevel(params), a constructor. Patch the constructor:

local newLevel = require("modules.level")
package.loaded["modules.level"] = function(params)
  local level = newLevel(params)
  -- wrap level.playerInput, level.setCursor, … here
  return level
end

Must happen before anything calls newLevel (i.e. in preInit, before a level is generated). Note models/world.lua and the generators hold local newLevel = require("modules.level") captured at their require time — so we must replace package.loaded before those modules are first required, which in main.lua means before world = require("models.world"). That is earlier than our chosen insertion point.

Simpler alternative that avoids the ordering problem entirely: don't wrap the constructor at all. Read world.currentLevel on demand — it's a public field, and every query we need (getCursorMessage, getListByTag, grid, actors, FoV, calculatePathFromPlayer) is a public method or field on it. Prefer this. Only wrap the constructor if we discover we need a level-lifecycle event, and even then states.switch("ingame") plus a level-identity check gives us the same signal.

3. debug.getupvalue for state-local focus (the interesting one)

For the ~16 states that keep focus in file-locals, read them without editing the file:

local function getLocal(fn, name)
  for i = 1, math.huge do
    local n, v = debug.getupvalue(fn, i)
    if not n then return nil end
    if n == name then return v, i end
  end
end

-- after main_menu.keypressed has run:
local sel  = getLocal(main_menu.keypressed, "selectedOption")
local list = getLocal(main_menu.keypressed, "optionsList")
speech.say(list[sel].title)

Verified working on this Lua build. debug.setupvalue also works if we ever need to drive focus.

Honest assessment of the tradeoff. This is the technique that buys the most decoupling and carries the most risk:

  • ✅ Zero game edits. A new game version drops in and the mod still loads.
  • ✅ Failures are local and detectable — if selectedOption is renamed, getLocal returns nil, and we can log "accessibility: main_menu focus probe failed" instead of crashing.
  • ⚠️ Each state needs a hand-written descriptor saying which upvalue holds focus and which holds the list, and how to render an entry to text. ~16 descriptors, each 5–15 lines. That's real work, but it's work in our files.
  • ⚠️ Upvalue names are not part of any API. A refactor renames them silently. Mitigation: a self-test that runs at startup, walks every descriptor, and reports which probes resolved — so a version bump surfaces breakage immediately rather than mid-playthrough.
  • ⚠️ Upvalues are per-closure. selectedOption is only an upvalue of a function that references it. Pick the probe function per state (usually keypressed, sometimes draw). Some states may need a different function; the descriptor names it.
  • ❌ Doesn't help where focus is derived rather than stored, or where announcing needs to happen at a moment we can't observe. For those, fall back to technique 4.

Recommendation: use debug.getupvalue as the default for menu states, with a per-state descriptor table and a startup self-test. Accept a small patch file for the handful of states where it genuinely doesn't fit.

4. Deterministic patch file (fallback)

For anything the above can't reach, keep edits in accessibility/patches/ as a unified diff, applied by a script. Since the extracted source is not a git repo, the cleanest setup is:

  1. git init in the game root; commit the pristine 0.4.2 tree as the first commit, tagged vanilla-0.4.2.
  2. Develop the mod on a branch.
  3. New game version → commit the new pristine tree on vanilla, then git rebase / git cherry-pick the mod commits. Conflicts are localised to the few touched files.

This gives us reproducible re-application and a readable audit of exactly how much game code we touched. Even if we end up needing zero patches, having the vanilla baseline committed is worth it for diffing versions.

What we must not break

  • No behaviour change for sighted players. All wrappers call through to the original and return its results verbatim (return realFn(...), preserving multiple return values).
  • Speech must never throw. Wrap the TTS require in pcall; if it fails, speech.say becomes a no-op and the game runs normally.
  • No new keybinding conflicts. Our hotkeys (Page Up/Down, Home, arrow-cluster explore keys) must be checked against keybindings.checkForConflicts. Page Up/Down and Home are not in the game's default set — verified — but users can rebind, so consume our keys only when they aren't bound to a game command in the current context, and make ours rebindable too.
  • Don't consume keys the game needs. Our keypressed wrapper runs first, returns true-ish only for keys we own, and otherwise falls through to the real handler.
  • Frame budget. Object-tracker category scans walk xMax × yMax grid cells (getListByTag does exactly this). Do it on keypress, cache per turn, never per frame.

Proposed file layout

accessibility/
  init.lua              -- preInit/postInit, pcall-guarded bootstrap
  config.lua            -- mod settings, persisted separately from the game's config
  speech.lua            -- TTS wrapper (love2talk + queue + interrupt policy + no-op fallback)
  history.lua           -- unbounded console history + line cursor
  hotkeys.lua           -- global key layer, context-aware, rebindable
  tracker.lua           -- object tracker (categories, instances, distance/direction)
  categories.lua        -- tracker category definitions (tiles/actors/items -> category)
  surroundings.lua      -- tile explorer around the player, wall tones
  describe.lua          -- shared text formatting (distance, direction, tile stack, actor)
  patches/
    console.lua         -- console.print wrapper
    popups.lua          -- inventoryCursorPopup / levelCursorPopup / popupStack wrappers
    states.lua          -- states.switch / keypressed wrappers
  states/               -- one focus descriptor per game state
    main_menu.lua
    journal.lua
    ...
  vendor/
    love2talk/          -- vendored copy of d:\code\love2talk
  assets/
    walltones/          -- already present
  docs/

Vendoring love2talk (rather than pointing at d:\code\love2talk) is necessary for distribution; note its objc/ submodule is Mac-only and can be dropped for a Windows-first release.