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:
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
Reference in New Issue
Block a user