Opening the journal and selecting an entry announced its title and stopped, which is an index into content the player could never reach. Page flipping was silent too. The screen is three widgets at once: the tab row (q/e), the list of entry titles down the left (up/down), and the page on the right holding the actual text (left/right). The descriptor read only the first two. It now reads options[selectedOption].pages[selectedPageId] and speaks its text blocks. A page is bounded by what fits on screen, so it is safe as a single utterance. Layout newlines are flattened first -- splitTextByPages joins the game's own wrapped lines with \n, so left in they make the reader pause mid-sentence at every wrap point. Page position is announced only when there is more than one page, and the blocks are pushed into the console history so [ and ] walk back through a long lore entry after the utterance has gone by. The tab leads only when the tab changed and the title only when the entry changed, so flipping pages does not bury the new page under a repeated heading. That suppression made read() stateful, which is a trap: it is called for the same focus from four places -- the per-frame poll, the post-keypress announce, repeatFocus, and the testbed's before/after probes -- so a poll would consume the "changed" flag and the real announcement would come out headless. The descriptor now caches its last (key, text) and replays it whenever the key is unchanged, making read() idempotent; the suppression state advances exactly once per genuine focus change. Adds a generic descriptor.reset hook, called from menus.announceScreen. The memory lives in file-locals that outlive the screen, so without it reopening the journal on the entry you left it on would read the page with no title. Testbed asserts the spoken text contains the page body rather than merely being non-nil. 29 checks, 0 failures. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Accessibility mod — developer documentation
Making Sulphur Memories: Alchemist 0.4.2 blind-accessible.
Player-facing keymap and setup: accessibility/README.md.
Picking this up mid-stream? Read scratch.md first. It is the living handoff document: current state, every bug found and why, and the traps.
| Doc | Contents |
|---|---|
| scratch.md | Start here. Working state, bugs found, gotchas, keymap, how to run the testbed. |
| 01-architecture.md | How the game is built: states, console, level model, keybindings. |
| 02-hooking-strategy.md | How the mod attaches with (near) zero edits to game files. |
| 03-implementation-plan.md | The original six-phase plan, with outcomes. |
| 04-open-questions.md | Questions raised; see 05 for answers. |
| 05-decisions.md | Decisions made, and the D4 correction. |
Status
Phases 0–5 complete. 23 state descriptors, 5 patches, 26/26 testbed checks, history unit tests passing.
Game-file footprint: one line in main.lua. Verify at any time:
git diff --stat vanilla-0.4.2 -- . ':!accessibility' ':!.gitignore'
The three findings that shaped everything
-
The game was already keyboard-complete. All 28 states implement
keypressed, with a full rebindable-keys system. The gap was that nothing announced what was focused — so the job was narration, not reimplementation. -
The game already writes the text.
console.printis a single chokepoint for all log messages;level.getCursorMessage()describes tiles;inventoryCursorPopup.setcovers four screens. Mostly we re-route existing strings to speech rather than writing a description engine. -
Everything is a
required singleton, anddebug.getupvalueworks. Module functions can be patched in place, and file-local focus variables (selectedOption, …) are readable from outside. Hence the one-line footprint.
Architecture in one breath
main.lua calls require("accessibility").initialize(). That patches module
singletons (console.print, popupStack.add, inventoryCursorPopup.set,
modules.slider), wraps states.switch / update / keypressed / textinput,
and installs a global hotkey layer. Per-screen narration lives in
accessibility/states/<statename>.lua descriptors, read via debug.getupvalue.
A startup self-test reports which patches and probes still resolve — the canary
for a future game version moving something.