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>
7.2 KiB
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:
-- 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 trustsaccess.announce(entry, i, n)calls injected into game files.mode = "probe"→ the driver wrapsstates.keypressed, reads upvalues viadebug.getupvalue, and calls the samerender.
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.2tag) 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:
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.