# 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.