# 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 complete. Phase 1 next.** --- ## 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` ### Done — Phase 0 (bootstrap) · commit `5c581ed` - `git init`, pristine tree committed and tagged **`vanilla-0.4.2`** (commit `bbb70d0`, 1838 files, `accessibility/` deliberately excluded so the tag is genuinely clean). - love2talk vendored to `accessibility/vendor/love2talk/` — see `VENDORED.md` there for the five local modifications. - **DLL elevation problem solved.** `Tolk.dll` etc. live in `accessibility/lib/` and are loaded by *absolute path* via `love.filesystem.getSource()`, with `SetDllDirectoryA` pointing at that folder so Tolk can find its own screen-reader clients. No admin rights, no copying into `Program Files`. Tracked in git via a `!accessibility/lib/*.dll` un-ignore. - `accessibility/speech.lua` — pcall-guarded, frame-queued, `logToConsole = true`. - `accessibility/init.lua` — patch registry + `states.switch` / `states.update` hooks + startup self-test. - `accessibility/stateNames.lua` — spoken names for all 28 states. - **One line added to `main.lua`.** Nothing else in the game touched. **Verified working** via `lovec.exe .`: ``` [accessibility] TTS initialised [accessibility] TTS: available [accessibility] speech channel: yes [speech]! Main menu ``` ### Done — Phase 1: console history + review keys · + wall tones - `accessibility/history.lua` — unbounded (1000-entry ring) history. Mirrors the game's `(xN)` coalescing so speech matches the screen. Review cursor **holds still while new lines arrive** and only follows the tail when not being used. - `accessibility/hotkeys.lua` — global key layer in front of `states.keypressed`. - `accessibility/patches/console.lua` — wraps `console.print` (calls the real one *first*, so a mod bug can't swallow an on-screen message) and `console.clear`. - `accessibility/walltones.lua` — see below. - `accessibility/tests/test_history.lua` — 16 assertions, all passing. Run with: `lua accessibility/tests/test_history.lua` (pure Lua, no LÖVE needed). **Auto-speak policy (Q4, answered):** speak everything, queued and joined in order at end of frame. Any keypress that reaches the game counts as an explicit action and silences the pending backlog (`hotkeys.onGameKey` → `speech.silence`). **⚠ Not yet verified in-game.** Loading, the self-test, and history logic are all confirmed. Whether console speech and wall tones behave correctly *during play* needs a human at the keyboard — Talon to test. ### Keymap so far | Key | Action | |---|---| | `[` / `]` | Previous / next console history line | | `Shift+[` / `Shift+]` | Oldest / newest line | | `\` | Repeat current line | | `Shift+\` | Copy current line to clipboard | Reserved for the tracker (Phase 3): `PageUp`/`PageDown`, `Home`, with Ctrl/Shift. **Free scancodes** (game binds none of these): `[ ] \ ; ' , / - =`, `h k l m n o p u v w`, `F1`–`F12`, `pageup pagedown home end insert`, `kp0 kp. kp+ kp- kp* kp/`. **Game-claimed scancodes — never bind these unmodified:** `. 0-9 \` a b backspace c d delete down e escape f g i j kp1-kp9 kpenter lalt lctrl left lshift mouse3 q r ralt rctrl return right rshift s space t tab up x y z` ⚠ **`keybindings.getCommand()` dispatches on scancode alone and ignores modifiers.** Ctrl and Shift are *held* modifiers in game (`moveCamera`, `repeatAction`/`adjustByFive`). So `Ctrl+Up` is seen by the game as plain "up" — a movement. Never bind a modifier combo over a game-claimed scancode. Combos over *unclaimed* scancodes (e.g. `Ctrl+PageUp`) are safe. Always consume our keys. ### Wall tones (Q5, answered: on step, 3D-positioned, radius 5) Key design point, in `walltones.lua`: **direction and distance are decoupled.** The game's listener sits at the camera, 4 units above the plane, with an *inverse* distance model. Placing a tone at its wall's real position would (a) make far walls quieter — the opposite of what was asked — and (b) collapse the pan toward centre, since the 4-unit vertical offset dominates a 2-unit horizontal one. So: the source is placed at a **fixed radius** from the listener in the wall's direction, at the listener's own height (pure horizontal pan, constant attenuation), and **volume is computed explicitly** from distance. Positioned relative to the listener via `love.audio.getPosition()`, so panning stays correct even while the camera is panned away. Step detection **polls** `playerActor.x/y` each frame instead of hooking movement — free, and covers walking, pathing, portals and pushes by construction. ⚠ `config.louderWhenFurther = true` as requested, but this inverts the usual convention (proximity cues normally get louder as you approach, and a near wall is the more urgent fact). Flip the flag in `walltones.lua` if it reads backwards. Respects `FoV`/`rememberedFoV` — never leaks unseen map geometry. ### Done — Menu narration (Phase 4, pulled forward) + testbed Pulled forward because menus were a **blocker**: Talon could hear the screen name but not the selected option, so the game could not be started at all. - `accessibility/menus.lua` — descriptor driver. Supports both D3 backends (`probe` via `debug.getupvalue`, `patched` via injected announce calls); the per-screen `render` is shared. Includes the probe self-test. - `accessibility/states/{main_menu,settings_menu,load_game,new_game,narration}.lua` - `accessibility/testbed.lua` — drives the game by code. `lovec.exe . --a11y-test` **Verified end to end: main menu → new game → name entry → intro narration → gameplay, 8/8 checks passing.** Console patch smoke-tested including the `(x2)` fold and review keys. #### Three real bugs the testbed caught 1. **`love.keypressed` was bound to the pre-wrapper function.** `states.setup()` does `love[callback] = love[callback] or states[callback]`, capturing **by value**, and it runs before us. `main.lua` defines `love.load/update/ mousepressed/mousereleased/quit` but NOT `keypressed`/`textinput` — so those were frozen to the original. Replacing the `states` table field alone did nothing. **Fix: reassign `love.keypressed`/`love.textinput` after wrapping.** `states.switch`/`states.update` were fine — looked up on the table at call time. ⚠ Any future callback we wrap needs the same treatment. 2. **Focus moves with no keypress.** `main_menu.mousemoved` assigns `selectedOption` straight from cursor position, so wherever the mouse rests silently overrides selection — including at startup. **Fix: poll focus in the update hook** rather than announcing only on keypress. `announceScreen` now speaks only the screen name and lets the poll add focus a frame later (`justSwitched` makes that first one non-interrupting so it appends). 3. **Probes may need chaining.** `narration.keypressed` doesn't touch `displayData`/`currentPage`/`currentLoadedLine` — it only calls `continueNarration()`, so they are upvalues of *that* function. The descriptor reaches `continueNarration` as an upvalue of `keypressed`, then reads through it. Expect this pattern in other states. #### Also learned - `states.lua` loads states via `require("states/"..name)` — a **SLASH**. `require()` caches on the exact string, so `require("states.main_menu")` would load a *second independent copy* with its own upvalues. Always match the slash. - **Plain movement is silent** — `actions/move.lua` only prints when a move fails (bound, immobilized). Walking proves nothing about the console patch; drive `console.print` directly to test it. ### Known gaps (next up) - **`ingame_dialogue` announces "Game", not "Conversation", and reads no dialogue text.** It appears to be entered via a path that announces `ingame` first. Needs a descriptor + investigation of how it's switched to. **This is the next blocker for actually playing.** - `ingame` itself has no descriptor — that's Phase 2 (tile examination). - Wall tones still unverified by ear. ### Next — Phase 2: tile examination Patch `views/levelCursorPopup.set` to narrate the game's existing examine mode, augmented with distance/direction/passability. Then the surroundings explorer (`Alt`+numpad to probe a direction without moving). Needs Q3 (verbosity) answered. --- ## 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 | |---|---|---| | `main.lua` | +1 line: `require("accessibility").initialize()` after `console.prepare()` | Sole mod entry point. Placed there because templates, keybindings, `states.setup()` and the console canvas are all ready by then, and it's before the first `states.switch()`. | Verify this list is exhaustive at any time with: ``` git diff --stat vanilla-0.4.2 -- . ':!accessibility' ':!.gitignore' ``` Currently: `main.lua | 1 +`. ### How to run ``` cd "D:/code/Sulphur Memories Alchemist 0.4.2 sanctified" "C:/Program Files/LOVE/lovec.exe" . ``` `lovec` (not `love`) gives the console, where `[speech]` lines mirror every utterance — speech is verifiable from the log, not only by ear. `!` after `[speech]` means the utterance interrupted. --- ## 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`.