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>
141 lines
9.0 KiB
Markdown
141 lines
9.0 KiB
Markdown
# 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.
|