Files
alchemist-accessibility/accessibility/docs/02-hooking-strategy.md
T

141 lines
9.0 KiB
Markdown
Raw Normal View 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()`:
```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.