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>
This commit is contained in:
Accessibility Mod
2026-08-01 20:28:30 +02:00
co-authored by Claude Opus 5
commit e57350ded3
23 changed files with 1461 additions and 0 deletions
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+170
View File
@@ -0,0 +1,170 @@
# Game architecture — findings
Target: *Sulphur Memories: Alchemist* 0.4.2 "sanctified", LÖVE 11.5, Lua 5.1/LuaJIT.
## 1. Top-level layout
| Path | Role |
|---|---|
| `main.lua` | `love.load`, requires everything, runs `preprocess*()` for all templates, switches to `main_menu`. |
| `states.lua` | Tiny state machine. **The single most important hook point.** |
| `states/*.lua` (28 files) | One file per screen. Each returns a table of LÖVE callbacks. |
| `modules/` | Reusable objects: `console`, `level`, `tile`, `item`, popups. |
| `models/` | Game data + singletons: `world`, `player`, `keybindings`, `config`, `journal`, `actor`. |
| `views/` | Pure drawing: `levelWindow`, `inventoryWindow`, `*CursorPopup`, `hotbar`, `hpbar`. |
| `actions/`, `animations/`, `generators/`, `shaders/` | Gameplay verbs, tweens, map gen, GLSL. |
Global variables (declared in `.luacheckrc`): `width`, `height`, `tileSize`, `UIScale`, `TEsound`, and all the font objects (`mediumfont`, `smallfont`, …). No module system beyond `require`, so **every module is a singleton in `package.loaded`** — this is what makes monkey-patching viable.
## 2. `states.lua` — the master hook
```lua
function states.setup()
for _,callback in pairs(love2dCallbacksList) do
states[callback] = function(...) stateFiles[currentState][callback](unpack({...})) end
love[callback] = love[callback] or states[callback]
end
end
function states.switch(newState, params) ... end
function states.getCurrent() ... end
```
Every LÖVE callback (`keypressed`, `draw`, `update`, `mousepressed`, …) funnels through `states[callback]`. State files are lazily `require`d into `stateFiles[stateName]` on first switch.
Consequences:
- Wrapping `states.switch` gives us a **screen-change event** for free (announce the new screen).
- Wrapping `states.keypressed` gives us a **global hotkey layer** that can consume a key before the game sees it, in any screen.
- Because state tables are plain Lua tables held in `stateFiles`, we can wrap individual state callbacks *after* they are loaded.
## 3. Console — already a single chokepoint
`modules/console.lua` exposes exactly:
```lua
console.print(message, colorTemplate) -- colorTemplate is "flavor" or nil (combat/positive/... are commented out)
console.clear()
console.draw() / console.update(dt) / console.prepare() / console.refreshOutput()
```
`buffer` is a file-local list of `{message, times, textHeight, lifetime, colorTemplate}`. Repeated identical messages **coalesce** into one entry with `times` incremented — the drawn text becomes `"msg (x3)"`.
Lines expire (`lifetime = 3` s) and scroll away, so the visual buffer is not a usable history. **We need our own unbounded history list**, appended from a wrapper around `console.print`.
Grepping shows `console.print` is called from `models/actor.lua`, `modules/tile/templates.lua`, `actions/*`, etc. — all of them go through this one function. Nothing else writes game text to the log.
## 4. The level model
`modules/level.lua` (1807 lines) — `newLevel(params)` returns a closure table `self`. Relevant public surface:
**Data**
- `self.grid[x][y]` → array (stack) of tile objects, bottom-to-top. Top-most non-hidden tile is what the player "sees".
- `self.actors` → flat array of actor objects.
- `self.FoV[x][y]`, `self.rememberedFoV[x][y]` → booleans (currently visible / previously seen).
- `self.xMax`, `self.yMax`, `self.cursorX`, `self.cursorY`, `self.playerActor`, `self.name`.
**Queries we will use**
| Function | Use for accessibility |
|---|---|
| `getCursorMessage()` | Returns the description string for the tile under the cursor (actor first, then top visible tile). **Already the exact text we want to speak.** |
| `getLevelDescriptionMessage()` | Level name + in-game datetime. |
| `getActorOnCoordinates(x,y,cond)` | Actor lookup. |
| `getItemOnCoordinates(x,y)` | Item lookup. |
| `getTile(x,y,templateId,fromBottom)` | Tile lookup. |
| `getListByTag(tag)` | **Returns `{ {x,y}, … }` of all *seen* tiles matching a tag, already sorted by distance to player.** This is the seed of the object tracker. |
| `getClosestActorOfType(x,y,templateId)`, `countActorsById`, `getAnyActorById` | Actor tracking. |
| `isVisible(x,y)`, `isNearVisible(x,y)`, `isPassable(x,y,…)`, `isWaterTile(x,y)` | Tile probing for the surroundings explorer. |
| `calculatePathFromPlayer(x,y)` | Returns a `{dx,dy}` step list — used for "how do I get there". |
| `calculateStepFromPlayer(x,y)` | Single next step. |
| `setCursor(x,y)` / `moveCursor(dx,dy,…)` / `resetCursor()` / `getCursorPosition()` | Drive the examine cursor programmatically. |
| `playerInput(cmd,x,y,state)` / `bufferInput(cmd,array,state)` | Issue player actions. |
**Important caveat:** `getListByTag` reads `templatesByTag`, and `modules/tile/templatesByTag.lua` currently defines **only one tag** (`exit`, containing `way_out`, `building_door`, `horse`, `ladder_up`, `ladder_down`). The object tracker will need its own, much richer category table — see `03-implementation-plan.md`.
## 5. Actors, tiles, items
`models/actor.lua` — actor objects carry `templateId`, `x`, `y`, `name`, `nameObserved`, `nameAsSubject`, `nameAsOwner`, `description`, `hp`, `maxhp`, `visibilityAlpha`, `isPlayerControlled`, `parent` (the level).
Template tables are large data files: `modules/tile/templates.lua` (6745 lines), `models/actor/templates.lua` (2887), `modules/item/templates.lua` (7152). They are preprocessed at load. Tiles carry flags we can categorise on: `blocksMovement`, `blocksVision`, `tryInteract`, `loot`, `unlootable`, `isHidden`, `description`, plus `templateId`.
`modules/item/categories.lua` already defines 7 player-facing item categories (All / Potions / Ingredients / Raw materials / Consumables / Tools / Texts). Reuse these names verbatim for consistency.
## 6. Keyboard vs. mouse — better than expected
**Every one of the 28 states already implements `keypressed`.** The game is a keyboard-first roguelike with mouse as an alternative, not a mouse-only game. Verified by reading `inventory`, `craft`, `ingame`, `ingame_observe`, `ingame_dialogue`, `main_menu`.
`models/keybindings.lua` is a full rebindable-keys system with **contexts** (`ingame`, `aiming`, `menu`, `menuGlobal`, `menuHotkeys`, `hotbar`, with a `parents` inheritance chain), persisted to `keybindings.json`. Public API:
```lua
keybindings.getCommand(scancode, filter) -- scancode -> command name
keybindings.scancodeToDirection(scancode, filter) -- -> dx, dy
keybindings.isCommandDown(command, filter)
keybindings.getKeyForCommand(command, filter)
keybindings.checkForConflicts(filter, newScancode)
```
So the real accessibility gap is **not** "can't reach it with the keyboard" — it is **"nothing tells you what is currently focused."** That reframes the whole job from *reimplementing UIs* to *narrating existing focus state*. Much smaller, much more mod-friendly.
### The narration chokepoint
Menu-ish states converge on a `updateCursorPopup()`-style helper that ends in:
```lua
cursorPopup.set(inventoryWindow.getCursorPopupStats()) -- craft, inventory, refine, trade
cursorPopup.set(message, x, y, alignX, alignY) -- ingame_observe (levelCursorPopup)
```
- `views/inventoryCursorPopup.lua` → `set(lines, x, y, alignX, alignY)` where `lines` is `{ {text=…, icon=…}, … }`.
- `views/levelCursorPopup.lua` → same signature, `lines` from `levelWindow.getCursorPopupStats()` which just forwards `levelModel.getCursorMessage()`.
Patching `set` on these two view modules narrates the focused item in **`inventory`, `craft`, `refine`, `trade`, `ingame_observe`** at once. Five screens, two wrappers, zero game edits.
### The states that *don't* use cursorPopup
`main_menu`, `journal`, `character`, `research`, `worldmap`, `settings_menu`, `config_menu`, `keybindings_menu`, `load_game`, `new_game`, `actions_menu`, `alchemy_table`, `ingame_dialogue`, `menu_text`, `narration`, `ingame_message`.
These hold focus in **file-local variables** — typically `selectedOption`, plus `optionsList` / `options` / `activeTabIndex` / `selectedPage`. Example, `states/main_menu.lua`:
```lua
local selectedOption
local optionsList = { {id=1, title="Continue", isEnabledFunction=…, callback=…}, … }
```
File-locals are not reachable through the returned state table — **but they are upvalues of the state's own functions**, and `debug.getupvalue` reaches them. Verified working on this Lua:
```
1 selectedOption 3
2 optionsList <table>
```
So we can read (and even `debug.setupvalue`) menu focus **without editing a single game file**. See `02-hooking-strategy.md` for how far to push this.
## 7. Popups and other text surfaces
- `views/popupStack.lua` + `modules/{loot,recipe,lore,map,journal,tutorial,bestiary}Popup.lua` — transient popups pushed via `popupStack.add{…}`. `states/ingame.lua` exposes `ingame.lootPopup`, `ingame.recipePopup`, `ingame.lorePopup`, `ingame.mapPopup`, `ingame.journalPopup`, `ingame.tutorialPopup`, `ingame.displayMessage` — all good wrap points.
- `views/descriptionWindow.lua` — the persistent level/status panel.
- `views/hpbar.lua`, `views/hotbar.lua`, `views/statusGrid.lua` — player status, needs an explicit "read my status" key.
- `states/ingame_message.lua`, `states/menu_message.lua`, `states/menu_text.lua`, `states/narration.lua` — full-screen text.
## 8. TTS backend — `d:\code\love2talk`
```lua
tts = require "Love2talk"
tts.say(text, interrupt) -- interrupt defaults to false
tts.isSpeaking() -- unreliable under screen readers
```
Windows path uses `tolk.lua` → `ffi.load("Tolk")` → Tolk.dll, with `trySAPI(true)` so it falls back to SAPI 5 when no screen reader is running. `encoding.lua` converts to UTF-16.
**Deployment constraint:** `ffi.load("Tolk")` resolves through the OS loader, so `Tolk.dll` and its screen-reader client DLLs (`nvdaControllerClient64.dll`, `SAAPI64.dll`, …) must sit **next to `love.exe`** (or in a directory on `PATH`) — *not* inside the `.love` archive, and not inside the game source folder unless the game is launched with that folder as CWD. Copies of these DLLs already exist elsewhere on this machine (`D:\code\cafe`, `D:\code\dragonrpg-mapmaker`, `D:\code\DragonWarriorAccess`). This is an open question — see `04-open-questions.md` Q1.
Also note `Love2talk.lua` assigns `backend` as a **global**, and `tolk.lua` calls `Tolk_Load()` at require time with no error handling. We should wrap the require in `pcall` so a missing DLL degrades to silence rather than crashing the game.
## 9. Existing accessibility assets
`accessibility/assets/walltones/` — `walltone_{north,south,east,west}.wav`. Directional wall tones, presumably for the surroundings explorer. LÖVE + the game's `TEsound` wrapper (`tesound.lua`) can play these; `love.audio.setDistanceModel("inverse")` is already set in `main.lua` and `TEsound.playPositioned` exists (used for positional SFX), so spatialised cues are available if we want them.
## 10. Build / distribution
`build.lua` is a `love-build` config: name `sulphur-memories-alchemist`, version `0.4.2`, LÖVE `11.5`, output `dist`, with an `ignore` list. Adding `accessibility/` to the game is compatible with this build path. LÖVE 11.5 is installed locally at `C:\Program Files\LOVE\love.exe` (and `lovec.exe`, which gives a console window — useful for debugging).
`.luacheckrc` uses `std = "min+alch"` with a custom globals list; new accessibility globals would need adding there if we want lint to stay clean.
+140
View File
@@ -0,0 +1,140 @@
# 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.
@@ -0,0 +1,175 @@
# Implementation plan
Phases are ordered so each one is independently playable-and-testable. Nothing later is required for something earlier to be useful.
---
## Phase 0 — Baseline & bootstrap
1. `git init` in the game root; commit the pristine tree, tag `vanilla-0.4.2`. (`.gitignore`: `dist/`, `*.json` save data.)
2. Vendor `d:\code\love2talk` into `accessibility/vendor/love2talk/` (drop `objc/` and `main.lua`).
3. Resolve the Tolk DLL placement question (see `04-open-questions.md` Q1) and document the launch procedure.
4. `accessibility/speech.lua`:
- `pcall(require, "accessibility.vendor.love2talk")`; on failure, all functions become no-ops and we log once.
- `speech.say(text, interrupt)` — normalises `nil`/empty, collapses whitespace, strips the game's inline markup if any.
- `speech.interrupt(text)` — convenience for keypress-driven output.
- A small **queue** so consecutive `say` calls in one frame don't clobber each other: buffer within a frame, flush one joined utterance at end of `update`. This matters a lot — a single game turn can emit several `console.print` lines.
5. `accessibility/init.lua` with `preInit`/`postInit`, everything inside `pcall` so a mod bug never bricks the game.
6. Add the bootstrap line(s) to `main.lua`. Add mod globals to `.luacheckrc`.
**Test:** game launches, speaks "Main menu" on startup.
---
## Phase 1 — Console speech + line-by-line review
This is the highest-value, lowest-risk piece.
**`accessibility/history.lua`**
- Wrap `console.print(message, colorTemplate)`.
- Append `{text, colorTemplate, turn = world.turn, time = os.clock()}` to an unbounded (or N=1000 ring) list.
- Mirror the game's coalescing: if the new message equals the last, increment `times` rather than appending, so review output matches what a sighted player sees (`"You feel cold (x3)"`).
- Speak new lines automatically, non-interrupting, queued.
**Review keys** (working proposal, all rebindable):
| Key | Action |
|---|---|
| `Ctrl+[` / `Ctrl+]` | Previous / next history line (moves review cursor, speaks it) |
| `Ctrl+Home` / `Ctrl+End` | Jump to oldest / newest line |
| `Ctrl+\` | Speak current line again; press twice quickly to spell it |
| `Ctrl+Shift+C` | Copy current line to clipboard (`love.system.setClipboardText`) |
Entering review mode does **not** pause the game (it's turn-based; nothing advances without input anyway). Review cursor resets to newest whenever a new line arrives *unless* the user is actively reviewing.
**Open design point:** should routine flavour messages (`colorTemplate == "flavor"`, greyed out in-game) be spoken automatically or only on review? Proposal: a config toggle, default *on* but spoken last in the frame's queue. See Q4.
**Test:** walk around, take a hit, harvest something — all log text is spoken; review keys walk the full session history.
---
## Phase 2 — Tile inspection & surroundings
The game already has a full examine cursor (`states/ingame_observe.lua`), driven by `moveCursor(dx,dy)` and producing text via `levelWindow.getCursorPopupStats()` → `level.getCursorMessage()`.
**2a. Narrate the existing examine mode (nearly free).**
Patch `views/levelCursorPopup.set(lines, …)` to speak `lines`. That instantly makes the game's own examine mode (default key: the `examination` command) fully usable — arrow keys move the cursor, each tile is spoken.
Augment the spoken string beyond what's drawn:
- Prepend distance + direction from the player (`"3 north-east"`).
- Append passability (`"blocked"` / `"passable"` / `"water"`), whether the tile is currently visible vs. remembered (`"remembered"`), and whether it holds lootable items (`tile.loot and not tile.unlootable`).
- Announce cursor-hit-edge-of-map rather than silently not moving.
**2b. Surroundings explorer (new).**
A quick "what's around me" that doesn't require entering examine mode.
| Key | Action |
|---|---|
| Numpad 1–9 (with a modifier, e.g. `Alt`) | Speak the single tile in that direction, without moving |
| `Alt+5` | Speak the tile the player is standing on |
| A "scan" key | Speak all 8 neighbours in clockwise order, terse |
**2c. Wall tones.**
`accessibility/assets/walltones/walltone_{n,s,e,w}.wav` are present. Play the corresponding tone when the player *bumps* a wall (movement blocked) and optionally as a continuous ambient cue when a wall is directly adjacent. Use `TEsound.play(..., {"sfx","interface"})` so the game's own volume sliders apply. Needs a decision on when they fire — see Q5.
**Test:** blind-navigate a corridor in the starting area using only examine + surroundings + wall tones.
---
## Phase 3 — Object tracker
The headline feature. Key scheme as specified:
| Key | Action |
|---|---|
| `Ctrl+Page Up` / `Ctrl+Page Down` | Previous / next **category** |
| `Page Up` / `Page Down` | Previous / next **item within the current category** |
| `Shift+Page Up` / `Shift+Page Down` | Previous / next **instance of that specific object** |
| `Home` | Announce distance + direction of the current item |
Semantics I'm assuming (please confirm — Q2): a *category* is e.g. "Creatures"; an *item within a category* is a distinct kind, e.g. "rat"; an *instance* is a specific rat at (12,7). So Page Down cycles rat → beetle → guard, and Shift+Page Down cycles rat #1 → rat #2 → rat #3.
**`accessibility/categories.lua`** — the tracker's own category table, since the game's `templatesByTag` only defines `exit`. Proposed categories:
1. **Creatures** — from `level.actors`, excluding the player, filtered to visible-or-remembered. Sub-grouped by `templateId`, labelled with `actor.nameObserved`.
2. **Items** — lootable tiles / items on the ground (`getItemOnCoordinates`, and tiles with `loot`). Sub-grouped by item category from `modules/item/categories.lua`.
3. **Exits** — reuse `level.getListByTag("exit")` verbatim (doors, ladders, ways out, horses).
4. **Interactables** — tiles with `tryInteract` that aren't exits (containers, levers, alchemy tables, beds, fires).
5. **Terrain features** — water, fire, hazards (spore clouds, slime), stairs.
6. **Alchemy** — harvestable ingredient nodes specifically; this is an alchemy game and these matter enough to deserve their own category.
Category membership is determined by a predicate over `templateId` + tile flags. Building this requires a pass over `modules/tile/templates.lua` (6745 lines) to classify template IDs — the single biggest chunk of manual work in the project. **Mitigation:** classify by *flags first* (`tryInteract`, `loot`, `blocksMovement`, `blocksVision`) and fall back to an explicit ID list only for the cases flags get wrong. That should keep the hand-maintained list to dozens rather than hundreds of entries, and it degrades gracefully on new content in future versions.
**Scanning.** One function `tracker.rescan()` walking `x=1..xMax, y=1..yMax`, honouring `FoV` / `rememberedFoV` exactly as `getListByTag` does (so the tracker never reveals unseen information — important, this must not become a cheat). Results cached and invalidated on turn advance / level change / FoV recalculation. Sort each bucket by distance to player (`sortByDistanceToPlayer` logic already exists in `modules/level.lua`).
**Announcements.**
- Category change: `"Creatures, 4 kinds"`.
- Item change: `"rat, 3"` (3 instances).
- Instance change: `"rat 2 of 3, 5 tiles north-west"`.
- `Home`: `"5 tiles north-west, 7 steps by path"` — using `calculatePathFromPlayer` for the true walking distance, which is far more useful than Euclidean in a walled map.
- Empty category: `"Exits, none"`.
**Extras worth adding** (cheap, given the above): a key to move the examine cursor to the tracked object, and a key to auto-path to it (`bufferInput("move", calculatePathFromPlayer(x,y), ingame)` — the game already does exactly this in `ingame_observe.keypressed` under `confirmTarget`).
**Test:** in a populated level, enumerate every creature, exit and item; verify counts match what examine mode finds; verify nothing outside FoV is ever reported.
---
## Phase 4 — Menus and screens
Now the ~16 states that keep focus in file-locals.
**`accessibility/states/<statename>.lua`** — one descriptor per state:
```lua
return {
probe = "keypressed", -- which state function to read upvalues from
focus = "selectedOption", -- upvalue holding the index
list = "optionsList", -- upvalue holding the entries
title = "Main menu",
render = function(entry, index, total, state)
return ("%s, %d of %d%s"):format(entry.title, index, total,
entry.isEnabled and "" or ", unavailable")
end,
}
```
The generic driver (`patches/states.lua`) wraps `states.keypressed`: run the real handler, then re-probe the descriptor; if focus changed, speak. It also wraps `states.switch` to announce the screen title and the initially-focused entry.
States needing descriptors: `main_menu`, `settings_menu`, `config_menu`, `keybindings_menu`, `load_game`, `new_game`, `journal`, `character`, `research`, `worldmap`, `actions_menu`, `alchemy_table`, `ingame_dialogue`.
Special cases:
- **`ingame_dialogue`** — must speak the NPC's line *and* the numbered response options; the game already supports number keys 1–0 to pick a response directly, which is ideal. Needs a "repeat the NPC line" key.
- **`journal`, `character`, `research`** — tabbed and paged; need tab-change announcements (`activeTabIndex`, `selectedPage`, `selectedPageId`) plus a "read the page body" key that hands the long text to the history reviewer from Phase 1, so it can be read line by line.
- **`menu_text`, `narration`, `ingame_message`, `menu_message`** — full-screen prose. Route the whole text into the review buffer and speak it; these have no meaningful focus state, so they need no upvalue probing.
- **`craft`, `refine`, `trade`, `inventory`** — already covered by the `inventoryCursorPopup` patch from Phase 2a. But they also have a **confirmation sub-window with a slider** (`modules/slider.lua`, quantity selection). Left/Right adjusts it (`adjustByFive` modifier for ×5) — needs the current value spoken on change.
- **`ingame_targeting_melee` / `ingame_targeting_throw`** — targeting cursor; reuse the Phase-2 cursor narration plus valid/invalid-target announcement (`canThrowOnCell`, `level.recalculateInteractableArea`).
**Test:** complete a full loop — new game → dialogue → inventory → craft a potion → journal — with the monitor off.
---
## Phase 5 — Player status, polish, packaging
- **Status key(s):** HP (`player.actor.hp`/`maxhp`), active statuses (`views/statusGrid.lua`, `models/status`), current action (`player.currentSelectedAction`), hotbar contents, level name + in-game time (`level.getLevelDescriptionMessage()`), player coordinates.
- **Popup announcements:** wrap `popupStack.add` and the `ingame.*Popup` methods so loot/recipe/lore/bestiary/tutorial popups are spoken and reviewable.
- **Interruptions:** `ingame.checkInterruptions(hpLoss)` fires on damage — speak an urgent, interrupting alert.
- **Mod settings screen:** speech rate/verbosity, toggles for flavour text, wall tones, auto-speak. Either a new state or (cleaner, more decoupled) a plain `accessibility/config.json` plus hotkeys to toggle at runtime with spoken confirmation.
- **Self-test:** startup check that walks every state descriptor and every patch target, logging what resolved. This is the version-bump canary.
- **Packaging:** decide `.love`/exe distribution vs. source drop, given the DLL constraint (Q1). Update `build.lua`'s `ignore` if needed.
- **Docs:** a player-facing `accessibility/README.md` with the full keymap.
---
## Effort estimate
| Phase | Relative size | Risk |
|---|---|---|
| 0 Bootstrap | S | Low (DLL placement is the only unknown) |
| 1 Console | S | Low |
| 2 Tiles | M | Low — builds on existing examine mode |
| 3 Tracker | L | Medium — template classification is the grind |
| 4 Menus | L | Medium — 13 descriptors, upvalue fragility |
| 5 Polish | M | Low |
The pleasant surprise is that Phases 1, 2a and much of 4 lean on text the game **already generates** (`console.print`, `getCursorMessage`, `getCursorPopupStats`). We are mostly re-routing existing strings to speech, not writing a description engine.
+107
View File
@@ -0,0 +1,107 @@
# Open questions
Ordered roughly by how much they block work. Q1–Q3 affect the design; the rest are preferences I can pick defaults for if you'd rather not decide up front.
---
### Q1 — Tolk DLL placement and how you launch the game *(blocking Phase 0)*
`ffi.load("Tolk")` goes through the Windows loader, so `Tolk.dll` + the screen-reader clients (`nvdaControllerClient64.dll`, `SAAPI64.dll`, …) must be next to the **executable** or on `PATH` — they can't live inside a `.love` archive.
Copies already exist on this machine at `D:\code\cafe`, `D:\code\dragonrpg-mapmaker`, `D:\code\DragonWarriorAccess`.
- How are you running the game right now — `love.exe "D:\code\Sulphur Memories..."` from a shortcut, or something else?
- Is the end goal a **fused .exe** (via `build.lua` / love-build, DLLs shipped alongside) or a **source folder + system LÖVE**?
- Are you happy for me to copy the DLLs into the project (or a `dist/` staging folder), or do you want them kept out of the tree?
---
### Q2 — Object tracker semantics *(blocking Phase 3)*
I've assumed a three-level hierarchy:
- **Category** (`Ctrl+PgUp/PgDn`) → "Creatures", "Items", "Exits", …
- **Item within category** (`PgUp/PgDn`) → distinct *kinds*: "rat", "beetle", "guard"
- **Instance** (`Shift+PgUp/PgDn`) → the specific rat: "rat 2 of 3, 5 tiles north-west"
Is that the model you have in mind? The alternative reading is that PgUp/PgDn walks a flat distance-sorted list of every object in the category, and Shift only kicks in for same-named ones. Both are defensible; the first is more structured, the second faster to sweep.
Also: my proposed categories are **Creatures / Items / Exits / Interactables / Terrain features / Alchemy**. Anything you'd add, drop, or split? (Alchemy ingredient nodes are broken out separately because the game is built around harvesting them — tell me if that's over-thinking it.)
---
### Q3 — Verbosity and how much to *add* vs. *relay*
The game already produces good description text (`getCursorMessage`, the popup lines). I can either:
- **(a) Relay only** — speak exactly what a sighted player reads. Faithful, low risk of the mod drifting from the game.
- **(b) Relay + augment** — add distance, direction, passability, visible-vs-remembered, "has loot", etc.
I've assumed **(b)** for tile examination, since bare descriptions without spatial context aren't much use non-visually. Confirm? And should augmentation be a verbosity setting (terse / normal / verbose) rather than fixed?
---
### Q4 — Auto-speak policy for the console
A single game turn can emit several log lines (combat flavour, status changes, ambient flavour). Options:
- Speak everything, queued, in order — thorough but can lag behind fast play.
- Speak everything except `colorTemplate == "flavor"` (the greyed-out ambient text), which stays review-only.
- Speak everything, but let a new keypress interrupt the pending queue.
I lean toward: **speak everything, queued; a new player action interrupts leftover queue**; with a config toggle to mute flavour. Sound right?
Related: the game **coalesces** repeated identical messages into `"message (x3)"`. Should speech do the same, or announce each occurrence?
---
### Q5 — Wall tones: when do they fire?
You've supplied `walltone_{north,south,east,west}.wav`. Candidate triggers:
- **On bump** — the player tries to move into a wall and can't. Unambiguous, low noise.
- **On adjacency** — a tone whenever a wall is directly adjacent, as an ambient orientation cue. Richer, but potentially constant in a corridor.
- **On the surroundings-scan key only** — explicit, never intrusive.
What were you picturing? And should they be **positioned** audio (LÖVE's `TEsound.playPositioned`, panned by direction — the game already sets an inverse distance model) or flat stereo?
---
### Q6 — Keybindings: integrate or stay separate?
The game has a proper rebindable-keys system with contexts and a `keybindings_menu` screen, persisted to `keybindings.json`.
- **Integrate:** register our commands into `models/keybindings.lua`'s tables so they show up in the game's own rebinding UI and get conflict detection for free. Nicer for the player; more coupling, and it writes our commands into the player's `keybindings.json`.
- **Separate:** our own `accessibility/keys.json` and our own (spoken) rebinding flow. Fully decoupled; duplicates a system that already exists.
I lean **separate** for decoupling, but reusing `keybindings.checkForConflicts` to warn about clashes. Preference?
---
### Q7 — How much game-file editing is acceptable, really?
`debug.getupvalue` lets us read menu focus with **zero** game edits, at the cost of depending on internal variable names (`selectedOption`, `optionsList`) that could be renamed in any future version. The alternative is ~13 small patches — one `access.announce(...)` call per state — which are far more robust but must be re-applied on every version bump (manageable via the git-rebase workflow in `02-hooking-strategy.md`).
My recommendation: **upvalue probing by default + a startup self-test that loudly reports any probe that stopped resolving**, so a broken version bump is obvious immediately. But if you'd rather have boring-and-robust over clever-and-fragile, say so now — it changes Phase 4's shape substantially.
---
### Q8 — Scope of "everything mouse-only"
Good news from the research: **all 28 states already implement `keypressed`**, and the ones I read (`inventory`, `craft`, `ingame`, `ingame_observe`, `ingame_dialogue`, `main_menu`) are genuinely fully keyboard-driven. So the gap is *narration*, not *reachability*.
I haven't yet exhaustively verified every state for mouse-only corners — likely suspects are the slider in `craft`/`refine`, the `worldmap` travel targets, and `keybindings_menu`'s key-capture flow. Do you want me to do a full keyboard-reachability audit of all 28 states as an explicit deliverable (adds a doc, moderate time), or handle gaps opportunistically as they surface during Phase 4?
---
### Q9 — Testing and target screen reader
- Which screen reader do you use (NVDA, JAWS, SAPI)? Affects interrupt behaviour and punctuation handling.
- Do you want me to actually **run** the game with `lovec.exe` during development to verify hooks fire (I can, LÖVE 11.5 is installed), or would you rather drive testing yourself and have me work from the source?
- Is there an existing save file I can load to reach a populated level quickly, or should I plan around the tutorial start?
---
### Q10 — Upstream intent
Is this purely a personal/community mod, or do you intend to offer it to the developer (Spellweaver) for upstreaming? If upstreaming is plausible, that argues for the clean-patches approach over upvalue probing, and for integrating with the game's own keybindings and settings systems rather than running parallel ones.
+128
View File
@@ -0,0 +1,128 @@
# Decisions
Answers given, and their consequences. Supersedes the corresponding entries in `04-open-questions.md`.
---
## D1 (was Q1) — Development uses system LÖVE
Dev setup: `C:\Program Files\LOVE\love.exe` (11.5, matches `build.lua`'s `love = '11.5'`), running the source folder directly. `lovec.exe` in the same directory gives a console window and is what we'll use for debugging.
Tolk DLLs therefore go **next to `love.exe` in `C:\Program Files\LOVE\`** — that's the executable directory the OS loader searches. (Copies available at `D:\code\cafe`, `D:\code\dragonrpg-mapmaker`, `D:\code\DragonWarriorAccess`.) Needs an elevated copy, one time.
Distribution deferred until the mod works. Noted for later: whatever we ship must place the DLLs beside the executable, so a plain `.love` file can never be the whole delivery.
---
## D2 (was Q2) — Tracker hierarchy confirmed
Three levels, as proposed:
| Level | Key | Example |
|---|---|---|
| Category | `Ctrl+PgUp` / `Ctrl+PgDn` | "Creatures" |
| Item (kind) | `PgUp` / `PgDn` | "rat" |
| Instance | `Shift+PgUp` / `Shift+PgDn` | that specific rat at (12,7) |
| — | `Home` | announce distance + direction of current instance |
Categories accepted: **Creatures / Items / Exits / Interactables / Terrain features / Alchemy**, conditional on covering everything. See D4 for the coverage check.
---
## D3 (was Q7 + Q10) — Small patches now, upvalue probing kept as a fallback layer
Decision: use **small explicit patches** in game state files where they genuinely simplify things — but keep the option of switching to zero-edit operation, in case the game ships on Steam and editing files stops being practical for end users.
### Why Steam changes things
A Steam LÖVE release is normally a **fused executable** (the `.love` archive appended to `love.exe`). Editing game source then means unpack → patch → repack → replace the exe, which Steam's file integrity check may revert on next update. That's a bad end-user story, and it's exactly the scenario the upvalue technique was designed for.
### The design that keeps both doors open
Make the **state descriptor** the single abstraction, and let each descriptor declare *how* it gets focus — with two interchangeable backends:
```lua
-- accessibility/states/main_menu.lua
return {
title = "Main menu",
-- Backend A (preferred): the patched game file calls access.announce(...) directly.
-- Backend B (fallback): read the same values as upvalues, no game edit needed.
probe = { fn = "keypressed", focus = "selectedOption", list = "optionsList" },
render = function(entry, i, n)
return ("%s, %d of %d%s"):format(entry.title, i, n, entry.isEnabled and "" or ", unavailable")
end,
}
```
`render` — the part that carries all the per-screen knowledge — is **identical for both backends**. Switching modes is a global config flag, not a rewrite:
- `mode = "patched"` → the driver trusts `access.announce(entry, i, n)` calls injected into game files.
- `mode = "probe"` → the driver wraps `states.keypressed`, reads upvalues via `debug.getupvalue`, and calls the same `render`.
This costs us little now (we write `render` either way, plus a ~40-line probe driver) and means a Steam release is a config change plus fixing whichever probes drifted — not a redesign.
### Consequences
- Patches stay **mechanical and uniform**: a single `access.announce(...)` line per focus-change site, nothing else. Easy to read in a diff, easy to re-apply.
- The git baseline (`vanilla-0.4.2` tag) becomes load-bearing — it's how we rebase patches onto a new version. Do this in Phase 0.
- The **startup self-test** is still required, and now checks both backends: which patch sites are present, and which probes still resolve. This is the version-bump canary either way.
- Upstreaming to Spellweaver stays viable, since the patches are small and additive. Worth revisiting once there's something working to show.
---
## D4 — Category coverage check (the condition on D2)
I classified all tile templates statically to confirm the six categories don't leave gaps.
**Scale is much smaller than the line counts suggested:**
| Template set | Count |
|---|---|
| Tile templates (`modules/tile/templates.lua`, 6745 lines) | **231** |
| Actor templates (`models/actor/templates.lua`) | **40** |
| Item templates (`modules/item/templates.lua`) | **344** |
231 tile templates is very tractable — the line count is inflated by inline behaviour functions, not by breadth.
**Parent inheritance is already flattened for us.** `modules/tile/preprocess.lua` copies every missing field down from `template.parent` at load time. Since `main.lua` runs `preprocessTileTemplates()` during `love.load`, by the time our code runs, `require("modules.tile.templates")` gives fully-resolved templates — we read `template.name` / `template.description` / flags directly with no chain-walking.
*(Minor upstream quirk noticed: the flatten is a single pass over `pairs()`, so a grandchild template may inherit from a not-yet-flattened parent depending on hash order. Pre-existing game behaviour, not ours to fix — but if a template ever looks oddly under-specified, this is why.)*
**Only 6 templates are genuinely un-nameable** (no name, no description, no parent) — all invisible mechanics that *should* be excluded from tracking:
`blank`, `throw_blocker`, `movement_blocker`, `fire_detector`, `wall_features_position_dummy`, `ladder_down_hidden`.
Every other template resolves to a name/description and lands in a category. **The six categories cover the space.**
**The classifier flags exist and are clean:**
| Signal | Count | Maps to |
|---|---|---|
| `tryInteractFunction` | 139 | Interactables / Exits |
| `loot` | 68 | Items (+ Alchemy when the loot is an ingredient) |
| `blocksMovement` | 133 | Terrain (walls) |
| `blocksVision` | 127 | Terrain |
| `isThreatening` | 15 | Terrain features → hazards |
| `getPassability` | 11 | Terrain → water/special |
| `isLightSource` | 50 | (not a category; useful description detail) |
| `isPushable` / `isBreakableByHand` | 17 / 17 | Interactables |
| `isWallFeature` | 22 | Terrain |
Critically, `modules/tile.lua:234` defines the runtime flag as:
```lua
self.tryInteract = (self.loot or self.tryInteractFunction) and function(self,actor) ... end
```
So **`tile.tryInteract` alone cleanly separates "the player can do something here" from scenery** — exactly the flag-first classification proposed in `03-implementation-plan.md`, and it's authoritative rather than heuristic.
**Residual hand-maintained list** is therefore small: the 6 exclusions above, the existing `exit` tag set (`way_out`, `building_door`, `horse`, `ladder_up`, `ladder_down`), and an ingredient-node list to split **Alchemy** out of **Items**. Dozens of entries, not hundreds — and new content in future versions falls through to a sensible default category rather than vanishing.
---
## Still open
From `04-open-questions.md`: **Q3** (verbosity: relay vs. augment), **Q4** (console auto-speak policy), **Q5** (wall tone triggers), **Q6** (keybinding integration), **Q8** (full keyboard audit as explicit deliverable?), **Q9** (which screen reader; do you want me running the game during dev).
None of these block starting Phase 0. Q5 and Q9 become relevant at Phase 2; Q4 at Phase 1.
+23
View File
@@ -0,0 +1,23 @@
# Accessibility mod — documentation
Research and planning for making *Sulphur Memories: Alchemist* 0.4.2 blind-accessible.
| Doc | Contents |
|---|---|
| [01-architecture.md](01-architecture.md) | How the game is built: states, console, level model, keybindings, where text already exists. |
| [02-hooking-strategy.md](02-hooking-strategy.md) | How to attach the mod with (near) zero edits to game files. |
| [03-implementation-plan.md](03-implementation-plan.md) | Six phases, each independently testable. |
| [04-open-questions.md](04-open-questions.md) | Decisions needed before/while building. |
| [05-decisions.md](05-decisions.md) | Answers given so far, and what they imply. Supersedes Q1/Q2/Q7/Q10. |
## The short version
Three findings shape everything:
1. **The game is already keyboard-complete.** All 28 states implement `keypressed`; there's a full rebindable-keys system with contexts. The accessibility gap is that *nothing announces what is focused* — not that things are unreachable. This makes the job much smaller than a typical mouse-driven game.
2. **The game already writes the text we need to speak.** `console.print` is a single chokepoint for all log messages. `level.getCursorMessage()` returns tile/creature descriptions. `getCursorPopupStats()` feeds the item popups in five screens. We are mostly *re-routing existing strings to speech*, not writing a description engine.
3. **Everything is a `require`d singleton, and `debug.getupvalue` works.** Module functions can be monkey-patched in place, and the file-local `selectedOption` variables in menu states are readable from outside. A one-line bootstrap in `main.lua` is the only unavoidable edit.
Proposed footprint: **1–3 lines added to `main.lua`**, everything else new files under `accessibility/`. A git baseline tagged `vanilla-0.4.2` makes re-application on future versions a rebase.
+94
View File
@@ -0,0 +1,94 @@
# SCRATCH — current working state
**Read this first on any handoff.** Living document: update it whenever state changes, before ending a turn.
Last updated: 2026-08-01 · Currently: **Phase 0, starting**
---
## What this project is
Blind-accessibility mod for *Sulphur Memories: Alchemist* 0.4.2 (LÖVE 11.5, Lua/LuaJIT), extracted to
`D:\code\Sulphur Memories Alchemist 0.4.2 sanctified`. All mod code lives under `accessibility/`.
Design goal: **change as little game code as possible** so the mod re-applies cleanly to new versions.
Full research is in `accessibility/docs/` — read `README.md` there for the map.
---
## Confirmed decisions (do not relitigate)
| # | Decision |
|---|---|
| D1 | Dev uses system LÖVE at `C:\Program Files\LOVE\` (`lovec.exe` for console output). Distribution deferred. |
| D2 | Tracker hierarchy: category (`Ctrl+PgUp/Dn`) → kind (`PgUp/Dn`) → instance (`Shift+PgUp/Dn`), `Home` = distance+direction. |
| D3 | **Small explicit patches** to game files are allowed where they simplify things — but every state descriptor also carries a `probe` spec so we can flip to zero-edit `debug.getupvalue` mode if the game ships fused on Steam. `render` is shared by both backends. |
| D4 | Six tracker categories confirmed and coverage-verified: Creatures / Items / Exits / Interactables / Terrain features / Alchemy. |
| — | Screen reader: **NVDA**. Running the game via `lovec` during dev is approved. |
| — | User is blind — all verification must be describable in text; don't rely on screenshots. |
## Still-open questions (in `04-open-questions.md`)
Q3 verbosity (relay vs augment) · Q4 console auto-speak policy · Q5 wall-tone triggers ·
Q6 keybinding integration vs separate · Q8 full keyboard audit as deliverable?
None block Phase 0. Q4 lands in Phase 1, Q5 + verbosity in Phase 2.
---
## Key facts worth not rediscovering
- **`console.print(msg, colorTemplate)`** in `modules/console.lua` is the *single* chokepoint for all game log text. Repeated identical messages coalesce into `"msg (x3)"` via a `times` counter. Buffer entries expire after 3s — useless as history, so we keep our own.
- **`level.getCursorMessage()`** returns the description under the examine cursor (actor first, then top visible tile). The game already has a full arrow-key examine mode: `states/ingame_observe.lua`.
- **`cursorPopup.set(...)`** — patching `views/inventoryCursorPopup.lua` + `views/levelCursorPopup.lua` narrates focus in `inventory`, `craft`, `refine`, `trade`, `ingame_observe` at once.
- **All 28 states already implement `keypressed`.** The gap is narration, not reachability.
- **`modules/tile.lua:234`**: `tile.tryInteract = (self.loot or self.tryInteractFunction) and function...` — authoritative "player can do something here" flag. Basis of tracker classification.
- **Template counts:** 231 tiles, 40 actors, 344 items. Parent inheritance is *already flattened* by `modules/tile/preprocess.lua` during `love.load`, so read fields directly.
- **6 un-nameable mechanical templates to exclude:** `blank`, `throw_blocker`, `movement_blocker`, `fire_detector`, `wall_features_position_dummy`, `ladder_down_hidden`.
- **`debug.getupvalue` works** on this Lua build — verified reading `selectedOption` / `optionsList` from a state's `keypressed`.
- **Tolk constraint:** `ffi.load("Tolk")` uses the OS loader, so DLLs must sit next to `love.exe` — they can *never* live inside a `.love` archive.
- `tolk.lua` calls `Tolk_Load()` at require time with no error handling, and `Love2talk.lua` leaks `backend` as a global. Always `pcall` the require.
---
## Current status
### Done
- Full architecture research → `docs/01-architecture.md`
- Hooking strategy → `docs/02-hooking-strategy.md`
- Phased plan → `docs/03-implementation-plan.md`
- Open questions → `docs/04-open-questions.md`
- Decisions + category coverage proof → `docs/05-decisions.md`
### In progress — Phase 0 (bootstrap)
Tasks #1–#5 in the task list. Nothing implemented yet; no game files modified yet.
### Next after Phase 0
Phase 1 = console history + speech + review keys. Highest value, lowest risk.
---
## Invariants for all mod code
1. Every wrapper calls through to the original and returns its results verbatim (`return realFn(...)` — preserve multiple returns).
2. Speech must never throw. Missing DLL ⇒ silent no-op, game still runs.
3. No behaviour change for sighted players.
4. Never reveal information outside `FoV` / `rememberedFoV` — the tracker must not become a cheat.
5. Grid scans (`xMax × yMax`) happen on keypress or turn change, never per frame.
6. Our keys fall through to the game handler unless we own them.
---
## Modified game files (keep this list exhaustive)
| File | Change | Why |
|---|---|---|
| *(none yet)* | | |
---
## Scratchpad / notes
- `.luacheckrc` uses `std = "min+alch"` with an explicit globals list — add mod globals there to keep lint clean.
- Upstream quirk (not ours to fix): `modules/tile/preprocess.lua` flattens parents in a single `pairs()` pass, so a grandchild may inherit from a not-yet-flattened parent depending on hash order.
- Tolk DLL sources on this machine: `D:\code\cafe`, `D:\code\dragonrpg-mapmaker`, `D:\code\DragonWarriorAccess`.
+118
View File
@@ -0,0 +1,118 @@
-- accessibility/init.lua — mod entry point.
--
-- This is the ONLY thing the game calls into. main.lua gains exactly one line:
--
-- require("accessibility").initialize()
--
-- placed after console.prepare() and before the first states.switch(). By that
-- point templates are preprocessed, keybindings are loaded, states.setup() has
-- run, and the console canvas exists — everything we patch is ready.
--
-- Everything here is wrapped so that a bug in the mod degrades to "no speech"
-- rather than taking the game down with it.
local accessibility = {}
local speech = require("accessibility.speech")
local stateNames = require("accessibility.stateNames")
local initialized = false
--------------------------------------------------------------------------------
-- Patch registry
--
-- Later phases register their patches here. Each entry is {name, fn}; fn is called
-- inside a pcall during initialize(), and a failure disables only that patch.
--------------------------------------------------------------------------------
local patches = {}
function accessibility.registerPatch(name, fn)
patches[#patches + 1] = {name = name, fn = fn}
end
local patchResults = {}
local function applyPatches()
for _, patch in ipairs(patches) do
local ok, err = pcall(patch.fn)
patchResults[patch.name] = ok and "ok" or tostring(err)
if not ok then
print("[accessibility] patch '" .. patch.name .. "' FAILED: " .. tostring(err))
end
end
end
-- Startup self-test output. This is the version-bump canary: when the game
-- updates and something we hook has moved, it shows up here immediately rather
-- than as silence three screens into a playthrough.
function accessibility.selfTest()
print("[accessibility] --- self test ---")
print("[accessibility] TTS: " .. (speech.isAvailable() and "available"
or ("UNAVAILABLE (" .. tostring(speech.getFailureReason()) .. ")")))
if speech.isAvailable() then
-- Distinguishes "DLL loaded" from "actually reached a screen reader or SAPI".
print("[accessibility] speech channel: " .. (speech.hasSpeech() and "yes" or "NO — nothing will be audible"))
end
for name, result in pairs(patchResults) do
print("[accessibility] patch " .. name .. ": " .. result)
end
print("[accessibility] --- end self test ---")
end
--------------------------------------------------------------------------------
-- Core hooks into states.lua
--------------------------------------------------------------------------------
local function hookStates()
local states = require("states")
-- Screen changes: announce the new screen.
local realSwitch = states.switch
states.switch = function(newState, params)
local result = realSwitch(newState, params)
-- After the switch, so a state's open() has already set up its focus.
pcall(function()
local name = stateNames[newState] or newState
speech.say(name, true)
end)
return result
end
-- Per-frame speech flush. main.lua calls states.update(dt) via a table lookup,
-- so wrapping the field here avoids editing love.update.
local realUpdate = states.update
states.update = function(dt)
local result = realUpdate(dt)
pcall(speech.flush)
return result
end
end
--------------------------------------------------------------------------------
-- Entry point
--------------------------------------------------------------------------------
function accessibility.initialize()
if initialized then
return
end
initialized = true
local ok, err = pcall(function()
speech.initialize()
hookStates()
applyPatches()
end)
if not ok then
print("[accessibility] initialisation FAILED, game continues unmodified: " .. tostring(err))
return
end
accessibility.selfTest()
end
return accessibility
Binary file not shown.
Binary file not shown.
Binary file not shown.
+179
View File
@@ -0,0 +1,179 @@
-- speech.lua — the mod's only route to text-to-speech.
--
-- Invariant: speech must NEVER throw. If the TTS backend is missing or broken,
-- every function here becomes a no-op and the game runs exactly as vanilla.
--
-- Utterances are queued within a frame rather than spoken immediately. A single
-- game turn routinely emits several console.print calls ("You hit the rat.",
-- "The rat dies.", "You feel cold."), and speaking each one with interrupt would
-- leave you hearing only the last. flush() joins the frame's queue into one
-- utterance, in order.
local speech = {}
local tts
local available = false
local failureReason
local queue = {}
local pendingInterrupt = false
-- Mirror every utterance to stdout. Worth keeping on by default during
-- development: it makes speech verifiable from the lovec console log rather than
-- only by ear, which matters when diagnosing whether a hook fired at all versus
-- fired with empty text.
speech.logToConsole = true
--------------------------------------------------------------------------------
-- Initialisation
--------------------------------------------------------------------------------
function speech.initialize()
-- tolk.lua calls Tolk_Load() at require time and raises if the DLL is absent,
-- so this pcall is load-bearing, not defensive garnish.
local ok, result = pcall(require, "accessibility.vendor.love2talk.Love2talk")
if not ok then
available = false
failureReason = tostring(result)
print("[accessibility] TTS unavailable, running silent: " .. failureReason)
return false
end
tts = result
available = true
print("[accessibility] TTS initialised")
return true
end
function speech.isAvailable()
return available
end
function speech.getFailureReason()
return failureReason
end
-- Whether Tolk found a real speech channel (running screen reader, or SAPI
-- fallback). isAvailable() only means the library loaded.
function speech.hasSpeech()
if not available or not tts.hasSpeech then
return false
end
local ok, result = pcall(tts.hasSpeech)
return ok and result or false
end
--------------------------------------------------------------------------------
-- Text normalisation
--------------------------------------------------------------------------------
-- Screen readers handle a single clean line far better than raw game strings,
-- which arrive with newlines (the game wraps its own text) and padding.
local function normalise(text)
if text == nil then
return nil
end
if type(text) ~= "string" then
text = tostring(text)
end
text = text:gsub("[\r\n]+", ". ")
text = text:gsub("%s+", " ")
text = text:gsub("^%s+", ""):gsub("%s+$", "")
if text == "" then
return nil
end
return text
end
speech.normalise = normalise
--------------------------------------------------------------------------------
-- Speaking
--------------------------------------------------------------------------------
-- Queue text to be spoken at the end of this frame.
-- interrupt=true discards anything already queued this frame AND cuts off whatever
-- the screen reader is currently saying. Use it for keypress-driven output, where
-- the user expects an immediate answer to the key they just pressed.
function speech.say(text, interrupt)
text = normalise(text)
if not text then
return
end
if interrupt then
queue = {text}
pendingInterrupt = true
else
queue[#queue + 1] = text
end
end
-- Convenience: the common case for hotkeys.
function speech.interrupt(text)
speech.say(text, true)
end
-- Speak immediately, bypassing the queue. Reserve this for urgent alerts (taking
-- damage) where waiting for end-of-frame is wrong.
function speech.immediate(text, interrupt)
text = normalise(text)
if not text or not available then
return
end
pcall(tts.say, text, interrupt ~= false)
end
-- Called once per frame from init.lua's update hook.
function speech.flush()
if #queue == 0 then
return
end
local text = table.concat(queue, ". ")
local shouldInterrupt = pendingInterrupt
queue = {}
pendingInterrupt = false
if speech.logToConsole then
print("[speech]" .. (shouldInterrupt and "!" or " ") .. " " .. text)
end
if not available then
return
end
pcall(tts.say, text, shouldInterrupt)
end
function speech.silence()
queue = {}
pendingInterrupt = false
if not available or not tts.silence then
return
end
pcall(tts.silence)
end
-- Unreliable under screen readers (they don't report speaking state); only
-- meaningful with the SAPI fallback. Do not use for timing.
function speech.isSpeaking()
if not available then
return false
end
local ok, result = pcall(tts.isSpeaking)
return ok and result or false
end
return speech
+38
View File
@@ -0,0 +1,38 @@
-- Human-readable names for the game's states, spoken on screen change.
--
-- Keys must match the filenames in states/ exactly, since states.switch() is
-- called with the filename. Anything missing falls back to the raw state name,
-- which is ugly but never wrong.
return {
main_menu = "Main menu",
new_game = "New game",
load_game = "Load game",
settings_menu = "Settings",
config_menu = "Configuration",
keybindings_menu = "Key bindings",
menu_message = "Message",
menu_text = "Text",
ingame = "Game",
ingame_observe = "Examine",
ingame_dialogue = "Conversation",
ingame_message = "Message",
ingame_popup = "Popup",
ingame_say = "Console",
ingame_wait = "Wait",
ingame_targeting_melee = "Targeting, melee",
ingame_targeting_throw = "Targeting, throw",
inventory = "Inventory",
character = "Character",
journal = "Journal",
research = "Research",
worldmap = "World map",
actions_menu = "Actions",
trade = "Trade",
craft = "Crafting",
refine = "Refining",
alchemy_table = "Alchemy table",
narration = "Narration",
}
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2021 pitermach
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+51
View File
@@ -0,0 +1,51 @@
-- love2talk — vendored from d:\code\love2talk
-- See VENDORED.md for the list of local modifications.
local os = love.system.getOS()
local backend -- upstream leaked this as a global; scoped to the module here
if os == "Windows" then
backend = require "accessibility.vendor.love2talk.tolk"
backend.trySAPI(true)
elseif os == "OS X" then
backend = require "accessibility.vendor.love2talk.macspeech"
end
local function say(text, interrupt)
if not backend then
return
end
interrupt = interrupt or false
if os == "Windows" then
backend.output(text, interrupt)
else
backend.output(text)
end
end
local function isSpeaking()
if not backend then
return false
end
return backend.isSpeaking()
end
local function silence()
if not backend or not backend.silence then
return
end
backend.silence()
end
-- LOCAL ADDITION: exposed for diagnostics. Tells us whether Tolk actually found a
-- speech channel (a running screen reader, or the SAPI fallback) as opposed to
-- merely having loaded the DLL successfully.
local function hasSpeech()
if not backend or not backend.hasSpeech then
return false
end
return backend.hasSpeech()
end
return {say = say, isSpeaking = isSpeaking, silence = silence, hasSpeech = hasSpeech}
+47
View File
@@ -0,0 +1,47 @@
# Vendored: love2talk
Source: `d:\code\love2talk` (by Piotr, MIT — see `LICENSE`).
Vendored 2026-08-01 for the Sulphur Memories accessibility mod.
## What was copied
`Love2talk.lua`, `tolk.lua`, `encoding.lua`, `macspeech.lua`, `LICENSE`.
## What was dropped
- `objc/` — Mac-only submodule, ~4 files. Re-add if a macOS build is ever wanted;
`macspeech.lua` is kept so that only the submodule is missing.
- `main.lua` — upstream's standalone demo, would collide with the game's own `main.lua`.
- `readme.md`, `.gitmodules`, `.gitignore` — not needed here.
## Local modifications
| File | Change | Why |
|---|---|---|
| `Love2talk.lua` | `require "Tolk"` → `require "accessibility.vendor.love2talk.tolk"` | Upstream assumes it sits at the project root. Also fixes a latent case bug: it required `"Tolk"` but the file is `tolk.lua` — works on case-insensitive Windows, would fail elsewhere. |
| `Love2talk.lua` | `backend` made `local` | Upstream leaked it as a global. The game's `.luacheckrc` uses a strict globals list, and a stray global risks colliding with game code. |
| `Love2talk.lua` | `say`/`isSpeaking` guard on `backend == nil` | On an unsupported OS upstream sets no backend, then dereferences it. Now degrades to silence. |
| `Love2talk.lua` | added `silence()` passthrough | `tolk.lua` already exposes `Tolk_Silence`; we need it to cut speech on interrupt. |
| `tolk.lua` | `require "encoding"` → `require "accessibility.vendor.love2talk.encoding"` | Same path reason. |
Nothing else was touched. `encoding.lua` and `macspeech.lua` are byte-identical to upstream.
## Runtime requirement — DLLs
`tolk.lua` does `ffi.load("Tolk")`, which goes through the **Windows loader**, not LÖVE's
virtual filesystem. The DLLs therefore **cannot live inside a `.love` archive or in this
folder** — they must sit next to the executable (`love.exe`) or on `PATH`.
Required next to `love.exe`:
- `Tolk.dll`
- `nvdaControllerClient64.dll` (NVDA — our target screen reader)
- `SAAPI64.dll` (System Access)
- plus any other screen-reader clients Tolk should support
`Love2talk.lua` calls `trySAPI(true)`, so with no screen reader running it falls back to
SAPI 5 and still speaks.
Also note `tolk.lua` calls `Tolk_Load()` **at require time** with no error handling — a
missing DLL raises immediately. Always `pcall` the require; `accessibility/speech.lua`
does this and degrades to a silent no-op.
+40
View File
@@ -0,0 +1,40 @@
local ffi = require "ffi"
local kernel32 = ffi.load("kernel32")
ffi.cdef[[
int Beep(int x, int y);
typedef unsigned int UINT;
typedef unsigned int DWORD;
typedef const char * LPCSTR;
typedef char * LPSTR;
typedef wchar_t * LPWSTR;
typedef const wchar_t *LPCWSTR;
typedef int *LPBOOL;
int WideCharToMultiByte(UINT CodePage,
DWORD dwFlags,
LPCWSTR lpWideCharStr, int cchWideChar,
LPSTR lpMultiByteStr, int cbMultiByte,
LPCSTR lpDefaultChar,
LPBOOL lpUsedDefaultChar
);
int MultiByteToWideChar(UINT CodePage,
DWORD dwFlags,
LPCSTR lpMultiByteStr, int cbMultiByte,
LPWSTR lpWideCharStr, int cchWideChar);
]]
local CP_UTF8 = 65001
local function to_utf16(s)
local needed = kernel32.MultiByteToWideChar(CP_UTF8, 0, s, -1, NULL, 0)
local buf = ffi.new("wchar_t[?]", needed)
local written = kernel32.MultiByteToWideChar(CP_UTF8, 0, s, -1, buf, needed)
return ffi.string(buf, written*2)
end
local function to_utf8(s)
local needed = kernel32.WideCharToMultiByte(CP_UTF8, 0, s, -1, nil, 0, nil, nil)
local buf = ffi.new("char[?]", needed)
local written = kernel32.WideCharToMultiByte(CP_UTF8, 0, s, -1, buf, needed, nil, nil)
return ffi.string(buf, written - 1)
end
return {to_utf8=to_utf8, to_utf16=to_utf16}
+24
View File
@@ -0,0 +1,24 @@
local objc = require("objc.objc")
objc.load("AVFoundation")
local backend = {}
backend.synth = objc.AVSpeechSynthesizer:alloc():init()
-- Because AVSpeechSynthesizer doesn't give us the proper default voice (it returned ALbert on my system for some reason), we have to do this bit of uglyness with NSSpeechSynthesizer.
backend.voice = objc.NSSpeechSynthesizer:alloc():init():defaultVoice()
function backend.output(text, interrupt)
text = tostring(text)
interrupt = interrupt or false
if interrupt then
backend.synth:stopSpeakingAtBoundary(0)
end
local utterance = objc.AVSpeechUtterance:alloc():initWithString(text)
utterance.voice = objc.AVSpeechSynthesisVoice:voiceWithIdentifier(backend.voice)
backend.synth:speakUtterance(utterance)
end
function backend.isSpeaking()
return backend.synth:isSpeaking()
end
return backend
+106
View File
@@ -0,0 +1,106 @@
local ffi = require "ffi"
local encoding = require "accessibility.vendor.love2talk.encoding"
ffi.cdef[[
void Tolk_Load();
void Tolk_Output(const char *s, bool interrupt);
void Tolk_Silence();
void Tolk_Speak(const char *s, bool interrupt);
void Tolk_Braille(const char *s, bool interrupt);
void Tolk_TrySAPI(bool try);
void Tolk_PreferSAPI(bool prefer);
const wchar_t * Tolk_DetectScreenReader();
bool Tolk_HasSpeech();
bool Tolk_HasBraille();
bool Tolk_IsSpeaking();]]
-- LOCAL MODIFICATION: load Tolk from the mod's own lib/ directory.
--
-- ffi.load() goes through the Windows loader, which does not know about LOVE's
-- virtual filesystem. Upstream relies on Tolk.dll sitting next to love.exe, which
-- needs admin rights to arrange. Instead we resolve the real OS path via
-- love.filesystem.getSource() and load by absolute path.
--
-- Tolk.dll in turn loads the screen-reader clients (nvdaControllerClient64.dll,
-- SAAPI64.dll) itself, and those go through the normal search order — so we must
-- also add our directory to the DLL search path via SetDllDirectory, or Tolk would
-- load but find no screen reader and silently fall back to SAPI.
local function loadTolk()
local ok, source = pcall(function()
return love.filesystem.getSource()
end)
if ok and source then
local libDir = source .. "\\accessibility\\lib"
pcall(function()
ffi.cdef [[ int SetDllDirectoryA(const char *lpPathName); ]]
ffi.C.SetDllDirectoryA(libDir)
end)
local loaded, handle = pcall(ffi.load, libDir .. "\\Tolk.dll")
if loaded then
return handle
end
end
-- Fall back to the normal search order: next to love.exe, or on PATH.
return ffi.load("Tolk")
end
local tolk = loadTolk()
tolk.Tolk_Load()
local function output(s, interrupt)
interrupt=interrupt or false
tolk.Tolk_Output(encoding.to_utf16(s), interrupt)
end
local function speak(s, interrupt)
interrupt=interrupt or false
tolk.Tolk_Speak(encoding.to_utf16(s), interrupt)
end
local function braille(s)
tolk.Tolk_Braille(encoding.to_utf16(s))
end
local function silence()
tolk.Tolk_Silence()
end
local function trySAPI(try)
tolk.Tolk_TrySAPI(try)
end
local function preferSAPI(prefer)
tolk.Tolk_PreferSAPI(prefer)
end
local function isSpeaking()
return tolk.Tolk_IsSpeaking()
end
local function detectScreenReader()
--todo, need to convert the returned value to something Lua likes
end
local function hasSpeech()
return tolk.Tolk_HasSpeech()
end
local function hasBraille()
return tolk.Tolk_HasBraille()
end
return {
output=output,
speak=speak,
braille=braille,
silence=silence,
isSpeaking=isSpeaking,
trySAPI=trySAPI,
preferSAPI=preferSAPI,
detectScreenReader=detectScreenReader,
hasSpeech=hasSpeech,
hasBraille=hasBraille}