Files
alchemist-accessibility/accessibility/docs/02-hooking-strategy.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

141 lines
9.0 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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()`:
```lua
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:
```lua
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.
```lua
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:
```lua
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:
```lua
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.