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>
171 lines
11 KiB
Markdown
171 lines
11 KiB
Markdown
# 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.
|