Pressing a hotbar key ran hotkey.action(state), which for an equipment binding calls an itemActions entry's onActivation directly -- Wield, Drink, Switch on, Pack away. None of those reach console.print or a popup; most only play a sound. So the whole hotbar was silent: no item name, no verb, no confirmation the key had registered at all. patches/hotbar.lua wraps the hotbar VIEW factory, not models/hotkey: models/hotbar.lua captures newHotkey as a file-local at require time, and models/world.lua captures newHotbar the same way, both long before our entry point. views/hotbar.lua is required only by the three states, which load lazily -- the same ordering argument as patches/slider.lua. The verb is computed BEFORE the action runs. The itemAction conditions key off item.isEquipped, which the action itself flips, so asked afterwards they return the opposite verb and a press that wielded would announce "Unwield". Also narrates the empty slot, the silent no-applicable-action branch, binding and unbinding (sound-only in game), and action-type slots. Says nothing when the item is missing from the inventory -- the game prints that itself and patches/console.lua speaks it. 78 testbed checks, 0 failures. The prologue gives the player no items, so the live path is exercised by planting a torch, binding it to slot 1, pressing 1 twice and putting the world back. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
938 lines
52 KiB
Markdown
938 lines
52 KiB
Markdown
# 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-03 · Currently: **Phases 0–5 COMPLETE. In playtesting** —
|
||
fixing what Talon hits. Remaining work is polish, mod settings and distribution.
|
||
|
||
**Game is playable start-to-finish by ear** from main menu through the intro
|
||
conversation into gameplay, plus journal, character, actions and inventory —
|
||
verified by the testbed, **78/78 checks**.
|
||
|
||
**Not set up yet, or unsure how the two trees relate?** →
|
||
[00-dev-setup.md](00-dev-setup.md).
|
||
|
||
### Run the testbed (do this after any change)
|
||
```
|
||
cd "D:/code/Sulphur Memories Alchemist 0.4.2 sanctified"
|
||
bash accessibility/tests/run-testbed.sh
|
||
```
|
||
⚠ **Use the script, not `lovec . --a11y-test`.** The testbed needs an empty save
|
||
directory, and this used to be a bare `rm -rf ~/AppData/Roaming/LOVE/Alchemist`
|
||
in these docs — which ate Talon's actual playthrough. The script *moves* the save
|
||
aside, runs, and moves it back (restore is on an EXIT trap, and it never deletes
|
||
the real one). It can't be done inside the mod: `love.load` reads config,
|
||
keybindings and user data long before `main.lua` reaches our entry point.
|
||
It drives the game by code via `love.event.push`, navigating by *reading* focus
|
||
rather than counting keypresses. Every bug listed below was caught by it.
|
||
|
||
⚠ **The check COUNT varies between runs; this is expected.** Several
|
||
blocks are conditional — the config-menu section only runs if `selectByText`
|
||
lands on "Options", and mouse-hover can move menu focus at startup (see trap 2).
|
||
**What matters is `0 failures`, not the total.**
|
||
|
||
---
|
||
|
||
## 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`)
|
||
|
||
**Answered:** Q1 (dev LÖVE), Q2 (tracker hierarchy), Q3 (augment, not relay),
|
||
Q4 (speak everything queued, cancelled by explicit action), Q5 (wall tones on
|
||
step, stop-and-replay — confirmed working by ear), Q7/Q10 (patches allowed,
|
||
probe backend kept open for Steam), Q9 (NVDA, lovec approved).
|
||
|
||
**Still open:** Q6 (integrate our keys into the game's rebinding UI, or keep a
|
||
separate scheme) · Q8 (full keyboard-reachability audit as an explicit
|
||
deliverable?). Neither blocks Phase 4.
|
||
|
||
---
|
||
|
||
## 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`.
|
||
- **`inventoryCursorPopup.set(...)`** — one patch narrates focus in `inventory`, `craft`, `refine` and `trade`. ⚠ `levelCursorPopup` is deliberately **not** patched: it fires from an animation-completion callback and lands a keypress late. `ingame_observe` polls `level.cursorX/Y` instead.
|
||
- **All 28 states already implement `keypressed`.** The gap is narration, not reachability.
|
||
- **`modules/tile.lua:234`**: `tile.tryInteract = (self.loot or self.tryInteractFunction)`. ⚠ This is **not** a "worth tracking" signal — `wall` has a flavour-only `tryInteractFunction`. Structural tiles (`blocksMovement and blocksVision`) must be excluded first. See the D4 correction in `05-decisions.md`.
|
||
- **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`).
|
||
|
||
**Verified in-game** by the testbed (console capture + `(x2)` fold + review keys)
|
||
and by Talon at the keyboard (wall tones).
|
||
|
||
### Keymap so far
|
||
|
||
| Key | Action |
|
||
|---|---|
|
||
| `[` / `]` | Previous / next console history line |
|
||
| `Shift+[` / `Shift+]` | Oldest / newest line |
|
||
| `\` | Repeat current line |
|
||
| `Shift+\` | Copy current line to clipboard |
|
||
| `/` | Scan all 8 neighbours, clockwise from north (terse) |
|
||
| `Shift+/` | View watch: cycle off / announce when you stop / announce every step |
|
||
| `;` | Where am I — level, in-game time, coordinates |
|
||
| `Ctrl`+numpad 1–9 | Probe that direction without moving (`Ctrl+kp5` = standing on) |
|
||
| `Ctrl+PageUp` / `Ctrl+PageDown` | Tracker: previous / next **category** |
|
||
| `PageUp` / `PageDown` | Tracker: previous / next **kind** |
|
||
| `Shift+PageUp` / `Shift+PageDown` | Tracker: previous / next **instance** |
|
||
| `Home` | Tracker: distance, direction and path length to current instance |
|
||
| `Ctrl+Home` | Tracker: walk to current instance (the left-click path) |
|
||
| `'` | Health and active effects |
|
||
| `,` | Current selected action |
|
||
| `=` / `Shift+=` | Read details of the focused thing (Shift = keep the name line) |
|
||
|
||
Game keys worth knowing: **`x`** enters examine mode (not `e`), `f` finds exits
|
||
while examining, `j` journal, `i` inventory, `c` character, `a` actions menu.
|
||
|
||
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.
|
||
|
||
Volume follows the usual proximity convention: a near wall is loud
|
||
(`nearVolume`), one out at `radius` is quiet (`farVolume`). Set
|
||
`config.louderWhenFurther = true` in `walltones.lua` to invert it.
|
||
|
||
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.
|
||
|
||
### Done — dialogue narration + wall tone retrigger fix
|
||
|
||
**`ingame_dialogue` descriptor.** Speaks speaker + message on a *node* change, but
|
||
only the response on a *selection* change (re-reading the whole speech every time
|
||
you move between responses would be unusable). Responses can also be picked
|
||
directly with number keys 1–0, which the game already supports.
|
||
|
||
Two more bugs found and fixed:
|
||
|
||
4. **Nested `states.switch` announced in reverse.** `ingame.open()` sees
|
||
`world.pendingDialogue` and switches straight to `ingame_dialogue`. Since we
|
||
announce *after* `realSwitch` returns, the inner announcement ("Conversation")
|
||
fired first and the outer ("Game") then interrupted and clobbered it.
|
||
**Fix: after switching, if `states.getCurrent() ~= newState`, stay quiet** —
|
||
a nested switch already announced.
|
||
|
||
5. **Double numbering.** The game already prefixes response text with its number
|
||
(`"1. (Continue)"`, matching the number key that selects it), so adding ours
|
||
gave `"1. 1. (Continue)"`. Now only added when absent.
|
||
|
||
**Wall tones — retrigger fix (per Talon: "stop previous tones and play new ones").**
|
||
Tones are **2.33 s, mono, 44.1 kHz**. Steps land far faster than that and up to
|
||
four directions sound at once, so they stacked into a continuous drone. Now each
|
||
direction stops its previous source before starting a new one, so it reads as a
|
||
per-step cue layered over footsteps, and the volume you hear always reflects
|
||
where you're standing now. `playing` is cleared on level change.
|
||
|
||
*(Mono matters: OpenAL only spatialises mono sources. Stereo would not pan.)*
|
||
|
||
### Done — Phase 2: tile examination + surroundings
|
||
|
||
- `accessibility/describe.lua` — shared spatial formatting. 8-point compass,
|
||
**Chebyshev** distance (the game moves 8-directionally, so Euclidean would
|
||
overstate diagonals), and `tileFacts()` / `tile()`. Verbosity setting
|
||
(`terse`/`normal`/`verbose`) since sweeping 8 neighbours and inspecting one
|
||
tile want different amounts of detail.
|
||
- `accessibility/states/ingame_observe.lua` — narrates the game's examine cursor.
|
||
- `accessibility/surroundings.lua` — probe/scan/where-am-I without moving.
|
||
- `accessibility/patches/popups.lua` — wraps `inventoryCursorPopup.set`, covering
|
||
**inventory, craft, refine, trade** in one patch.
|
||
|
||
**Q3 answered by default: augment, not just relay.** The game's descriptions
|
||
("A wooden chair.") carry no position — fine on screen, useless by ear. We append
|
||
distance + direction, and blocked/has-items/water at normal verbosity.
|
||
|
||
#### Design note: why examine mode is polled, not popup-driven
|
||
`levelCursorPopup.set` is called from an **animation-completion callback**, so
|
||
unless the player enables the game's `quickExaminationPopup` option the text
|
||
lands a full keypress late — in testing you heard the *previous* tile while
|
||
standing on the next. The `ingame_observe` descriptor polls `level.cursorX/Y`
|
||
instead: same-frame, setting-independent, no game edit. `levelCursorPopup` is
|
||
therefore deliberately **not** patched; only `inventoryCursorPopup` is.
|
||
|
||
*(The level model is a closure instance, not a module, so it can't be patched at
|
||
require time — reading its public fields is the decoupled route.)*
|
||
|
||
#### Testability seam
|
||
`hotkeys.isCtrlDown` / `isShiftDown` are overridable fields, not direct calls to
|
||
`love.keyboard.isScancodeDown`. Real hardware state can't be faked by
|
||
`love.event.push`, so without this seam every modifier-gated hotkey would be
|
||
untestable. The testbed swaps them per keypress.
|
||
|
||
### Done — Phase 3: object tracker
|
||
|
||
- `accessibility/categories.lua` — flag-first classification into the six
|
||
categories, plus the 6-template exclusion list and reuse of the game's `exit`
|
||
tag.
|
||
- `accessibility/tracker.lua` — scan, cache, three-level navigation, announcements.
|
||
|
||
Verified in the starting house:
|
||
```
|
||
Interactables, 12 kinds
|
||
a bed, 1, nearest 1 tile south-west, 2 of 12
|
||
an open door 1 of 1, 2 tiles north-west
|
||
an open door, 2 tiles north-west, 2 steps by path <- Home
|
||
```
|
||
|
||
**Caching:** signature is `level name : player x : player y : turn`. Position
|
||
matters because everything sorts by distance; turn matters because creatures move
|
||
and containers empty. Scans run on keypress only, never per frame.
|
||
|
||
**FoV respected** — creatures only when currently visible, tiles when visible or
|
||
remembered. The tracker never reveals what a sighted player couldn't see.
|
||
|
||
**`Home` reports path length, not just offset.** Straight-line distance is badly
|
||
misleading indoors: a chest 3 tiles north through a wall can be 20 steps away.
|
||
Uses the game's own `calculatePathFromPlayer`.
|
||
|
||
**`Ctrl+Home` walks there.** The game does support click-to-path: `ingame.mousepressed`
|
||
(`states/ingame.lua:661`) builds `calculatePathFromPlayer(cursorX, cursorY)` and
|
||
feeds it to `level.bufferInput("move", path, ingame)`, or to `playerInput` directly
|
||
when the path is one step. `tracker.walkToSelection` reuses that exact shape with
|
||
the tracker's selected tile in place of the cursor, so interruptions, threatened-
|
||
player truncation and animation skipping all behave the way a mouse click does.
|
||
|
||
Two wrinkles, both handled:
|
||
|
||
* `calculatePathFromPlayer` returns nil for an impassable target, and most things
|
||
worth tracking (creatures, chests, beds) are impassable. We fall back to the
|
||
cheapest of the 8 neighbouring tiles and say "walking next to X".
|
||
* Guard on `states.getCurrent() == "ingame"`. In a menu or observe mode the input
|
||
buffer belongs to a different state, and handing it moves would be a bug.
|
||
|
||
Note `require("states/ingame")` with a slash — that is the module key `states.lua`
|
||
uses, and requiring `states.ingame` would load a *second* copy of the state.
|
||
|
||
**Selection is sticky across rescans.** The scan is rebuilt every turn and both
|
||
lists are sorted by distance, so holding a bare index means one step by the player
|
||
silently re-points the selection at a different rat. The tracker instead remembers
|
||
identity — the actor table for creatures, coordinates for tiles — and re-derives
|
||
the indices in `reanchor()` after each rescan. Selection is dropped only when the
|
||
thing stops being reported at all (out of FoV, killed, picked up); the existing
|
||
clamp then falls back to the nearest.
|
||
|
||
#### ⚠ Correction to D4 — `tryInteract` is NOT a clean "worth tracking" signal
|
||
`wall` carries a **flavour-only** `tryInteractFunction` ("You touch the wall.
|
||
It's cold and rough."), so flag-first classification promoted every wall segment
|
||
into Interactables — 13 in the starting house, burying the chest and cauldron.
|
||
**Fix: exclude structural tiles (`blocksMovement and blocksVision`) before the
|
||
`tryInteract` test.** Containers and furniture block movement but not sight, so
|
||
they survive. 13 kinds/32 instances → 12 kinds/19 instances.
|
||
`docs/05-decisions.md` D4 has been amended.
|
||
|
||
*(Not a bug: Items/Alchemy are empty in the tutorial house because
|
||
`chest_tutorial` literally prints "find nothing of use for now" — it has no loot
|
||
yet. Verify against a later area before trusting those two categories.)*
|
||
|
||
### Done — Phase 4 (most of it): menu descriptors
|
||
|
||
**16 states now have descriptors** — all registered in `describedStates` in
|
||
`init.lua`, all resolving in the startup self-test:
|
||
|
||
`main_menu` · `settings_menu` · `load_game` · `new_game` · `narration` ·
|
||
`ingame_dialogue` · `ingame_observe` · `actions_menu` · `worldmap` ·
|
||
`alchemy_table` · `journal` · `character` · `inventory` ·
|
||
`menu_text` / `ingame_message` / `menu_message` (shared `_textscreen.lua`)
|
||
|
||
Verified live: `Journal. Quests, A simple commission, 1 of 1` ·
|
||
`Character, 1 of 3` · `Actions. Interact, 1 of 1` ·
|
||
`Inventory. All items, empty, slot 1`. **21/21 testbed checks.**
|
||
|
||
#### Two driver improvements (both prompted by real failures)
|
||
|
||
- **`menus.getAnyUpvalue`** — finds a file-local across *all* of a state's
|
||
functions plus one level into inner closures. Safe because a Lua file-local is
|
||
a single shared upvalue, so any closure referencing it sees the same variable.
|
||
Needed because `actions_menu` and `worldmap` build their lists in `open()` and
|
||
never mention them in `keypressed`. The generic probe path now falls back to it.
|
||
- **`menus.hasUpvalue`** — checks a local *exists* rather than *holds a value*.
|
||
`getUpvalue` can't tell "no such local" from "declared but nil", which made the
|
||
self-test cry PROBE BROKEN on `worldmap` (whose `local options` is nil until
|
||
the screen opens). Self-test now reports `ok (unset until opened)`.
|
||
|
||
#### Ownership rule: descriptors beat the popup patch
|
||
`patches/popups.lua` now **stands down for any state that has a descriptor.**
|
||
`inventoryCursorPopup.set` only fires when there *is* an item, so on an empty
|
||
slot or empty tab it said nothing — you couldn't tell whether the cursor moved.
|
||
The `inventory` descriptor reports item-or-empty plus slot and tab; letting both
|
||
speak would double up on every item.
|
||
|
||
### Done — Phase 4 remainder: crafting screens, slider, config
|
||
|
||
**21 states have descriptors; 4 patches applied.** 25/25 testbed checks.
|
||
|
||
**The slider is genuinely generic** — `modules/slider.lua` is a *factory*
|
||
(`newSlider(params)`), and one wrapper on it narrates **all four** users:
|
||
`craft`, `refine`, `trade` and `ingame_wait` (the wait-N-turns screen). Same
|
||
leverage as the `console.print` and `inventoryCursorPopup` patches.
|
||
|
||
⚠ **But the options screens do NOT use it.** `config_menu` changes values through
|
||
`config.increase` / `config.decrease`, so it needed its own descriptor. It
|
||
reports tab + option name + value:
|
||
`Allow hitting yourself, No, 3 of 11` → right → `…, Yes, …`. The focus key
|
||
includes the value, so left/right re-announce even though focus hasn't moved.
|
||
|
||
`accessibility/states/_itemgrid.lua` is now shared by `inventory`, `craft`,
|
||
`refine` and `trade` — all four build a `views/inventoryWindow`. It stays silent
|
||
while `confirmationWindowEnabled` is set, so the slider owns speech during
|
||
quantity selection rather than the two fighting.
|
||
|
||
**Ordering note:** `patches/slider.lua` replaces `package.loaded["modules.slider"]`,
|
||
so it only affects requires that happen *after* it. Safe because states load
|
||
lazily and `applyPatches()` runs before the self-test (the one thing that loads
|
||
state files early).
|
||
|
||
⚠ **Slider not exercised live.** `ingame_wait` is unreachable in the prologue —
|
||
`tutorial_bed = { parent = "bed", tryInteractFunction = function() end }`, i.e.
|
||
the tutorial bed does nothing *by design*. `craft`/`refine`/`trade` need an
|
||
alchemy table and real items. The patch is confirmed *applied* by the self-test;
|
||
the testbed has the walk-to-bed machinery ready (`walkTo`, tracker lookup) for
|
||
when a real bed is reachable.
|
||
|
||
### Done — Phase 4 complete + Phase 5: status, popups, damage alerts
|
||
|
||
**All 23 states now have descriptors. 5 patches. 26/26 testbed checks.**
|
||
Final two descriptors:
|
||
|
||
- **`keybindings_menu`** — two panels (commands / keys) with independent
|
||
selection; `currentlyActiveWindow` says which has focus. Announcing the wrong
|
||
panel's row would be actively misleading, as the lists are unrelated.
|
||
- **`research`** — a 2D node graph, not a list. Nodes are icons with **no text
|
||
label**, so the id is the only identifier; the useful facts are
|
||
researched / locked / cannot afford / available. Without the affordability
|
||
part the screen is unusable by ear, since enter on an unaffordable node
|
||
silently does nothing.
|
||
|
||
**Phase 5 — `accessibility/status.lua`:**
|
||
|
||
| Key | Action |
|
||
|---|---|
|
||
| `'` | Health + active effects — `Health 20 of 20. no active effects` |
|
||
| `,` | Current action — `Action: Interact` |
|
||
|
||
Plus **unprompted damage alerts**, polled from `actor.hp`:
|
||
`Hit, minus 3, health 17 of 20` (immediate + interrupting), and
|
||
`Healed 3, health 20 of 20` (queued — worth knowing, never urgent).
|
||
|
||
⚠ Damage is **polled, not hooked**. `ingame.checkInterruptions(hpLoss)` exists
|
||
but only fires for interruptions during *queued movement* — damage taken on your
|
||
own turn never reaches it. Watching `hp` catches every source (combat, poison,
|
||
fire, falling) with no game edit.
|
||
|
||
**`patches/popupstack.lua`** wraps `popupStack.add`, the single entry point for
|
||
loot / recipe / lore / map / quest / tutorial / bestiary popups. None of these go
|
||
through `console.print`, so they were completely silent before.
|
||
|
||
**`speech.immediate` now logs too** (`[speech]*`). It bypasses the frame queue,
|
||
so it also bypassed the flush-time logger — meaning damage alerts fired but were
|
||
invisible in the testbed transcript. Log prefixes: `!` interrupt, `*` immediate,
|
||
` ` queued.
|
||
|
||
### Done — journal page text (bug fix, reported by Talon)
|
||
|
||
**Symptom:** open the journal with `j`, select an entry, hear nothing useful.
|
||
|
||
**Cause:** the journal is three widgets at once — the tab row, the list of entry
|
||
titles on the left, and the **page** on the right that holds the actual text.
|
||
The descriptor read only the first two. So it announced
|
||
`Quests, A simple commission, 1 of 1` and stopped, which is an index into
|
||
content you could never reach. Page flipping (`left`/`right`) was silent too.
|
||
|
||
**Fix** — `accessibility/states/journal.lua`:
|
||
- Reads `options[selectedOption].pages[selectedPageId]`, a list of text blocks,
|
||
and speaks their `text`. Page size is bounded by what fits on screen, so a
|
||
page is safe to speak as one utterance.
|
||
- **Flattens the newlines** in each block. `journalModel.splitTextByPages`
|
||
concatenates the game's own wrapped lines with `\n`, so those are pure layout —
|
||
left in, the reader pauses mid-sentence at every wrap point.
|
||
- Says `page 2 of 3` only when there is more than one page.
|
||
- Pushes the blocks into the console history, so `[` / `]` / `\` re-read the
|
||
page after the utterance has gone by — the same trick `_textscreen.lua` uses
|
||
for the manual.
|
||
- Title leads only when the *entry* changed, tab only when the *tab* changed, so
|
||
flipping pages doesn't bury the page you just turned to under a repeated title.
|
||
|
||
⚠ **`read()` must be idempotent.** It is called for the same focus from four
|
||
places: the per-frame poll, the post-keypress announce, `menus.repeatFocus`, and
|
||
the testbed's before/after probes. Any "only say this when it changed" state
|
||
inside a descriptor is therefore a trap — the poll consumes the flag and the
|
||
real announcement comes out headless. The journal descriptor caches its last
|
||
`(key, text)` and replays it whenever the key is unchanged, so the suppression
|
||
state advances exactly once per genuine focus change.
|
||
|
||
**New generic hook: `descriptor.reset`**, called by `menus.announceScreen`.
|
||
Change-suppression 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 in front of it.
|
||
|
||
Verified live: `Journal. Quests, A simple commission, 1 of 1, Someone's banging
|
||
at my door. Whoever it is, this person is very persistent and doesn't know about
|
||
doorbells.` **34/34 testbed checks**, including a new one that asserts the spoken
|
||
text actually contains the page body rather than merely being non-nil.
|
||
|
||
### Done — view watch: what comes in and out of view while walking (Talon request)
|
||
|
||
**`accessibility/viewwatch.lua`**, toggled with **`Shift+/`** — a natural pair
|
||
with `/` (scan around me now): `/` is "what is here", `Shift+/` sets how much of
|
||
that happens by itself.
|
||
|
||
Three modes, cycled by the key:
|
||
|
||
| Mode | Behaviour |
|
||
|---|---|
|
||
| `settled` (**default**) | Changes accumulate while moving; spoken once the player has stood still for `settleDelay` (0.4 s). |
|
||
| `always` | Every step reports its own change. |
|
||
| `off` | — |
|
||
|
||
**Why `settled` is the default and why it diffs against the last announcement,
|
||
not the last step.** Talon's question was "announce as they happen, or combine".
|
||
Combining is right for a long walk, but only if the baseline is the state at the
|
||
last *announcement* — then the summary is a genuine **net** change: something
|
||
that came into view mid-walk and left again before you stopped is correctly not
|
||
mentioned, and something that stayed in view throughout is mentioned once. A
|
||
per-step diff summed up would say both, which is worse than either mode. A
|
||
single step is the degenerate case: you are already standing still, so it
|
||
reports 0.4 s after the step lands.
|
||
|
||
**Arrivals and departures are deliberately asymmetric.** Membership is **current
|
||
`FoV` only** (never `rememberedFoV` — the question is what you can see from
|
||
where you stand). But furniture drops out of FoV constantly indoors as walls
|
||
come between you and it, and "the bed is out of view" every other step is pure
|
||
noise. So arrivals are reported for every watched category; departures only for
|
||
`config.announceGone`, which is **creatures only** by default.
|
||
|
||
Other decisions:
|
||
|
||
- Terrain is **not** watched by default (`config.watched.terrain = false`) —
|
||
water arrives dozens of tiles at a time and would drown everything else.
|
||
Classification is otherwise the tracker's (`categories.classifyTile`), so the
|
||
wall-exclusion correction to D4 applies here too.
|
||
- Scan is a **box of `radius` 14 tiles** around the player, not the whole grid.
|
||
Sight range is well inside that, and this runs on every step.
|
||
- Step detection **polls `playerActor.x/y`**, same as `walltones` — covers
|
||
walking, pathing, portals and pushes with no game function wrapped. The scan
|
||
runs only when the position changed or a settle is due, never per frame
|
||
(invariant 5).
|
||
- Entering a new level **re-baselines in silence** rather than reading the whole
|
||
first room; so does any mode change.
|
||
- Speech is **queued, not interrupting** — this is background awareness, so
|
||
anything the player asked for outranks it.
|
||
- Announces only in `ingame` / `ingame_observe`.
|
||
|
||
**Testability seam:** `viewwatch.snapshot()` and `viewwatch.diffText(prev, cur,
|
||
px, py)` are pure and exposed, so the testbed asserts the wording and the FoV
|
||
invariant directly instead of trying to capture speech. **34/34 testbed checks**
|
||
(5 new). Verified live during the run — the walk to the bed produced
|
||
`In view: a chest, 1 tile south-west. a torch on the wall, 3, nearest 2 tiles
|
||
south-west. …`.
|
||
|
||
### Done — inventory item action menu (bug fix, reported by Talon)
|
||
|
||
**Symptom:** enter/`z` on an inventory item makes a click and then nothing is
|
||
spoken. Equipping the tutorial torch was therefore impossible to confirm.
|
||
|
||
**Cause:** the click *is* the menu opening. `inventory.keypressed` →
|
||
`inventoryWindow.enableItemMenu()` → `modules/item/itemMenu.lua`, a **second,
|
||
nested selection** drawn as a small popup over the grid: the list of actions this
|
||
item currently allows, built by `modules/item/getActionsList.lua` from the
|
||
per-action `condition`s in `itemActions.lua` (Activate, Wield, Wear, Switch on,
|
||
Drink, Read, Open, Prepare for throwing…). Up/down move in it, enter activates,
|
||
**any other key cancels it**. None of this goes through `console.print` or the
|
||
cursor popup, so it was entirely silent. `TEsound.play(sound.select)` in
|
||
`enableItemMenu` was the click. **This is the game's only equipment UI** — there
|
||
is no separate equip screen — so the whole screen was unusable.
|
||
|
||
**Fix** — `accessibility/states/_itemgrid.lua`:
|
||
- `window.itemMenu` is checked **first** and the grid cell stays quiet under it;
|
||
the menu is modal over the grid, so announcing the cell would be wrong.
|
||
- The menu is a closure with no getters, so `lines` and `currentSelectedAction`
|
||
are read as upvalues of its own `draw()`.
|
||
- Leads with the item name only when the menu just opened. The "just opened"
|
||
memory is **the menu table itself**, not a flag, which keeps `read()`
|
||
idempotent (see the journal warning above — a flag would be consumed by the
|
||
poll and the real announcement would come out headless).
|
||
- **`isEquipped` is now part of the cell text and of the focus key.** After
|
||
"Switch on" the menu closes with the cursor unmoved, so without it the re-read
|
||
would be suppressed as unchanged and the action would still feel silent. Now:
|
||
`Switch on` → `a torch, equipped, slot 3`.
|
||
|
||
**Plus `accessibility/patches/itemmenu.lua`** (6th patch), wrapping the
|
||
`views/inventoryWindow` factory: when `getActionsList` returns nil — an item with
|
||
no applicable actions, e.g. a plain reagent — the game does *literally nothing*,
|
||
not even the click, which by ear is indistinguishable from a dropped keypress.
|
||
Says `<item>, no actions`. Success is not announced here; the descriptor owns it.
|
||
|
||
⚠ **Still not exercised against a real item.** The prologue inventory is empty,
|
||
so the testbed instead builds an `itemMenu` directly from a fake usable item and
|
||
asserts the two upvalue names still resolve — the version-bump canary for this
|
||
descriptor. **38/38 checks.** The live path (an actual torch, actually equipped)
|
||
is Talon's to confirm.
|
||
|
||
### Done — item details: `=` and the settle timer (Talon request)
|
||
|
||
**Symptom:** on the item grids you could hear *which* item you were on
|
||
(`a torch, slot 3`) but nothing about what it was — no description, no condition,
|
||
and crucially no "what does this refine into", which is the whole of alchemy.
|
||
Talon proposed a key. Two findings changed the shape of the answer.
|
||
|
||
**1. The game already composes the whole detail text in one place.**
|
||
`item.getPopupMessage()` (`modules/item.lua:199`) returns the popup's line list:
|
||
name+quantity, category, condition (only when `canDegrade`), the free-text
|
||
description, a `Yields:` block with one line per refinement type, and
|
||
lore-fragment progress. It is a plain method on the item, callable any time — so
|
||
`accessibility/itemdetails.lua` reads it rather than re-deriving anything from
|
||
templates. Verified text:
|
||
`It is a raw material. Some fresh green leaves from ivy. They contain weak
|
||
poison. Yields: x1 when ground in a mortar. x0.2 when distilled.`
|
||
|
||
**2. The game gates this behind a dwell, and so do we.** The popup only fades in
|
||
after `config.get("examinationPopupFadeInDuration")` (default 0.4 s). Arrow past
|
||
a slot quickly and a *sighted* player never sees the details either. So details
|
||
**settle**: stop for `details.config.settleDelay` (0.6 s — longer than 0.4
|
||
because speech is slower than a glance) and they speak themselves, queued not
|
||
interrupting. `=` reads them on demand; `Shift+=` keeps the name line too.
|
||
This mirrors an existing game behaviour instead of inventing a modality.
|
||
|
||
⚠ **`r` is unusable** even though it was the obvious mnemonic — it is in the
|
||
game-claimed scancode list, and `keybindings.getCommand()` dispatches on
|
||
scancode ignoring modifiers, so any binding on it can fire a game command. `=`
|
||
is from the verified free list and sits by the `[ ] \` review cluster.
|
||
|
||
**New generic hook: `descriptor.details(stateTable, menus, verbose)`**, returning
|
||
a **list of blocks**, not one string. Blocks are spoken joined *and* pushed
|
||
individually into the console history, so `[` / `]` / `\` walk a long
|
||
description back afterwards — the same trick `states/journal.lua` uses for pages.
|
||
Driven by `accessibility/details.lua` (`get` / `speak` / `update` / the pure
|
||
`settleStep`). Implemented so far only by `_itemgrid.lua`; `research`,
|
||
`ingame_observe` and `journal` are the obvious cheap follow-ups.
|
||
|
||
**The hint panels are the other half, and one reader covers all three.**
|
||
craft / refine / trade each draw a permanent panel beside the grid that the
|
||
cursor popup never contains — and **price exists only there**
|
||
(`states/trade.lua:156`), as do a recipe's component costs. The three panels turn
|
||
out to share a shape (`hintDescription`, a `componentHints` list of
|
||
`{nameText, descriptionText, color}`, and a budget line), so `hintBlocks()` in
|
||
`_itemgrid.lua` reads all three by upvalue rather than needing per-state
|
||
callbacks; unused fields are simply nil. **Affordability is carried only by the
|
||
red `{0.9,0.2,0.2}` colour**, and it decides whether enter does anything at all,
|
||
so it is decoded from the colour and spoken as `cannot afford`.
|
||
|
||
Notes:
|
||
- The action menu is deliberately **not** excluded from `details` the way it is
|
||
from `read()`. The menu is modal so the *verb* is what focus announces, but the
|
||
thing you want described is still the item — which is exactly when you want to
|
||
know what "Drink" would be drinking.
|
||
- The settle key is state-qualified and fires **once per focus key**; leaving a
|
||
screen and returning resets, or the same slot would be "already spoken".
|
||
- `patches/popups.lua` still stands down on all four grids. That is now
|
||
correct-by-design rather than a gap: `details` is the sanctioned route to that
|
||
same content, on demand and with history review.
|
||
|
||
**59 testbed checks, 0 failures** (was 38). New: a synthetic `ivy_leaves` item
|
||
asserting description + `Yields` survive (the version-bump canary for
|
||
`getPopupMessage`), a `hasUpvalue` canary over all the hint-panel locals in
|
||
craft/refine/trade, four assertions on the pure `settleStep`, and an end-to-end
|
||
`=` press in the inventory (`No details` on an empty slot — silence would be
|
||
indistinguishable from a dropped key).
|
||
|
||
⚠ **Not exercised against a real item.** Same bucket as the action menu: the
|
||
prologue inventory is empty and craft/refine/trade need an alchemy table. The
|
||
live path is Talon's to confirm.
|
||
|
||
### Done — the loot overlay key `s` (Talon request)
|
||
|
||
**Symptom:** the tutorial says *"Press 's' to see the contents of containers"*
|
||
and nothing was spoken. That is the one mechanic the game explicitly teaches for
|
||
finding reagents, and alchemy starts with finding reagents.
|
||
|
||
**What `s` actually does.** It is the `lootOverlay` command, and it sets a single
|
||
flag — `levelWindow.itemsHighlightEnabled`. `views/levelWindow.lua:818` then
|
||
draws, over every tile in **current FoV**, the icon of
|
||
`levelModel.getItemOnCoordinates(x,y)` — i.e. the **topmost item of the topmost
|
||
loot-bearing tile**. So the sighted affordance is "there is something in that
|
||
chest", one icon per tile, not a contents list.
|
||
|
||
**`accessibility/lootoverlay.lua`** speaks the same set of tiles, nearest first,
|
||
and reads out the **whole `tile.loot` list** rather than just the top entry —
|
||
the icon can only show one, we are not so limited. Blocks are pushed into the
|
||
console history so `[` / `]` / `\` walk a long list back. `No loot in view` when
|
||
empty: silence would be indistinguishable from a dropped key.
|
||
|
||
Live text: `Tiled man-made floor, here, A bunch of ivy leaves, 3`.
|
||
|
||
Decisions worth not relitigating:
|
||
|
||
- **No key of ours.** `s` is game-claimed and `keybindings.getCommand()` ignores
|
||
modifiers, so binding it in our layer would collide. We watch the *flag*
|
||
instead, on its **rising edge**. That covers the key in both hold and toggle
|
||
mode (`holdForItemHighlight`, which defaults to **hold**), the toolbar icon,
|
||
the player's own rebinding, and all four ingame states — they share one
|
||
`levelWindow` instance, created once in `ingame.open` and passed on by params.
|
||
- **The flag is checked in TWO places.** The end-of-frame poll is not enough on
|
||
its own: in hold mode a quick tap pushes keypressed and keyreleased inside one
|
||
frame, so the poll only ever sees it back down. `init.lua`'s `states.keypressed`
|
||
wrapper therefore also calls `lootoverlay.update()` *between* the game's
|
||
keypressed and its keyreleased. The poll still earns its place — it is what
|
||
catches the mouse and the key being released.
|
||
- **`levelWindow` is reached by `menus.getAnyUpvalue` on `states/ingame`** and
|
||
cached (it is created once, not per level). Covered by the startup self-test
|
||
(`[accessibility] loot overlay: ok`) as the version-bump canary.
|
||
- **Loot entries are item *params*, not items** — `{templateId, quantity}`;
|
||
`modules/lootPopup.lua` only builds real items when the popup opens. Names come
|
||
from `modules/item/templates`, so nothing is allocated to say a name.
|
||
- **Membership matches levelWindow's test exactly**: current `FoV` only, not
|
||
hidden, `visibilityAlpha ~= 0`, non-empty loot. `conditionalLoot` is *not*
|
||
read — the overlay does not draw it either. Invariant 4 holds: nothing is said
|
||
that is not being drawn on screen at that moment.
|
||
- `unlootable` containers are still reported (the overlay draws them) but tagged
|
||
`cannot be taken`. Scan is bounded to a 20-tile box and runs on the edge only,
|
||
never per frame — invariant 5.
|
||
|
||
**71 testbed checks, 0 failures** (was 59). The prologue has no loot at all
|
||
(`chest_tutorial` is empty by design), so the live path is exercised by
|
||
**planting `ivy_leaves` on the tile under the player, scanning, and putting the
|
||
tile back** — otherwise only the "nothing here" branch would ever run. Plus the
|
||
pure phrasing checks, the FoV invariant over the real grid, the `levelWindow`
|
||
probe, and an end-to-end `s` press.
|
||
|
||
### Done — hotbar keys 1–0 (bug fix, reported by Talon)
|
||
|
||
**Symptom:** bind an item to the hotbar, press its key, and you hear the sound
|
||
the action plays — and nothing else. No item name, no verb, no confirmation that
|
||
the key registered at all.
|
||
|
||
**Cause.** `views/hotbar.lua:checkKeypress` resolves the slot and calls
|
||
`hotkey.action(state)` (`models/hotkey.lua`), which for an equipment binding
|
||
looks up the applicable entry in `modules/item/itemActions.lua` and calls its
|
||
`onActivation` **directly** — Wield, Unwield, Drink, Switch on, Pack away… None
|
||
of those go through `console.print` or a popup; most only play a sound. So the
|
||
whole hotbar was silent by construction.
|
||
|
||
**`accessibility/patches/hotbar.lua`** (7th patch) wraps the hotbar **view**
|
||
factory. Live text: `Torch, Wield` → `Torch, Unwield`.
|
||
|
||
Decisions worth not relitigating:
|
||
|
||
- **The view, not the model.** `models/hotbar.lua` captures `newHotkey` as a
|
||
file-local at require time, and `models/world.lua` captures `newHotbar` the
|
||
same way — both long before we run, so replacing `package.loaded` for either
|
||
would change nothing. `views/hotbar.lua` is required only by
|
||
`states/{ingame,inventory,actions_menu}`, which load lazily; same ordering
|
||
argument as `patches/slider.lua`.
|
||
- **The verb is computed BEFORE the action runs.** The itemAction conditions key
|
||
off `item.isEquipped`, which the action itself flips — asked afterwards they
|
||
return the opposite verb, so a press that wielded would announce "Unwield".
|
||
- **`hotkey.pack()` is the only public view of a hotkey** (`{type, command,
|
||
index, itemTemplateId}`); everything else in it is a closure local.
|
||
`hotbarModel` is tracked by wrapping `refreshModel` rather than probed, since
|
||
it is swapped when the player changes hotbar page.
|
||
- **`actionIdsFor` mirrors a file-local table** (`itemActionsByEquipmentCommand`
|
||
in `models/hotkey.lua`) that has no getter. It is exported and canaried by the
|
||
testbed: every id must still exist and still carry a header.
|
||
- **Item not in inventory: we say nothing.** The game already prints
|
||
`"<name> not found in your inventory."` and `patches/console.lua` speaks it.
|
||
- **Empty slot** is announced (`Hotbar 1, empty`) — silence is indistinguishable
|
||
from a dropped key. So is the silent "no applicable action" branch, except
|
||
where `autoSwitchToThrow` fires, which reports the new action instead.
|
||
- **Binding and unbinding are narrated too** (`self.bind`), since those are
|
||
sound-only as well: `Torch bound to hotbar 1` / `Hotbar 1 cleared`.
|
||
- Action-type hotkeys (hit/interact/throw/push) report the resulting action with
|
||
`status.lua`'s wording: `Action: Throw`.
|
||
|
||
**78 testbed checks, 0 failures** (was 71). The prologue gives the player no
|
||
items, so the live path is exercised by **planting a torch, binding it to slot 1,
|
||
pressing `1` twice, and putting the world back** (a lit torch would otherwise
|
||
change FoV for the viewwatch and loot checks that follow). Plus the header-table
|
||
canary and the `Wield`/`Unwield` flip.
|
||
|
||
### Known gaps (next up)
|
||
- **Phase 5**: player status key (HP, statuses, current action), popup
|
||
announcements (`popupStack.add`), damage alerts
|
||
(`ingame.checkInterruptions`), mod settings.
|
||
- `ingame` itself has no descriptor.
|
||
- **Unverified against real content:** tracker categories Items / Alchemy /
|
||
Exits / Creatures / Terrain (tutorial house has none), and inventory item
|
||
names (inventory is empty in the prologue).
|
||
- `worldmap`, `journal`, `character`, `research`, `inventory`, `craft`, `refine`,
|
||
`trade`, `actions_menu`, `alchemy_table`, `keybindings_menu`, `config_menu`,
|
||
`menu_text`, `ingame_message`, `menu_message` still have no descriptors.
|
||
|
||
### 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' ':!CLAUDE.md'
|
||
```
|
||
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`.
|
||
|
||
---
|
||
|
||
## HANDOFF — read this if you are resuming
|
||
|
||
### State of play
|
||
Phases 0–5 complete. **23 state descriptors, 7 patches, 78/78 testbed checks**,
|
||
history unit tests passing, **one line changed in the game** (`main.lua`).
|
||
|
||
Everything in the original brief is implemented: console speech with
|
||
line-by-line review, the object tracker with the requested key scheme, tile
|
||
exploration, wall tones, and keyboard+speech access to every screen.
|
||
|
||
### First thing to do after any change
|
||
```
|
||
lua accessibility/tests/test_history.lua # pure Lua, instant
|
||
bash accessibility/tests/run-testbed.sh # ~4 min, 78 checks
|
||
git diff --stat vanilla-0.4.2 -- . ':!accessibility' ':!.gitignore' ':!CLAUDE.md' # must stay: main.lua | 1 +
|
||
```
|
||
|
||
### The five traps (all cost real debugging time — don't rediscover them)
|
||
|
||
1. **`love.keypressed` is captured by value.** `states.setup()` runs
|
||
`love[cb] = love[cb] or states[cb]` *before* us. Wrapping the `states` table
|
||
field alone does nothing for `keypressed`/`textinput` — you must also
|
||
reassign `love.keypressed`. `switch`/`update` are fine (looked up at call
|
||
time). **Any new callback you wrap needs the same treatment.**
|
||
2. **Focus moves without a keypress.** `main_menu.mousemoved` assigns
|
||
`selectedOption` from cursor position. Hence focus is *polled* in the update
|
||
hook, not announced only on keypress.
|
||
3. **Nested `states.switch` announces inner-first.** `ingame.open()` can switch
|
||
straight to `ingame_dialogue`. We announce after `realSwitch` returns, so the
|
||
outer announcement would clobber the inner one — guarded by checking
|
||
`states.getCurrent() ~= newState`.
|
||
4. **`states.lua` requires with a SLASH** (`require("states/"..name)`).
|
||
`require("states.main_menu")` loads a *second independent copy* with its own
|
||
upvalues. Always match the slash.
|
||
5. **Probes may need chaining or a cross-function search.** Some locals are
|
||
upvalues of an inner function (`narration` → `continueNarration`) or are only
|
||
touched in `open()` (`actions_menu`, `worldmap`). Use `menus.getAnyUpvalue`.
|
||
Use `menus.hasUpvalue` when you need "exists" rather than "has a value".
|
||
|
||
### Not verified — needs a real playthrough
|
||
These are blocked on content the prologue does not contain, **not** known broken:
|
||
|
||
- **Tracker categories** Items / Alchemy / Exits / Creatures / Terrain. Only
|
||
Interactables is populated in the tutorial house. Diagnostic: the testbed
|
||
prints `tracker contents:` with per-category kind names.
|
||
- **Inventory item names, the `equipped` suffix and the item ACTION MENU** —
|
||
inventory is empty in the prologue, so only the "empty slot" path has run live.
|
||
The action-menu upvalue names are covered by a synthetic testbed check.
|
||
- **Item details** (`=`, `Shift+=`, and the 0.6 s settle) against a real item,
|
||
and the craft / refine / trade hint panels. The synthetic `ivy_leaves` check
|
||
covers `getPopupMessage`, and `hasUpvalue` covers the panel locals, but no
|
||
live item has been focused. The settle delay in particular wants tuning by
|
||
ear — `details.config.settleDelay`.
|
||
- **The quantity slider** (`patches/slider.lua`). Confirmed *applied* by the
|
||
self-test but never exercised: `ingame_wait` is unreachable because
|
||
`tutorial_bed` has an empty `tryInteractFunction` by design, and
|
||
craft/refine/trade need an alchemy table and real items. The testbed already
|
||
has `walkTo()` and tracker-based bed lookup ready for when one is reachable.
|
||
- **The loot overlay (`s`) against real loot.** The prologue contains none, so
|
||
live it has only ever said `No loot in view`. The contents path is proven by
|
||
the testbed's planted-loot check, not by a real chest.
|
||
- **Popup announcements** (`patches/popupstack.lua`) — loot/quest/recipe popups
|
||
did not fire during the prologue run.
|
||
|
||
### Known-missing features (deliberate, not bugs)
|
||
- **No mod settings UI.** Verbosity (`describe.verbosity`), wall-tone config
|
||
(`walltones.config`) and `speech.logToConsole` are code-level fields only.
|
||
- **Q6 unanswered**: our hotkeys are a separate scheme, not registered in the
|
||
game's own rebinding UI. They are therefore not user-rebindable.
|
||
- **Q8 unanswered**: no exhaustive keyboard-reachability audit was done. All 28
|
||
states implement `keypressed`, and every screen reached so far was navigable,
|
||
but this was not proven screen-by-screen.
|
||
- **Distribution not designed** (D1 deferred). Any shipped build must place the
|
||
Tolk DLLs beside the executable — they cannot live inside a `.love`.
|
||
|
||
### If a future game version breaks something
|
||
Run the game and read the startup self-test. It reports every patch and every
|
||
descriptor probe. `PROBE BROKEN: no upvalue 'x'` means that local was renamed —
|
||
fix the descriptor in `accessibility/states/<state>.lua`. `ok (unset until
|
||
opened)` is normal.
|
||
|
||
To rebase the mod onto a new game version: commit the new pristine tree on top
|
||
of the `vanilla-0.4.2` baseline, then replay the mod commits. The only game-file
|
||
conflict possible is the single `main.lua` line.
|