aboutsummaryrefslogtreecommitdiffziptar.gz
diff options
context:
space:
mode:
authorgodosa <godosa@godosa.eu>2026-10-06 23:39:36 +0200
committergodosa <godosa@godosa.eu>2026-10-06 23:39:36 +0200
commit39066900773e7857faf2d02e7ed51b71d219d97d (patch)
treeffab8e4ddd971626776c3b1f4ce1da2c186c2187
downloadgodosa-engine-39066900773e7857faf2d02e7ed51b71d219d97d.tar.gz
godosa-engine-39066900773e7857faf2d02e7ed51b71d219d97d.zip
godosa-engine: initial public history
-rw-r--r--.editorconfig19
-rw-r--r--.gitignore23
-rw-r--r--CLAUDE.md24
-rw-r--r--CREDITS.md10
-rw-r--r--Directory.Build.props9
-rw-r--r--Directory.Packages.props18
-rw-r--r--Godosa.slnx9
-rw-r--r--LICENSE21
-rw-r--r--README.md12
-rw-r--r--docs/notes/avalonia-gotchas.md53
-rw-r--r--docs/notes/run-scripts-routing.md124
-rw-r--r--docs/notes/test-gotchas.md29
-rw-r--r--licenses.md22
-rwxr-xr-xscripts/alsoft_wave.sh18
-rwxr-xr-xscripts/audio_report.py101
-rwxr-xr-xscripts/digest_compare.py34
-rw-r--r--scripts/godosa_licenses.py145
-rwxr-xr-xscripts/mutate.sh52
-rw-r--r--scripts/test_audio_report.py44
-rw-r--r--scripts/test_digest_compare.py42
-rw-r--r--scripts/test_godosa_licenses.py116
-rw-r--r--scripts/test_thread_cpu.py24
-rw-r--r--scripts/test_thread_instr.py21
-rwxr-xr-xscripts/thread_cpu.py60
-rwxr-xr-xscripts/thread_instr.py42
-rw-r--r--src/Godosa.Audio/Godosa.Audio.csproj18
-rw-r--r--src/Godosa.Audio/OpenAlAudio.Reverb.cs83
-rw-r--r--src/Godosa.Audio/OpenAlAudio.Streams.cs119
-rw-r--r--src/Godosa.Audio/OpenAlAudio.cs360
-rw-r--r--src/Godosa.Core.Tests/Audio/AudioMixTests.cs22
-rw-r--r--src/Godosa.Core.Tests/Audio/Mp3Tests.cs90
-rw-r--r--src/Godosa.Core.Tests/Audio/SynthTests.cs122
-rw-r--r--src/Godosa.Core.Tests/Audio/WavReaderTests.cs127
-rw-r--r--src/Godosa.Core.Tests/Diagnostics/DebugFlagsTests.cs59
-rw-r--r--src/Godosa.Core.Tests/Diagnostics/FrameTimeStatsTests.cs97
-rw-r--r--src/Godosa.Core.Tests/Diagnostics/GpuAndCsvTests.cs53
-rw-r--r--src/Godosa.Core.Tests/Diagnostics/PerfCsvTests.cs38
-rw-r--r--src/Godosa.Core.Tests/Godosa.Core.Tests.csproj13
-rw-r--r--src/Godosa.Core.Tests/Input/GamepadScriptTests.cs56
-rw-r--r--src/Godosa.Core.Tests/Input/InputScriptTests.cs73
-rw-r--r--src/Godosa.Core.Tests/Input/MenuCursorTests.cs42
-rw-r--r--src/Godosa.Core.Tests/Runs/RouteLegsTests.cs116
-rw-r--r--src/Godosa.Core.Tests/Runs/RunsTests.cs149
-rw-r--r--src/Godosa.Core.Tests/Runs/Scripts/ActionInputTests.cs47
-rw-r--r--src/Godosa.Core.Tests/Runs/Scripts/CheckpointCacheTests.cs185
-rw-r--r--src/Godosa.Core.Tests/Runs/Scripts/ConditionTests.cs63
-rw-r--r--src/Godosa.Core.Tests/Runs/Scripts/FakeGame.cs100
-rw-r--r--src/Godosa.Core.Tests/Runs/Scripts/ReviewFixTests.cs157
-rw-r--r--src/Godosa.Core.Tests/Runs/Scripts/RunReportTests.cs83
-rw-r--r--src/Godosa.Core.Tests/Runs/Scripts/ScriptLoaderTests.cs103
-rw-r--r--src/Godosa.Core.Tests/Runs/Scripts/ScriptParserTests.cs111
-rw-r--r--src/Godosa.Core.Tests/Runs/Scripts/ScriptRunFlagsTests.cs54
-rw-r--r--src/Godosa.Core.Tests/Runs/Scripts/ScriptRunnerStructureTests.cs85
-rw-r--r--src/Godosa.Core.Tests/Runs/Scripts/ScriptRunnerTests.cs224
-rw-r--r--src/Godosa.Core.Tests/Runs/Scripts/ScriptValidatorTests.cs63
-rw-r--r--src/Godosa.Core.Tests/Runs/Scripts/StepLexerTests.cs58
-rw-r--r--src/Godosa.Core.Tests/Runs/Scripts/VarsTests.cs84
-rw-r--r--src/Godosa.Core.Tests/SettingsFileTests.cs90
-rw-r--r--src/Godosa.Core/Audio/AudioTypes.cs45
-rw-r--r--src/Godosa.Core/Audio/BiquadFilter.cs37
-rw-r--r--src/Godosa.Core/Audio/Mp3.cs133
-rw-r--r--src/Godosa.Core/Audio/Pcm.cs24
-rw-r--r--src/Godosa.Core/Audio/SignalStats.cs51
-rw-r--r--src/Godosa.Core/Audio/Synth.cs189
-rw-r--r--src/Godosa.Core/Audio/WavReader.cs157
-rw-r--r--src/Godosa.Core/Diagnostics/DebugFlags.cs20
-rw-r--r--src/Godosa.Core/Diagnostics/EngineDebug.cs25
-rw-r--r--src/Godosa.Core/Diagnostics/FrameTimeStats.cs139
-rw-r--r--src/Godosa.Core/Diagnostics/PerfCsv.cs27
-rw-r--r--src/Godosa.Core/Godosa.Core.csproj5
-rw-r--r--src/Godosa.Core/Input/GamepadState.cs58
-rw-r--r--src/Godosa.Core/Input/InputScript.cs82
-rw-r--r--src/Godosa.Core/Input/MenuCursor.cs21
-rw-r--r--src/Godosa.Core/Runs/DigestLine.cs13
-rw-r--r--src/Godosa.Core/Runs/FrameClock.cs23
-rw-r--r--src/Godosa.Core/Runs/HeadlessLoop.cs20
-rw-r--r--src/Godosa.Core/Runs/RouteLegs.cs117
-rw-r--r--src/Godosa.Core/Runs/RunFlags.cs39
-rw-r--r--src/Godosa.Core/Runs/Scripts/CheckpointCache.cs112
-rw-r--r--src/Godosa.Core/Runs/Scripts/Condition.cs234
-rw-r--r--src/Godosa.Core/Runs/Scripts/Input.cs24
-rw-r--r--src/Godosa.Core/Runs/Scripts/InputPlans.cs111
-rw-r--r--src/Godosa.Core/Runs/Scripts/LibVerbs.cs32
-rw-r--r--src/Godosa.Core/Runs/Scripts/RunGame.cs68
-rw-r--r--src/Godosa.Core/Runs/Scripts/RunOutput.cs21
-rw-r--r--src/Godosa.Core/Runs/Scripts/RunReport.cs58
-rw-r--r--src/Godosa.Core/Runs/Scripts/ScriptAst.cs25
-rw-r--r--src/Godosa.Core/Runs/Scripts/ScriptLoader.cs218
-rw-r--r--src/Godosa.Core/Runs/Scripts/ScriptParser.cs231
-rw-r--r--src/Godosa.Core/Runs/Scripts/ScriptProgram.cs57
-rw-r--r--src/Godosa.Core/Runs/Scripts/ScriptRunFlags.cs100
-rw-r--r--src/Godosa.Core/Runs/Scripts/ScriptRunner.cs568
-rw-r--r--src/Godosa.Core/Runs/Scripts/ScriptTypes.cs46
-rw-r--r--src/Godosa.Core/Runs/Scripts/ScriptValidator.cs109
-rw-r--r--src/Godosa.Core/Runs/Scripts/StepLexer.cs104
-rw-r--r--src/Godosa.Core/Runs/Scripts/Vars.cs174
-rw-r--r--src/Godosa.Core/Runs/SeedStreams.cs14
-rw-r--r--src/Godosa.Core/Runs/StateDigest.cs51
-rw-r--r--src/Godosa.Core/SettingsFile.cs49
-rw-r--r--src/Godosa.Desktop/Buttons.cs15
-rw-r--r--src/Godosa.Desktop/CursorWarp.cs66
-rw-r--r--src/Godosa.Desktop/DisplayMode.cs19
-rw-r--r--src/Godosa.Desktop/FrameTimeGraph.cs65
-rw-r--r--src/Godosa.Desktop/GlProgram.cs35
-rw-r--r--src/Godosa.Desktop/Godosa.Desktop.csproj12
-rw-r--r--src/Godosa.Desktop/GpuTimer.cs71
-rw-r--r--src/Godosa.Desktop/InputScriptPlayer.cs124
-rw-r--r--src/Godosa.Desktop/PadNavigator.cs85
-rw-r--r--src/Godosa.Gamepad/Godosa.Gamepad.csproj11
-rw-r--r--src/Godosa.Gamepad/SdlGamepadSource.cs107
110 files changed, 8457 insertions, 0 deletions
diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 0000000..a8652a5
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,19 @@
+root = true
+
+[*]
+charset = utf-8
+end_of_line = lf
+insert_final_newline = true
+trim_trailing_whitespace = true
+indent_style = space
+indent_size = 4
+
+[*.{csproj,props,slnx,xml,json,yml}]
+indent_size = 2
+
+[*.cs]
+csharp_style_namespace_declarations = file_scoped:suggestion
+csharp_prefer_braces = when_multiline:suggestion
+csharp_style_var_for_built_in_types = false:suggestion
+csharp_style_var_when_type_is_apparent = true:suggestion
+dotnet_sort_system_directives_first = true
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..ba6647d
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,23 @@
+bin/
+obj/
+.vs/
+.vscode/
+.idea/
+*.user
+__pycache__/
+*.pyc
+.worktrees/
+out/
+
+# private state (lives in the private project repo)
+TASKS.md
+tasks/
+workflow.toml
+docs/plans/
+docs/specs/
+docs/AREA-NOTES.md
+USER-NEXT.md
+CATALOG.md
+publish-denylist.local
+archive/
+.wf/
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..e8ee1b2
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,24 @@
+# CLAUDE.md — godosa-engine
+
+Shared engine library for Avalonia + Silk.NET games. Terse everywhere.
+Private notes (tasks, specs, catalog) are kept outside this repo; never add them here (see `.gitignore`).
+
+## Rules
+- Lib knows no game: no game types, game names, env var names or settings classes. The game passes values in
+ or implements a small interface (`IAudioMix`, key-target `Func`, env var name via `Configure`).
+- `Godosa.Core` = pure .NET, tested. `Godosa.Desktop` / `Godosa.Audio` / `Godosa.Gamepad` = Avalonia+GL, OpenAL, SDL glue.
+- `public` for anything a game uses. Warnings are errors. Package versions only in
+ `Directory.Packages.props`, equal to the games' (Avalonia 12.1.2, all Silk.NET 2.23.0).
+- New tests mutation-checked: `scripts/mutate.sh` = KILLED.
+- Breaking API is allowed: the games adapt when they take the update.
+
+## Build / test (≈ 10 s)
+`dotnet build Godosa.slnx` (0 warnings) · `dotnet test Godosa.slnx` · `python3 -m unittest discover -s scripts -p 'test_*.py'`.
+From a game: its solution includes the lib projects, so its `dotnet test` runs the lib tests too.
+
+## Git
+- Branch `master`. Games use this repo as the submodule `lib/godosa-engine`.
+- Work in a git worktree on a branch (`.worktrees/<topic>`), not in the main tree; ff-merge to `master` when green.
+
+## Where
+`src/Godosa.*` · `scripts/` · `docs/notes/`.
diff --git a/CREDITS.md b/CREDITS.md
new file mode 100644
index 0000000..848f086
--- /dev/null
+++ b/CREDITS.md
@@ -0,0 +1,10 @@
+# Credits
+
+| Kind | Who | Note |
+|---|---|---|
+| Author | godosa | |
+| Libraries | see [licenses.md](licenses.md) | licence notices |
+| Origin of the code | lifted from earlier games of the same author | |
+| Tooling | AI assistance (Claude, Anthropic) | engineering tool; statement in README |
+
+The lib holds no game content and no code ported from any original game.
diff --git a/Directory.Build.props b/Directory.Build.props
new file mode 100644
index 0000000..ae58eb3
--- /dev/null
+++ b/Directory.Build.props
@@ -0,0 +1,9 @@
+<Project>
+ <PropertyGroup>
+ <TargetFramework>net10.0</TargetFramework>
+ <Nullable>enable</Nullable>
+ <ImplicitUsings>enable</ImplicitUsings>
+ <LangVersion>latest</LangVersion>
+ <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
+ </PropertyGroup>
+</Project>
diff --git a/Directory.Packages.props b/Directory.Packages.props
new file mode 100644
index 0000000..e936019
--- /dev/null
+++ b/Directory.Packages.props
@@ -0,0 +1,18 @@
+<Project>
+ <PropertyGroup>
+ <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
+ </PropertyGroup>
+ <ItemGroup>
+ <PackageVersion Include="Avalonia" Version="12.1.2" />
+ <PackageVersion Include="Silk.NET.OpenGL" Version="2.23.0" />
+ <PackageVersion Include="Silk.NET.OpenAL" Version="2.23.0" />
+ <PackageVersion Include="Silk.NET.OpenAL.Extensions.Creative" Version="2.23.0" />
+ <PackageVersion Include="Silk.NET.OpenAL.Extensions.Soft" Version="2.23.0" />
+ <PackageVersion Include="Silk.NET.OpenAL.Soft.Native" Version="1.23.1" />
+ <PackageVersion Include="NLayer" Version="3.0.0" />
+ <PackageVersion Include="Silk.NET.SDL" Version="2.23.0" />
+ <PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
+ <PackageVersion Include="xunit" Version="2.9.2" />
+ <PackageVersion Include="xunit.runner.visualstudio" Version="2.8.2" />
+ </ItemGroup>
+</Project>
diff --git a/Godosa.slnx b/Godosa.slnx
new file mode 100644
index 0000000..1f3b428
--- /dev/null
+++ b/Godosa.slnx
@@ -0,0 +1,9 @@
+<Solution>
+ <Folder Name="/src/">
+ <Project Path="src/Godosa.Core.Tests/Godosa.Core.Tests.csproj" />
+ <Project Path="src/Godosa.Core/Godosa.Core.csproj" />
+ <Project Path="src/Godosa.Desktop/Godosa.Desktop.csproj" />
+ <Project Path="src/Godosa.Gamepad/Godosa.Gamepad.csproj" />
+ <Project Path="src/Godosa.Audio/Godosa.Audio.csproj" />
+ </Folder>
+</Solution>
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..c6247a1
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 godosa
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..210847b
--- /dev/null
+++ b/README.md
@@ -0,0 +1,12 @@
+# godosa-engine
+
+Shared engine library for the Avalonia + Silk.NET games in this folder (timing, perf CSV, debug
+flags, GPU timer, GL helpers, audio, gamepad). Entry points: `src/Godosa.*`; notes: `docs/notes/`.
+
+Licence: MIT, see [LICENSE](LICENSE). Third-party notices: [licenses.md](licenses.md). Credits: [CREDITS.md](CREDITS.md).
+
+## From the author
+
+These projects are things that have been tumbling about in my head for a long time, and I now feel like trying to do something with. The bulk of my focus here is on some of the games that I love, but every time I boot them up, I just fiddle about in the menu and set up mods and fixes for a couple hours, with my drive to play them fizzling out. This is my hope to solve that, and while I am a professional software engineer this would not have happened were it not for the rise of (relatively) cheap AI that could do the bulk of the work with me. If you don't approve, that's fine. I did these things for me, sharing them is something I do in the hopes to help others in similar situations, that just want to play the games they love. Thank you to all that made these playable in the first place, with some luck this finds you, and can bring some joy.
+
+Written with AI assistance (Claude, by Anthropic), used as an engineering tool.
diff --git a/docs/notes/avalonia-gotchas.md b/docs/notes/avalonia-gotchas.md
new file mode 100644
index 0000000..5150505
--- /dev/null
+++ b/docs/notes/avalonia-gotchas.md
@@ -0,0 +1,53 @@
+# Avalonia / Desktop gotchas
+
+Each one cost debugging time once. The code has comments at each site;
+this file is the index.
+
+- **The GL view isn't hit-testable by default.** `OpenGlControlBase` draws
+ through GL, not Avalonia's scene. Pointer events and `Cursor` go to the
+ panel beneath unless the control implements `ICustomHitTest`
+ (`GameView.HitTest`). Display-only overlays need `IsHitTestVisible =
+ false` so the pointer reaches the game.
+- **Deactivation ≠ lost focus.** Clicking another app deactivates the window
+ but keeps logical focus (`IsFocused` stays true), so `OnLostFocus` never
+ fires. Release held keys on `Window.Deactivated` too, because `OnKeyUp`
+ never arrives for a key released elsewhere.
+- **The sim only advances while frames render.** `GameLoop.Frame` runs
+ inside `OnOpenGlRender`. Stopping `RequestNextFrameRendering` (paused)
+ stops the sim, and "keep simulating while minimized" would need a timer.
+- **The render callback runs on the UI thread** (verified on this Linux/X11
+ setup). The overlay and `FrameTimeStats` share state without locks for
+ that reason. Re-check if a platform moves rendering off-thread.
+- **No pointer lock API** in Avalonia 12.1.2. Free look reads deltas between
+ pointer positions and stalls at the window edge. Recentering uses a
+ platform call (`CursorWarp`, X11 `XWarpPointer`; off where it fails).
+- **Escape vs focused controls.** A clicked `CheckBox` takes keyboard focus,
+ so `Esc` for the options menu is a window-level *tunnel* handler, not
+ `GameView.OnKeyDown`.
+- **Tests: float vs double.** `FixedStepClock.TickSeconds` is `float`. Compare
+ it against double arithmetic with a tolerance (~1e-6), not 9 places.
+- **A clicked in-game `Button` steals the game's keys.** It takes focus, so
+ WASD stops reaching `GameView` and Space presses the button. HUD buttons
+ are `Focusable = false` (`Ui.Action`).
+- **Raising `Button.ClickEvent` doesn't toggle a `CheckBox`.** Toggle logic
+ lives in `ToggleButton.OnClick`; scripted/pad presses use `Ui.Press`.
+- **`KeyBindings` inside a control resolves to Avalonia's
+ `InputElement.KeyBindings`**, not ours — qualify the game's own `KeyBindings` type fully.
+- **Invisible controls aren't templated.** `IsVisible = false` skips
+ measure/template, so the first real open pays for it (first NPC dialog
+ ~170 ms). `Ui.WarmUp()` shows a zero-opacity card briefly instead.
+- **Routed key events only reach controls on the route.** A `KeyDown` raised
+ on the focused `GameView` tunnels window → `GameScreen` → `GameView`; a
+ sibling panel (options) never sees it. Screen-wide key handling lives on
+ `GameScreen` (e.g. key-rebind capture).
+- **Scripted input vs X injection.** On KDE Wayland `xdotool` keys don't reach
+ a focused XWayland window (and may type into the user's terminal); drive
+ smoke runs with `smoke_run.sh --input FILE` (routed events in-process).
+- **The HUD is redrawn every frame over the GL view.** The GL control changes every frame
+ and covers the window, so the compositor re-draws every overlay each frame — text is
+ the expensive part. `CacheMode = new BitmapCache()` on HUD cards (`Ui.Card`) makes them
+ re-render only on change; set `RenderAtScale` to any scale transform above them or they
+ blur. Merging text blocks into fewer visuals doesn't help.
+- **Don't judge perf by CPU % on this machine** (±50 % with other load). Use the `instr:`
+ smoke step, interleaved runs, `PauseWhenInactive` off; see game-loop spec §7.
+
diff --git a/docs/notes/run-scripts-routing.md b/docs/notes/run-scripts-routing.md
new file mode 100644
index 0000000..8b8a2d7
--- /dev/null
+++ b/docs/notes/run-scripts-routing.md
@@ -0,0 +1,124 @@
+# Run scripts — routing a player-mode route
+
+How a game ("game A" below; "game B" = a 3D game) gets a player-mode run script (input only: clicks, keys, the game's own UI) across a large world, and
+what any game reuses. The lib's runner and step format: `src/Godosa.Core/Runs/Scripts/`. Code:
+`src/Godosa.Core/Runs/RouteLegs.cs` (pure, tested in `Godosa.Core.Tests/Runs/RouteLegsTests.cs`).
+
+## The method: scout headless, then play once in the window
+
+1. **Scout** with debug-mode scripts run headless (no window, seconds per run): teleport, then ask questions through
+ game cheats that print answers to stdout (`cheat "route=…"`, `"hops=…"`, `"path=…"`, `"near=…"`, `"find=…"`).
+ Debug mode never reaches the player script; it only finds the facts.
+2. **Write** the player steps from those answers (`travel-to` legs, `walk-path` targets, portal clicks, answer
+ texts). Cite the fact in a comment above the step (why the detour, which script opens what).
+3. **Play** the chapter once in the window from the last checkpoint; the checkpoint cache makes reruns resume at the
+ first changed step. Fix from the failure report (`out/runs/<run>/report.txt` + `fail.snap`), rerun.
+
+Scouting from a fresh game misleads where the world changes with play (opened seals, dead NPCs, decayed bodies):
+check a finding against the run's own `fail.snap` (the save doc) when it disagrees with the window run.
+
+## Overland: `RouteLegs.Path` + `RouteLegs.Legs`
+
+- The world is a grid (sectors). `Path` = breadth-first, 8 neighbours, over the game's passability (`blocked(x, y)`).
+- The game's own router (what a click on the world map does) takes only some trips: greedy steps, short detour windows,
+ refusals across water or mountains. `Legs` cuts the BFS path into the farthest stops that router accepts
+ (`accepts(stop, cell)` = the game's router finds a way): each stop becomes one `travel-to` leg.
+- Legs are **direction dependent**: the reverse of a working leg list can be refused. Scout each direction.
+- `Legs` returns null with the stuck stop: report it (`the router is stuck at …`) instead of guessing.
+
+## Seals: `RouteLegs.Seals`
+
+Script-sealed cells (a pass a tile script opens, a bridge a toll opens) are blocked like walls until the story opens
+them. When `Path` finds nothing, `Seals` reruns it with the soft cells open and names the ones a way would cross (at
+most 8, start to goal); empty when it is walled off anyway or already open. A refusal that names seals tells the author
+to walk across instead of travelling, or to open the seal first. A sealed goal cell is still reachable on foot: travel
+to the nearest open cell, then `walk-path` in.
+
+## Local maps: `RouteLegs.Hops`
+
+Inside buildings and dungeons the way is walks plus portal clicks (doors, stairs, relocate scripts). `Hops` is a BFS
+over portal exits: `reaches(a, b)` = the game's tile pathfinder walks from a to b; the result is the portals to click,
+in order (empty: walk straight there; null: no chain).
+
+## In the steps (player verbs that keep a route going)
+
+- `walk-path` clicks ahead along the game's A* path, fights back when attacked, passes over a foe whose shots hit a
+ wall (it is out of line of fire from here), closes stray loot windows.
+- `travel-to` resumes after random encounters; refuses inside a town map (walk out first).
+- `journey <legs…>` (game A) = `travel-to` per leg, expecting fights: an encounter's talk that offers a fight gets
+ that option (the game's dialog data marks it: code `co`), the fight is fought, combat mode is left once nothing is
+ after the party, the leg goes on. Any other talk faults with the speaker: story talks stay scripted. The scout's
+ route answer prints one `journey` line. Worth copying: one verb owns the whole interruption loop, so scripts never
+ carry per-site encounter patches.
+- Setup cheats (no random encounters) are run state, not world state: a checkpoint must carry them, and a format change
+ must change the cache key, or a resume quietly plays a different game (game A met ambushes for hours that way).
+- `fight` steps two tiles along the path toward a foe it cannot hit from here.
+- Arrival snaps: travel near a named area lands at the area (the nearest wins), not at the clicked spot; plan the next
+ leg from where the PC actually lands.
+
+## Pace (watchable, recordable runs)
+
+- A long A* every poll was most of a window run's cost (game A: up to 25 ms per game frame); plan the way once per
+ step, keep it while the PC is on it, plan again off it or after a click that did not move the PC.
+- Frames per drawn frame must stay a fixed count (determinism: steps locate on what is drawn), so the wall-clock pace
+ swings with what each frame costs. Even it out with a hold, not a count: each game frame due at k / fps, held until
+ then; a lag beyond 0.1 s re-anchors instead of bursting (game A `RunPace`, `--pace 120`; off by default).
+- The bigger stalls were holds: a window run that holds the game while its scene rebuilds stalls whenever something
+ forces rebuilds — game A rebuilt the whole static scene on every critter pose frame and on travel fatigue damage
+ (each world-map tick). Measure first (frames held per second, by cause), then key rebuilds on what the static scene
+ actually draws and skip holds while the scene is covered (world map).
+- Determinism in a window: clicks pick on the drawn scene, so which game frame was drawn last must not depend on wall
+ time. Fixed game frames per draw (no wall-time budget), a completed step ends the batch and the next render only
+ draws, and overlays that come and go on UI callbacks (a loading splash) must not take hits.
+
+## Gotchas met so far (game A)
+
+- Bodies decay (a game day): loot quest items when the foe dies, not chapters later.
+- Killing gatekeepers can flip a faction's reaction town-wide (a reputation), not just the witnesses.
+- Off-screen objects can't be clicked: walk next to them first; the step's poll budget can run out while walking.
+
+## Gotchas met so far (game B, Source-style 3D maps)
+
+- Dialogue effects run at the line's end, not when it starts: an NPC line's action (a quest state, a G var) lands
+ after its audio (a 9.8 s mp3), so wait out the line before probing or answering. Time waits from the speech file's
+ length (`Mp3Reader.Seconds`); an oracle without audio (Wine here) ends lines early, so probe both at a fixed late time.
+- Map exits can start hidden: a `trigger_once` in front unhides the `trigger_changelevel` 0.2 s later. A teleport
+ straight onto the exit then does nothing; land on it and wait, or walk through.
+- Pickups are picked by the use ray or by a near-item cone, which a teleport onto the item's spot can miss (eye
+ inside / looking past it): stand a step beside the item and look at it.
+- Map-to-map with landmarks: after a changelevel the PC lands at the landmark offset, not at the teleport spot; plan
+ the next leg from the `arrive <map> <origin>` line.
+- Teleports land a few units above the floor: a spot a hair under it starts the player inside the world (every move
+ blocked from the first frame; game B: z 36.1 vs a floor at 36.03+).
+- A straight `walk-to` that stops short is often a closed door (stuck right in front of it): `face` it, use, wait for
+ it to swing, walk on. Use reach is measured from the eye (96 units in Source): walk close before `press-use`.
+- First-person aiming: closed loop. Send the mouse move for the remaining turn every frame and finish within a
+ tolerance (1°); the mouse filter (m_filter) and integer pixels make one-shot turns land short.
+- NPCs start talks on their own (an alley bum, a pier hawker, a guard spotting the player): the walk ends with the
+ talk open (game B `walk-to`/`walk-path` stop on a talk). Answer, `wait not dialog-open`, repeat the walk.
+- Node graphs (Source `.ain`) skip stairwells, tunnels and building interiors: `walk-path` to the last covered spot,
+ then chain `walk-to` legs found with a floor scan (game B `floor-map`: floor height per grid cell under the player
+ hull; start just above the level you want, or roofs and upper flights answer). Stairs show as steady height steps.
+- A hull is 32 wide: thin invisible posts and rails decide which line works. Compare with the original before calling
+ it a collision bug (game B pier: the original's player clears a post by 0.125 units at y −1608; ours aimed at −1600
+ and stuck; the brush was the same).
+- Gates and doors can be locked or unlocked by quest state when the map loads (game B `beachHouseOpen()` on
+ OnMapLoad): a debug scout started on that map sees a different world than the quest run.
+- A changelevel lying on a stair or ledge may never meet the hull when walking down (the hull rides the edge): come back
+ up into it, or stand still inside it.
+- Original movement as an oracle: teleport the PC, hold forward, sample the origin on a timer; where it stops is where
+ your route must turn.
+- Scout facts from the map data itself (entity lump: changelevel targets + landmarks, `OnStartTouch`/`OnPlayerPickup`
+ outputs, the level script's quest functions, the dialogue's conditions) before trying moves in the game.
+- Quests with many solutions: pick the one the runner can do today and cheat the PC into it in setup (game B
+ `cheat vstats get charisma 5`: the game's own console cheats, so the rest of the run stays player mode). Dialogue
+ checks hide failed replies: `answer n` counts the shown ones, so a stat change renumbers them.
+- Teleport lands next frame: wait a frame or two before a verb that reads the player's position (path planning).
+- Doors rotate about their origin (the hinge): aim use at the leaf's middle. A door can be locked from one side only
+ (game B: the knob on the player's side carries the difficulty): scout the side, not just the door.
+- When a node graph routes through a door the runner cannot open, that door is the designers' intended way: find what
+ opens it (lockpick, key, quest state) before hunting for another path.
+- Watching runs: eased turns look right but change routing: walking while easing round a point closer than the turn
+ radius circles it (stuck fault). Hold forward only when facing the point (tighter when it is near), let go at sharp
+ corners. Run the runner in lockstep with the window's frame loop (one runner frame per drawn frame, each side waits
+ for the other) so the world is never touched from two threads and digests match headless.
diff --git a/docs/notes/test-gotchas.md b/docs/notes/test-gotchas.md
new file mode 100644
index 0000000..496f323
--- /dev/null
+++ b/docs/notes/test-gotchas.md
@@ -0,0 +1,29 @@
+# Test gotchas
+
+Traps met while writing Core tests. Add one when a flaky or misleading test
+cost real time.
+
+## Allocation tests (`GC.GetAllocatedBytesForCurrentThread`)
+
+- **The counter can jump by one allocation context (~8 KB)** under parallel
+ test load — seen as `8200 B` where the same window normally reads `128 B`
+ (2026-09-25, ~2 in 25 build+test runs). Not reproducible in isolation or
+ with an in-test GC-pressure thread. A budget below ~8 KB must measure
+ several windows and take the quietest (`AmbienceDirector_SteadyState…`);
+ a real per-event allocation shows in every window.
+- **`foreach` over an interface (`IReadOnlyList<T>`, `IEnumerable<T>`) boxes
+ the enumerator** — 32–40 B per loop. Hot paths index instead
+ (`CombatSystem.HitSphere`, `Overlaps`). `List<T>` / arrays typed concretely
+ are fine.
+- **Undrained buffers grow in doublings** in headless tests (`_sounds`,
+ notices, damage events cap at a max but grow to it first): one-off spikes of
+ 0.5–1 KB mid-window are that, not a leak. Budget per tick over ≥ 1000 ticks
+ or drain like the Desktop does.
+- Tests run on Debug builds: Core code is never JIT-optimised there, so tiered
+ compilation / escape analysis don't change what Core allocates.
+
+## Timing tests (`Stopwatch`)
+
+- A build server or parallel tests can steal the CPU for one measurement:
+ take the best of several runs (`AudioSpeedTests.RealtimeFactor`). A real
+ slowdown lowers all of them (checked with a `Thread.Sleep` mutant).
diff --git a/licenses.md b/licenses.md
new file mode 100644
index 0000000..62dd6d5
--- /dev/null
+++ b/licenses.md
@@ -0,0 +1,22 @@
+# Third-party licences
+
+Source of truth for versions: `Directory.Packages.props`. Licence = the package's own nuspec
+(`~/.nuget/packages/<id>/<version>/*.nuspec`), read 2026-09-29. Update this file in the same
+commit as any package change. Rule: no copyleft code linked statically.
+
+| Package | Version | Licence | Used by | Notes |
+|---|---|---|---|---|
+| Avalonia | 12.1.2 | MIT | Godosa.Desktop | © The AvaloniaUI Project |
+| NLayer | 3.0.0 | MIT | Godosa.Core | MP3 decoding (`Mp3Reader`, `Mp3Stream`) |
+| Silk.NET.OpenGL | 2.23.0 | MIT | Godosa.Desktop | © .NET Foundation and Contributors |
+| Silk.NET.OpenAL | 2.23.0 | MIT | Godosa.Audio | |
+| Silk.NET.OpenAL.Extensions.Creative | 2.23.0 | MIT | Godosa.Audio | |
+| Silk.NET.OpenAL.Extensions.Soft | 2.23.0 | MIT | Godosa.Audio | |
+| Silk.NET.OpenAL.Soft.Native | 1.23.1 | LGPL-2.0-or-later | Godosa.Audio | OpenAL Soft as a separate shared library, loaded at run time (P/Invoke); not linked statically; the user can replace it |
+| Silk.NET.SDL | 2.23.0 | MIT | Godosa.Gamepad | SDL2 itself: zlib licence, shared library |
+| Microsoft.NET.Test.Sdk | 17.12.0 | MIT | tests only | not shipped |
+| xunit | 2.9.2 | Apache-2.0 | tests only | not shipped |
+| xunit.runner.visualstudio | 2.8.2 | Apache-2.0 | tests only | not shipped |
+
+A game that ships the lib must reproduce the MIT notices above and say that OpenAL Soft is LGPL
+and where its source is (https://github.com/kcat/openal-soft).
diff --git a/scripts/alsoft_wave.sh b/scripts/alsoft_wave.sh
new file mode 100755
index 0000000..31644ec
--- /dev/null
+++ b/scripts/alsoft_wave.sh
@@ -0,0 +1,18 @@
+#!/usr/bin/env bash
+# Writes DIR/alsoft.conf so OpenAL Soft renders to DIR/audio.wav (wave backend, stereo
+# int16) instead of the speakers, and removes an old DIR/audio.wav. Run the game with
+# ALSOFT_CONF=DIR/alsoft.conf. Env: ALSOFT_EXTRA="key = value" lines added to [general].
+# Usage: alsoft_wave.sh DIR
+set -euo pipefail
+out=${1:?usage: alsoft_wave.sh DIR}
+mkdir -p "$out"
+rm -f "$out/audio.wav"
+cat >"$out/alsoft.conf" <<CONF
+[general]
+drivers = wave
+channels = stereo
+sample-type = int16
+${ALSOFT_EXTRA:-}
+[wave]
+file = $out/audio.wav
+CONF
diff --git a/scripts/audio_report.py b/scripts/audio_report.py
new file mode 100755
index 0000000..14af9ab
--- /dev/null
+++ b/scripts/audio_report.py
@@ -0,0 +1,101 @@
+#!/usr/bin/env python3
+"""Levels (and a spectrogram) of a game audio capture — smoke runs write DIR/audio.wav
+through OpenAL Soft's wave backend (audio spec §8). The game is killed at the end of a
+run, so the WAV header sizes may be unfinished: data is read to end of file.
+
+Usage: scripts/audio_report.py WAV [--png OUT.png] [--start S] [--end S]
+ prints one line per second: RMS and peak in dBFS (mono mix)
+ --png: spectrogram of [start, end] via ffmpeg (skipped if ffmpeg is missing)
+"""
+import argparse
+import array
+import math
+import shutil
+import struct
+import subprocess
+import sys
+import tempfile
+from pathlib import Path
+
+
+def read_wav(path):
+ """Returns (rate, channels, samples as list of ints, interleaved). 16-bit PCM only."""
+ data = Path(path).read_bytes()
+ if data[:4] != b"RIFF" or data[8:12] != b"WAVE":
+ raise ValueError("not a RIFF/WAVE file")
+ pos, rate, channels, bits = 12, None, None, None
+ while pos + 8 <= len(data):
+ cid, size = data[pos:pos + 4], struct.unpack("<I", data[pos + 4:pos + 8])[0]
+ body = pos + 8
+ if cid == b"fmt ":
+ _fmt, channels, rate = struct.unpack("<HHI", data[body:body + 8])
+ bits = struct.unpack("<H", data[body + 14:body + 16])[0]
+ elif cid == b"data":
+ if bits != 16:
+ raise ValueError(f"need 16-bit PCM, got {bits}")
+ raw = data[body:] # to EOF: the size may be a placeholder
+ raw = raw[: len(raw) - len(raw) % (2 * channels)]
+ samples = array.array("h")
+ samples.frombytes(raw)
+ if sys.byteorder == "big":
+ samples.byteswap()
+ return rate, channels, samples
+ pos = body + size + (size & 1)
+ raise ValueError("no data chunk")
+
+
+def db(x):
+ return -120.0 if x <= 0 else 20 * math.log10(x)
+
+
+def levels(rate, channels, samples):
+ """[(second, rms_dbfs, peak_dbfs)] of the mono mix, one per whole or partial second."""
+ frames = len(samples) // channels
+ out = []
+ for start in range(0, frames, rate):
+ end = min(frames, start + rate)
+ total, peak = 0.0, 0
+ for f in range(start, end):
+ v = sum(samples[f * channels + c] for c in range(channels)) / channels
+ total += v * v
+ peak = max(peak, abs(v))
+ rms = math.sqrt(total / (end - start)) / 32768
+ out.append((start // rate, db(rms), db(peak / 32768)))
+ return out
+
+
+def write_pcm16(path, rate, channels, samples):
+ raw = samples.tobytes() if sys.byteorder == "little" else array.array("h", samples).byteswap() or samples.tobytes()
+ header = struct.pack("<4sI4s4sIHHIIHH4sI", b"RIFF", 36 + len(raw), b"WAVE", b"fmt ", 16, 1, channels,
+ rate, rate * channels * 2, channels * 2, 16, b"data", len(raw))
+ Path(path).write_bytes(header + raw)
+
+
+def main():
+ ap = argparse.ArgumentParser()
+ ap.add_argument("wav")
+ ap.add_argument("--png")
+ ap.add_argument("--start", type=float, default=0.0)
+ ap.add_argument("--end", type=float)
+ args = ap.parse_args()
+ rate, channels, samples = read_wav(args.wav)
+ print(f"{rate} Hz, {channels} ch, {len(samples) / channels / rate:.1f} s")
+ for second, rms, peak in levels(rate, channels, samples):
+ bar = "#" * max(0, int((rms + 60) / 2))
+ print(f"{second:4d}s rms {rms:7.1f} dBFS peak {peak:7.1f} {bar}")
+ if args.png:
+ if not shutil.which("ffmpeg"):
+ print("ffmpeg missing: no spectrogram")
+ return
+ first = int(args.start * rate) * channels
+ last = len(samples) if args.end is None else int(args.end * rate) * channels
+ with tempfile.TemporaryDirectory() as tmp:
+ fixed = Path(tmp) / "fixed.wav"
+ write_pcm16(fixed, rate, channels, samples[first:last])
+ subprocess.run(["ffmpeg", "-y", "-v", "error", "-i", str(fixed), "-lavfi",
+ "showspectrumpic=s=1200x480:legend=1:scale=log:fscale=log", args.png], check=True)
+ print(f"spectrogram: {args.png}")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/digest_compare.py b/scripts/digest_compare.py
new file mode 100755
index 0000000..b4144fe
--- /dev/null
+++ b/scripts/digest_compare.py
@@ -0,0 +1,34 @@
+#!/usr/bin/env python3
+"""Compare the `digest <frame> <time> <hex>` lines of two run logs (deterministic runs).
+
+Usage: digest_compare.py A.log B.log → exit 0 when equal, 1 with the first difference.
+"""
+import sys
+from pathlib import Path
+
+
+def digests(path: str) -> list[str]:
+ return [line.strip() for line in Path(path).read_text(errors="replace").splitlines() if line.startswith("digest ")]
+
+
+def main(argv: list[str]) -> int:
+ if len(argv) != 3:
+ print(__doc__.strip())
+ return 2
+ a, b = digests(argv[1]), digests(argv[2])
+ if not a and not b:
+ print("FAIL: no digest lines")
+ return 1
+ for k, (x, y) in enumerate(zip(a, b), 1):
+ if x != y:
+ print(f"FAIL: digest {k} differs\n {x}\n {y}")
+ return 1
+ if len(a) != len(b):
+ print(f"FAIL: {len(a)} vs {len(b)} digests")
+ return 1
+ print(f"ok: {len(a)} digests equal, last {a[-1]}")
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main(sys.argv))
diff --git a/scripts/godosa_licenses.py b/scripts/godosa_licenses.py
new file mode 100644
index 0000000..f28f312
--- /dev/null
+++ b/scripts/godosa_licenses.py
@@ -0,0 +1,145 @@
+#!/usr/bin/env python3
+"""Third-party licence list for a .NET game: generates licenses.md and checks it (Python stdlib only).
+
+A game keeps a small `scripts/licenses.py` that builds a `Config` and calls `main(argv, config)`:
+
+ python3 scripts/licenses.py check: fail if stale, a licence is unknown, or a copyleft
+ package ships (exit 1)
+ python3 scripts/licenses.py --write regenerate licenses.md
+
+Sources: every csproj in the solution: its obj/project.assets.json (needs `dotnet restore`/build first), the nuspec in
+the NuGet cache for licence + copyright. Lifted from an earlier project of the same author (NuGet part).
+"""
+import json
+import os
+import re
+import sys
+import xml.etree.ElementTree as ET
+from dataclasses import dataclass, field
+from pathlib import Path
+
+# Licence expressions that must not ship linked into the game's code. LGPL is allowed only as a separate shared
+# library, via Config.allow_shipped with the reason.
+COPYLEFT = re.compile(r"\b(A?GPL|LGPL|MPL|EPL|CDDL|EUPL|OSL|CC-BY-SA)", re.I)
+
+
+@dataclass
+class Config:
+ solution: str # the .slnx whose projects count
+ shipped_dirs: tuple[str, ...] # project dir prefixes whose output ships; everything else = dev/test only
+ header: str # markdown put above the tables
+ allow_shipped: dict[str, str] = field(default_factory=dict) # package id -> why a copyleft licence may ship
+ overrides: dict[str, str] = field(default_factory=dict) # package id -> licence (nuspec has no expression)
+ extra: list[tuple[str, str, str, str, str]] = field(default_factory=list) # (what, version, licence, copyright, where)
+ planned: list[tuple[str, str, str, str, str]] = field(default_factory=list) # (what, version, licence, ships as, checked in)
+ md_path: str = "licenses.md"
+ command: str = "python3 scripts/licenses.py"
+
+
+def nuget_root() -> Path:
+ return Path(os.environ.get("NUGET_PACKAGES", Path.home() / ".nuget" / "packages"))
+
+
+def nuspec_info(pid: str, ver: str, cache: Path) -> tuple[str, str]:
+ d = cache / pid.lower() / ver.lower()
+ specs = list(d.glob("*.nuspec")) if d.is_dir() else []
+ if not specs:
+ return "", ""
+ root = ET.parse(specs[0]).getroot()
+ lic = copy = ""
+ for el in root.iter():
+ tag = el.tag.rsplit("}", 1)[-1]
+ if tag == "license" and el.get("type") == "expression":
+ lic = (el.text or "").strip()
+ elif tag == "copyright":
+ copy = " ".join((el.text or "").split())
+ return lic, copy
+
+
+def has_runtime_assets(target: dict) -> bool:
+ """False for build-only packages (analyzers, MSBuild tasks): nothing lands in the output."""
+ for key in ("runtime", "native", "runtimeTargets"):
+ if any(not f.endswith("/_._") for f in target.get(key, {})):
+ return True
+ return False
+
+
+def collect_nuget(repo: Path, cache: Path, cfg: Config) -> tuple[dict, list[str]]:
+ """{(id, ver): {"users": set, "ship_users": set, "shipped": bool}}, errors."""
+ pkgs: dict = {}
+ errors = []
+ for rel in sorted(re.findall(r'Path="([^"]+\.csproj)"', (repo / cfg.solution).read_text())):
+ csproj = repo / rel
+ assets = csproj.parent / "obj" / "project.assets.json"
+ if not assets.exists():
+ errors.append(f"{rel}: no obj/project.assets.json (run dotnet restore)")
+ continue
+ data = json.loads(assets.read_text())
+ runtime = {}
+ for tgt in data.get("targets", {}).values():
+ for key, t in tgt.items():
+ if t.get("type") == "package" and has_runtime_assets(t):
+ runtime[key] = True
+ shipped = rel.startswith(cfg.shipped_dirs)
+ for key, lib in data.get("libraries", {}).items():
+ if lib.get("type") != "package":
+ continue
+ pid, ver = key.split("/", 1)
+ e = pkgs.setdefault((pid, ver), {"users": set(), "ship_users": set(), "shipped": False})
+ e["users"].add(csproj.stem)
+ if key in runtime and shipped:
+ e["ship_users"].add(csproj.stem)
+ e["shipped"] = True
+ return pkgs, errors
+
+
+def generate(repo: Path, cache: Path, cfg: Config) -> tuple[str, list[str]]:
+ pkgs, errors = collect_nuget(repo, cache, cfg)
+ rows = []
+ for (pid, ver), e in sorted(pkgs.items(), key=lambda kv: (kv[0][0].lower(), kv[0][1])):
+ lic, copy = nuspec_info(pid, ver, cache)
+ lic = cfg.overrides.get(pid, lic)
+ if not lic:
+ errors.append(f"NuGet {pid} {ver}: unknown licence (add to the overrides after checking)")
+ lic = "UNKNOWN"
+ if e["shipped"] and COPYLEFT.search(lic) and pid not in cfg.allow_shipped:
+ errors.append(f"NuGet {pid} {ver}: copyleft licence {lic} ships")
+ rows.append((pid, ver, lic, copy, e))
+
+ def cell(s: str) -> str:
+ return s.replace("|", "\\|")
+
+ md = [cfg.header]
+ md.append("## Shipped (NuGet)\n")
+ md.append("| Package | Version | Licence | Copyright | Used by | Notes |\n|---|---|---|---|---|---|")
+ for pid, ver, lic, copy, e in rows:
+ if e["shipped"]:
+ md.append(f"| {pid} | {ver} | {lic} | {cell(copy)} | {', '.join(sorted(e['ship_users']))} | {cfg.allow_shipped.get(pid, '')} |")
+ md.append("\n## Not a package\n")
+ md.append("| What | Version | Licence | Copyright | Where |\n|---|---|---|---|---|")
+ for what, ver, lic, copy, where in cfg.extra:
+ md.append(f"| {what} | {ver} | {lic} | {copy} | {where} |")
+ md.append("\n## Planned (audited, not yet used)\n")
+ md.append("| What | Version | Licence | Ships as | Checked in |\n|---|---|---|---|---|")
+ for what, ver, lic, how, src in cfg.planned:
+ md.append(f"| {what} | {ver} | {cell(lic)} | {how} | {src} |")
+ md.append("\n## Test and build-only (NuGet, nothing in the output)\n")
+ md.append("| Package | Version | Licence | Used by |\n|---|---|---|---|")
+ for pid, ver, lic, copy, e in rows:
+ if not e["shipped"]:
+ md.append(f"| {pid} | {ver} | {lic} | {', '.join(sorted(e['users']))} |")
+ return "\n".join(md) + "\n", errors
+
+
+def main(argv: list[str], cfg: Config, repo: Path, cache: Path | None = None) -> int:
+ cache = cache or nuget_root()
+ md, errors = generate(repo, cache, cfg)
+ if "--write" in argv:
+ (repo / cfg.md_path).write_text(md)
+ else:
+ f = repo / cfg.md_path
+ if not f.exists() or f.read_text() != md:
+ errors.append(f"{cfg.md_path} is stale: run {cfg.command} --write")
+ for e in errors:
+ print(e, file=sys.stderr)
+ return 1 if errors else 0
diff --git a/scripts/mutate.sh b/scripts/mutate.sh
new file mode 100755
index 0000000..e3132de
--- /dev/null
+++ b/scripts/mutate.sh
@@ -0,0 +1,52 @@
+#!/usr/bin/env bash
+# Mutation check: prove a test can fail. Replaces an exact snippet in one
+# source file (must match exactly once), runs the tests, and ALWAYS restores
+# the file. Exit 0 = mutant killed (some test failed, as it should); 1 =
+# mutant SURVIVED (tests too weak); 3 = mutant doesn't build (invalid —
+# replace a statement with `{ }` instead of deleting a loop body line).
+#
+# Usage (from the repo whose tests must catch it — a game root, or this lib's root):
+# <path>/mutate.sh <file> <old-snippet> <new-snippet>
+# e.g. scripts/mutate.sh src/MyGame.Core/Loop/InputLatch.cs \
+# '_jump |= sample.JumpPressed;' '_jump = sample.JumpPressed;'
+# <path>/mutate.sh <file> <old> <new> -- <test command...>
+# runs that command instead of the whole `dotnet test` (a filtered dotnet test, or any other runner: python
+# unittest, ...); a dotnet command keeps the build / [FAIL] parsing, any other is killed by a non-zero exit.
+set -uo pipefail
+
+file=$1 old=$2 new=$3
+shift 3
+cmd=(dotnet test --nologo)
+if [[ ${1:-} == -- ]]; then
+ shift
+ cmd=("$@")
+fi
+backup=$(mktemp)
+cp "$file" "$backup"
+trap 'cp "$backup" "$file"; rm -f "$backup"' EXIT
+
+python3 - "$file" "$old" "$new" <<'EOF' || exit 2
+import sys
+path, old, new = sys.argv[1:]
+text = open(path, encoding="utf-8").read()
+n = text.count(old)
+if n != 1:
+ sys.exit(f"snippet matches {n} times in {path} (need exactly 1)")
+open(path, "w", encoding="utf-8").write(text.replace(old, new))
+EOF
+
+result=$("${cmd[@]}" 2>&1)
+status=$?
+if [[ ${cmd[0]} != dotnet ]]; then
+ if (( status != 0 )); then
+ echo "KILLED (exit $status):"; tail -3 <<<"$result"; exit 0
+ fi
+ echo "SURVIVED: no test failed — strengthen the tests"; exit 1
+fi
+if grep -q "error CS" <<<"$result"; then
+ echo "INVALID: mutant doesn't build"; grep -m3 "error CS" <<<"$result"; exit 3
+fi
+if grep -q "Failed!" <<<"$result"; then
+ echo "KILLED by:"; grep "\[FAIL\]" <<<"$result" | sed -E 's/^\[xUnit[^]]*\] +//; s/ \[FAIL\]$//' | sort -u; exit 0
+fi
+echo "SURVIVED: no test failed — strengthen the tests"; exit 1
diff --git a/scripts/test_audio_report.py b/scripts/test_audio_report.py
new file mode 100644
index 0000000..87862ca
--- /dev/null
+++ b/scripts/test_audio_report.py
@@ -0,0 +1,44 @@
+import array
+import math
+import struct
+import sys
+import tempfile
+import unittest
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).resolve().parent))
+import audio_report as ar
+
+
+def wav_bytes(frames, rate=8000, channels=2, data_size=None):
+ raw = frames.tobytes()
+ size = len(raw) if data_size is None else data_size
+ return struct.pack("<4sI4s4sIHHIIHH4sI", b"RIFF", 36 + size, b"WAVE", b"fmt ", 16, 1, channels,
+ rate, rate * channels * 2, channels * 2, 16, b"data", size) + raw
+
+
+class AudioReportTests(unittest.TestCase):
+ def test_reads_to_eof_even_with_placeholder_data_size(self):
+ frames = array.array("h", [1000, -1000] * 8000) # 1 s stereo at 8 kHz
+ with tempfile.TemporaryDirectory() as tmp:
+ path = Path(tmp) / "a.wav"
+ path.write_bytes(wav_bytes(frames, data_size=0)) # killed writer: size never fixed
+ rate, channels, samples = ar.read_wav(path)
+ self.assertEqual((rate, channels, len(samples)), (8000, 2, 16000))
+
+ def test_levels_per_second_of_mono_mix(self):
+ # second 0: full-scale square on both channels (0 dBFS); second 1: silence
+ loud = [16384 if (i // 20) % 2 else -16384 for i in range(8000)]
+ frames = array.array("h", [v for s in loud for v in (s, s)] + [0, 0] * 8000)
+ lv = ar.levels(8000, 2, frames)
+ self.assertEqual(len(lv), 2)
+ self.assertAlmostEqual(lv[0][1], 20 * math.log10(0.5), places=3) # rms of ±0.5 square
+ self.assertEqual(lv[1][1], -120.0)
+
+ def test_opposite_channels_cancel_in_the_mix(self):
+ frames = array.array("h", [8000, -8000] * 8000)
+ self.assertEqual(ar.levels(8000, 2, frames)[0][1], -120.0)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/scripts/test_digest_compare.py b/scripts/test_digest_compare.py
new file mode 100644
index 0000000..400e057
--- /dev/null
+++ b/scripts/test_digest_compare.py
@@ -0,0 +1,42 @@
+import subprocess
+import sys
+import tempfile
+import unittest
+from pathlib import Path
+
+SCRIPT = Path(__file__).with_name("digest_compare.py")
+
+
+def run(a: str, b: str) -> subprocess.CompletedProcess:
+ with tempfile.TemporaryDirectory() as d:
+ pa, pb = Path(d, "a.log"), Path(d, "b.log")
+ pa.write_text(a)
+ pb.write_text(b)
+ return subprocess.run([sys.executable, str(SCRIPT), str(pa), str(pb)], capture_output=True, text=True)
+
+
+class DigestCompareTests(unittest.TestCase):
+ def test_equal(self):
+ r = run("x\ndigest 1 0 00000000000000aa\n", "y\ndigest 1 0 00000000000000aa\n")
+ self.assertEqual(0, r.returncode)
+ self.assertIn("1 digests equal", r.stdout)
+
+ def test_first_difference(self):
+ r = run("digest 1 0 00000000000000aa\ndigest 2 0 00000000000000bb\n",
+ "digest 1 0 00000000000000aa\ndigest 2 0 00000000000000cc\n")
+ self.assertEqual(1, r.returncode)
+ self.assertIn("digest 2 differs", r.stdout)
+
+ def test_count_mismatch(self):
+ r = run("digest 1 0 00000000000000aa\n", "digest 1 0 00000000000000aa\ndigest 2 0 00000000000000bb\n")
+ self.assertEqual(1, r.returncode)
+ self.assertIn("1 vs 2 digests", r.stdout)
+
+ def test_none(self):
+ r = run("x\n", "y\n")
+ self.assertEqual(1, r.returncode)
+ self.assertIn("no digest lines", r.stdout)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/scripts/test_godosa_licenses.py b/scripts/test_godosa_licenses.py
new file mode 100644
index 0000000..40e8387
--- /dev/null
+++ b/scripts/test_godosa_licenses.py
@@ -0,0 +1,116 @@
+"""python3 -m unittest discover -s scripts -p 'test_*.py'"""
+import contextlib
+import io
+import json
+import tempfile
+import unittest
+from pathlib import Path
+
+import godosa_licenses as gl
+
+
+def nuspec(cache: Path, pid: str, ver: str, lic: str | None, copy: str = "© Someone"):
+ d = cache / pid.lower() / ver
+ d.mkdir(parents=True)
+ licel = f'<license type="expression">{lic}</license>' if lic else '<license type="file">LICENSE</license>'
+ (d / f"{pid.lower()}.nuspec").write_text(
+ f'<?xml version="1.0"?><package xmlns="http://schemas.microsoft.com/packaging/2013/05/nuspec.xsd">'
+ f"<metadata><id>{pid}</id><version>{ver}</version>{licel}<copyright>{copy}</copyright></metadata></package>")
+
+
+def project(repo: Path, sln: str, rel: str, pkgs: dict[str, str]):
+ """pkgs: "Id/ver" -> "runtime" | "build" (analyzer, nothing in the output)."""
+ p = repo / rel
+ f = repo / sln
+ line = f'<Project Path="{rel}/{p.name}.csproj" />'
+ old = f.read_text() if f.exists() else ""
+ if line not in old:
+ f.write_text(old + line + "\n")
+ (p / "obj").mkdir(parents=True, exist_ok=True)
+ (p / f"{p.name}.csproj").write_text("<Project/>")
+ targets = {k: ({"type": "package", "runtime": {"lib/net10.0/X.dll": {}}} if kind == "runtime"
+ else {"type": "package", "runtime": {"lib/netstandard2.0/_._": {}}})
+ for k, kind in pkgs.items()}
+ (p / "obj" / "project.assets.json").write_text(json.dumps({
+ "targets": {"net10.0": targets},
+ "libraries": {k: {"type": "package"} for k in pkgs}}))
+
+
+class LicensesTest(unittest.TestCase):
+ def setUp(self):
+ self.tmp = tempfile.TemporaryDirectory()
+ self.repo = Path(self.tmp.name) / "repo"
+ self.cache = Path(self.tmp.name) / "cache"
+ self.repo.mkdir()
+ self.cache.mkdir()
+ self.cfg = gl.Config("Game.slnx", ("src/Game/",), "# Licences\n",
+ extra=[("runtime", "10", "MIT", "© R", "all")],
+ planned=[("Later", "2.0", "BSD|0", "managed", "nuspec")],
+ command="tool")
+ (self.repo / "Game.slnx").write_text("")
+
+ def tearDown(self):
+ self.tmp.cleanup()
+
+ def run_main(self, *argv) -> tuple[int, str]:
+ err = io.StringIO()
+ with contextlib.redirect_stderr(err):
+ code = gl.main(list(argv), self.cfg, self.repo, self.cache)
+ return code, err.getvalue()
+
+ def test_write_then_check_is_clean_and_splits_shipped_from_test(self):
+ nuspec(self.cache, "Pack", "1.0.0", "MIT", "© P")
+ nuspec(self.cache, "xunit", "2.9.2", "Apache-2.0")
+ project(self.repo, "Game.slnx", "src/Game", {"Pack/1.0.0": "runtime"})
+ project(self.repo, "Game.slnx", "src/Game.Tests", {"xunit/2.9.2": "runtime", "Pack/1.0.0": "runtime"})
+ self.assertEqual(self.run_main("--write")[0], 0)
+ self.assertEqual(self.run_main(), (0, ""))
+ md = (self.repo / "licenses.md").read_text()
+ self.assertTrue(md.startswith("# Licences\n"))
+ shipped, rest = md.split("## Not a package")
+ self.assertIn("| Pack | 1.0.0 | MIT | © P | Game | |", shipped)
+ self.assertNotIn("xunit", shipped)
+ self.assertIn("| runtime | 10 | MIT | © R | all |", rest)
+ self.assertIn("| Later | 2.0 | BSD\\|0 | managed | nuspec |", rest)
+ self.assertIn("| xunit | 2.9.2 | Apache-2.0 | Game.Tests |", rest)
+
+ def test_stale_names_the_command(self):
+ self.assertEqual(self.run_main(), (1, "licenses.md is stale: run tool --write\n"))
+
+ def test_copyleft_ships_only_when_allowed(self):
+ nuspec(self.cache, "OpenAL.Native", "1.0.0", "LGPL-2.0-or-later")
+ project(self.repo, "Game.slnx", "src/Game.Tests", {"OpenAL.Native/1.0.0": "runtime"})
+ self.assertEqual(self.run_main("--write")[0], 0) # test project: not shipped
+ project(self.repo, "Game.slnx", "src/Game", {"OpenAL.Native/1.0.0": "runtime"})
+ self.assertEqual(self.run_main("--write"), (1, "NuGet OpenAL.Native 1.0.0: copyleft licence LGPL-2.0-or-later ships\n"))
+ self.cfg.allow_shipped["OpenAL.Native"] = "shared library"
+ self.assertEqual(self.run_main("--write")[0], 0)
+ self.assertIn("| shared library |", (self.repo / "licenses.md").read_text())
+
+ def test_build_only_package_is_not_shipped(self):
+ nuspec(self.cache, "Analyzer", "1.0.0", "GPL-3.0-only")
+ project(self.repo, "Game.slnx", "src/Game", {"Analyzer/1.0.0": "build"})
+ self.assertEqual(self.run_main("--write")[0], 0)
+
+ def test_unknown_licence_fails_until_overridden(self):
+ nuspec(self.cache, "FileLicensed", "1.0.0", None)
+ project(self.repo, "Game.slnx", "src/Game.Tests", {"FileLicensed/1.0.0": "runtime"})
+ self.assertEqual(self.run_main("--write")[0], 1)
+ self.cfg.overrides["FileLicensed"] = "MIT"
+ self.assertEqual(self.run_main("--write")[0], 0)
+
+ def test_projects_come_from_the_configured_solution(self):
+ nuspec(self.cache, "GplLib", "1.0.0", "GPL-3.0-only")
+ project(self.repo, "Other.slnx", "src/Game", {"GplLib/1.0.0": "runtime"})
+ self.assertEqual(self.run_main("--write")[0], 0)
+ self.cfg.solution = "Other.slnx"
+ self.assertEqual(self.run_main("--write")[0], 1)
+
+ def test_missing_assets_fails(self):
+ (self.repo / "src/Game").mkdir(parents=True)
+ (self.repo / "Game.slnx").write_text('<Project Path="src/Game/Game.csproj" />')
+ self.assertEqual(self.run_main("--write")[0], 1)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/scripts/test_thread_cpu.py b/scripts/test_thread_cpu.py
new file mode 100644
index 0000000..f60a2d0
--- /dev/null
+++ b/scripts/test_thread_cpu.py
@@ -0,0 +1,24 @@
+import sys
+import unittest
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).resolve().parent))
+import thread_cpu as tc
+
+
+class ThreadCpuTests(unittest.TestCase):
+ def test_groups_by_name_and_counts_new_threads_from_zero(self):
+ before = {"1": ("main", 100), "2": ("worker", 10)}
+ after = {"1": ("main", 150), "2": ("worker", 20), "3": ("worker", 30)}
+ rows = tc.per_name(before, after, seconds=1.0, hz=100)
+ self.assertEqual(rows[0], ("main", 50.0, 1))
+ self.assertEqual(rows[1], ("worker", 40.0, 2)) # 10 + 30 ticks
+
+ def test_samples_this_process(self):
+ import os
+ threads = tc.sample(os.getpid())
+ self.assertIn(str(os.getpid()), threads)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/scripts/test_thread_instr.py b/scripts/test_thread_instr.py
new file mode 100644
index 0000000..d23e440
--- /dev/null
+++ b/scripts/test_thread_instr.py
@@ -0,0 +1,21 @@
+import sys
+import unittest
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).resolve().parent))
+import thread_instr as ti
+
+
+class ThreadInstrTests(unittest.TestCase):
+ def test_sums_threads_by_name(self):
+ text = "\n".join([
+ "Game.Desk-100,5000,,instructions:u,100,100.00,,",
+ "audio-101,300,,instructions:u,100,100.00,,",
+ "audio-102,200,,instructions:u,100,100.00,,",
+ "gl0-103,<not counted>,,instructions:u,0,0.00,,",
+ ])
+ self.assertEqual(ti.parse(text), {"Game.Desk": 5000, "audio": 500})
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/scripts/thread_cpu.py b/scripts/thread_cpu.py
new file mode 100755
index 0000000..f22ad51
--- /dev/null
+++ b/scripts/thread_cpu.py
@@ -0,0 +1,60 @@
+#!/usr/bin/env python3
+"""Per-thread CPU of a process over an interval, grouped by thread name (Linux /proc).
+Shows where a smoke run's CPU goes: UI/render thread, audio thread, OpenAL mixer,
+compositor, GC... (smoke_run.sh step `threads:<s>`).
+
+Usage: scripts/thread_cpu.py PID SECONDS [--top N]
+"""
+import argparse
+import os
+import time
+from collections import defaultdict
+
+
+def sample(pid):
+ """{tid: (name, cpu_ticks)} for every live thread of pid."""
+ out = {}
+ base = f"/proc/{pid}/task"
+ for tid in os.listdir(base):
+ try:
+ with open(f"{base}/{tid}/stat") as f:
+ stat = f.read()
+ except OSError:
+ continue # thread ended
+ name = stat[stat.index("(") + 1: stat.rindex(")")]
+ fields = stat[stat.rindex(")") + 2:].split()
+ out[tid] = (name, int(fields[11]) + int(fields[12])) # utime + stime
+ return out
+
+
+def per_name(before, after, seconds, hz):
+ """[(name, percent of one core, thread count)] busiest first; threads that started
+ during the interval count from zero."""
+ total = defaultdict(float)
+ count = defaultdict(int)
+ for tid, (name, ticks) in after.items():
+ start = before.get(tid, (name, 0))[1]
+ total[name] += (ticks - start) * 100.0 / hz / seconds
+ count[name] += 1
+ return sorted(((n, total[n], count[n]) for n in total), key=lambda r: -r[1])
+
+
+def main():
+ ap = argparse.ArgumentParser()
+ ap.add_argument("pid", type=int)
+ ap.add_argument("seconds", type=float)
+ ap.add_argument("--top", type=int, default=10)
+ args = ap.parse_args()
+ hz = os.sysconf("SC_CLK_TCK")
+ a = sample(args.pid)
+ time.sleep(args.seconds)
+ b = sample(args.pid)
+ rows = per_name(a, b, args.seconds, hz)
+ print(f"threads: {sum(r[1] for r in rows):.1f}% total over {args.seconds:g}s")
+ for name, pct, n in rows[: args.top]:
+ if pct >= 0.05:
+ print(f" {pct:5.1f}% {name}" + (f" ×{n}" if n > 1 else ""))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/thread_instr.py b/scripts/thread_instr.py
new file mode 100755
index 0000000..846d27f
--- /dev/null
+++ b/scripts/thread_instr.py
@@ -0,0 +1,42 @@
+#!/usr/bin/env python3
+"""User-space instructions per thread name over an interval (perf stat --per-thread) —
+a load-independent cost measure: CPU % swings with other work on the machine and
+clock speed, retired instructions don't (smoke_run.sh step `instr:<s>`).
+
+Usage: scripts/thread_instr.py PID SECONDS
+"""
+import re
+import subprocess
+import sys
+from collections import defaultdict
+
+
+def parse(text):
+ """{thread name: instructions} from `perf stat --per-thread -x,` output."""
+ out = defaultdict(int)
+ for line in text.splitlines():
+ parts = line.split(",")
+ if len(parts) < 4 or "instructions" not in parts[3]:
+ continue
+ try:
+ count = int(parts[1])
+ except ValueError:
+ continue # <not counted>
+ out[re.sub(r"-\d+$", "", parts[0].strip())] += count
+ return out
+
+
+def main():
+ pid, seconds = sys.argv[1], float(sys.argv[2])
+ res = subprocess.run(["perf", "stat", "--per-thread", "-x,", "-e", "instructions:u", "-p", pid,
+ "--", "sleep", str(seconds)], capture_output=True, text=True)
+ counts = parse(res.stderr)
+ total = sum(counts.values())
+ print(f"instr: {total / seconds / 1e6:.0f} M/s total over {seconds:g}s")
+ for name, n in sorted(counts.items(), key=lambda kv: -kv[1])[:8]:
+ if n / seconds >= 1e6:
+ print(f" {n / seconds / 1e6:7.0f} M/s {name}")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/src/Godosa.Audio/Godosa.Audio.csproj b/src/Godosa.Audio/Godosa.Audio.csproj
new file mode 100644
index 0000000..a478730
--- /dev/null
+++ b/src/Godosa.Audio/Godosa.Audio.csproj
@@ -0,0 +1,18 @@
+<Project Sdk="Microsoft.NET.Sdk">
+ <PropertyGroup>
+ <AllowUnsafeBlocks>true</AllowUnsafeBlocks>
+ </PropertyGroup>
+ <ItemGroup>
+ <Using Include="Godosa.Core.Audio" />
+ <Using Include="Godosa.Core.Diagnostics" />
+ </ItemGroup>
+ <ItemGroup>
+ <PackageReference Include="Silk.NET.OpenAL" />
+ <PackageReference Include="Silk.NET.OpenAL.Extensions.Creative" />
+ <PackageReference Include="Silk.NET.OpenAL.Extensions.Soft" />
+ <PackageReference Include="Silk.NET.OpenAL.Soft.Native" />
+ </ItemGroup>
+ <ItemGroup>
+ <ProjectReference Include="../Godosa.Core/Godosa.Core.csproj" />
+ </ItemGroup>
+</Project>
diff --git a/src/Godosa.Audio/OpenAlAudio.Reverb.cs b/src/Godosa.Audio/OpenAlAudio.Reverb.cs
new file mode 100644
index 0000000..b93e74a
--- /dev/null
+++ b/src/Godosa.Audio/OpenAlAudio.Reverb.cs
@@ -0,0 +1,83 @@
+using Silk.NET.OpenAL.Extensions.Creative;
+
+namespace Godosa.Audio;
+
+/// <summary>One EFX reverb for the region (audio spec §2): positioned sources send to it, 2D
+/// ones (beds, music, interface) don't. Air absorption dulls distant sounds.</summary>
+public sealed unsafe partial class OpenAlAudio
+{
+ private EffectExtension? _efx;
+ private uint _effect, _effectSlot;
+ private ReverbPreset? _reverbApplied;
+ private ReverbPreset? _reverbWanted;
+ private bool _reverbInitialised;
+
+ /// <summary>The current region's reverb, or null for none (UI thread).</summary>
+ public ReverbPreset? Reverb
+ {
+ set { lock (_poseLock) _reverbWanted = value; }
+ }
+
+ private void InitReverb()
+ {
+ if (!_al!.TryGetExtension(out _efx)) _efx = null;
+ if (_efx is null)
+ {
+ if (EngineDebug.Audio) EngineDebug.Write("audio", "no EFX: no reverb");
+ return;
+ }
+ _effect = _efx.GenEffects(1)[0];
+ _efx.SetEffectProperty(_effect, EffectInteger.EffectType, (int)EffectType.Reverb);
+ _effectSlot = _efx.GenAuxiliaryEffectSlots(1)[0];
+ _reverbApplied = null;
+ _reverbInitialised = false;
+ }
+
+ private void UpdateReverb()
+ {
+ if (_efx is null) return;
+ ReverbPreset? wanted;
+ lock (_poseLock) wanted = _reverbWanted;
+ if (_reverbInitialised && _reverbApplied == wanted) return;
+ _reverbInitialised = true;
+ _reverbApplied = wanted;
+ if (wanted is not { } p)
+ {
+ // No effect in the slot: sends to it are dropped and the mixer runs no reverb.
+ _efx.SetAuxiliaryEffectSlotProperty(_effectSlot, EffectSlotInteger.Effect, 0);
+ if (EngineDebug.Audio) EngineDebug.Write("audio", "reverb off");
+ return;
+ }
+ _efx.SetEffectProperty(_effect, EffectFloat.ReverbDensity, p.Density);
+ _efx.SetEffectProperty(_effect, EffectFloat.ReverbDiffusion, p.Diffusion);
+ _efx.SetEffectProperty(_effect, EffectFloat.ReverbGain, p.Gain);
+ _efx.SetEffectProperty(_effect, EffectFloat.ReverbGainHF, p.GainHF);
+ _efx.SetEffectProperty(_effect, EffectFloat.ReverbDecayTime, p.DecayTime);
+ _efx.SetEffectProperty(_effect, EffectFloat.ReverbDecayHFRatio, p.DecayHFRatio);
+ _efx.SetEffectProperty(_effect, EffectFloat.ReverbReflectionsGain, p.ReflectionsGain);
+ _efx.SetEffectProperty(_effect, EffectFloat.ReverbReflectionsDelay, p.ReflectionsDelay);
+ _efx.SetEffectProperty(_effect, EffectFloat.ReverbLateReverbGain, p.LateReverbGain);
+ _efx.SetEffectProperty(_effect, EffectFloat.ReverbLateReverbDelay, p.LateReverbDelay);
+ _efx.SetEffectProperty(_effect, EffectFloat.ReverbAirAbsorptionGainHF, p.AirAbsorptionGainHF);
+ _efx.SetEffectProperty(_effect, EffectFloat.ReverbRoomRolloffFactor, p.RoomRolloffFactor);
+ _efx.SetEffectProperty(_effect, EffectInteger.ReverbDecayHFLimit, p.DecayHFLimit ? 1 : 0);
+ _efx.SetAuxiliaryEffectSlotProperty(_effectSlot, EffectSlotInteger.Effect, (int)_effect); // re-attach applies the change
+ if (EngineDebug.Audio) EngineDebug.Write("audio", $"reverb {(p == ReverbPreset.Forest ? "forest" : p == ReverbPreset.Cave ? "cave" : "plain")}");
+ }
+
+ private void SendToReverb(uint source, bool positioned)
+ {
+ if (_efx is null) return;
+ _efx.SetSourceProperty(source, EFXSourceInteger3.AuxiliarySendFilter, positioned ? (int)_effectSlot : 0, 0, 0);
+ _efx.SetSourceProperty(source, EFXSourceFloat.AirAbsorptionFactor, positioned ? 1f : 0f);
+ }
+
+ private void ReleaseReverb()
+ {
+ if (_efx is null) return;
+ _efx.SetAuxiliaryEffectSlotProperty(_effectSlot, EffectSlotInteger.Effect, 0);
+ _efx.DeleteAuxiliaryEffectSlot(_effectSlot);
+ _efx.DeleteEffect(_effect);
+ _efx = null;
+ }
+}
diff --git a/src/Godosa.Audio/OpenAlAudio.Streams.cs b/src/Godosa.Audio/OpenAlAudio.Streams.cs
new file mode 100644
index 0000000..435c058
--- /dev/null
+++ b/src/Godosa.Audio/OpenAlAudio.Streams.cs
@@ -0,0 +1,119 @@
+using System.Collections.Concurrent;
+using System.Collections.Generic;
+using System.Threading;
+using Silk.NET.OpenAL;
+
+namespace Godosa.Audio;
+
+/// <summary>Endless stereo streams (ambience beds, music — audio spec §5–§6): each holds one
+/// source with a queue of buffers the audio thread refills from its generator.</summary>
+public sealed unsafe partial class OpenAlAudio
+{
+ private const int StreamBuffers = 4, StreamFrames = 2048; // ~190 ms queued
+
+ public sealed class StreamHandle
+ {
+ internal StreamHandle(IStereoStream source, int channel) => (Source, Channel) = (source, channel);
+ internal IStereoStream Source { get; }
+ internal int Channel { get; }
+ internal int Slot = -1;
+ internal uint[] Buffers = System.Array.Empty<uint>();
+ private int _gainBits = System.BitConverter.SingleToInt32Bits(1f); // written by the UI thread
+
+ public float Gain
+ {
+ get => System.BitConverter.Int32BitsToSingle(Volatile.Read(ref _gainBits));
+ set => Volatile.Write(ref _gainBits, System.BitConverter.SingleToInt32Bits(value));
+ }
+ }
+
+ private readonly bool[] _streamSlot = new bool[SourceCount];
+ private readonly List<StreamHandle> _streams = new(); // audio thread only
+ private readonly ConcurrentQueue<(StreamHandle Stream, bool Add)> _streamCommands = new();
+ private readonly float[] _streamMix = new float[StreamFrames * 2];
+ private readonly short[] _streamPcm = new short[StreamFrames * 2];
+
+ public StreamHandle AddStream(IStereoStream source, int channel, float gain = 1f)
+ {
+ var handle = new StreamHandle(source, channel) { Gain = gain };
+ _streamCommands.Enqueue((handle, true));
+ return handle;
+ }
+
+ public void RemoveStream(StreamHandle handle) => _streamCommands.Enqueue((handle, false));
+
+ private void UpdateStreams(AudioPauseState state)
+ {
+ var al = _al!;
+ while (_streamCommands.TryDequeue(out var command))
+ {
+ if (command.Add) _streams.Add(command.Stream);
+ else if (_streams.Remove(command.Stream)) Release(command.Stream);
+ }
+ foreach (var s in _streams)
+ {
+ if (s.Slot < 0 && !Claim(s)) continue;
+ uint source = _sources[s.Slot];
+ _gain[s.Slot] = s.Gain;
+ SetGain(s.Slot, s.Gain * _mix.ChannelGain(s.Channel, state));
+ al.GetSourceProperty(source, GetSourceInteger.BuffersProcessed, out int processed);
+ for (int i = 0; i < processed; i++)
+ {
+ uint buffer;
+ al.SourceUnqueueBuffers(source, 1, &buffer);
+ Fill(s, buffer);
+ al.SourceQueueBuffers(source, 1, &buffer);
+ }
+ if (SourceState(source) != Silk.NET.OpenAL.SourceState.Playing) al.SourcePlay(source); // start, or recover from a starve
+ }
+ }
+
+ private bool Claim(StreamHandle s)
+ {
+ var al = _al!;
+ int slot = FreeSource();
+ if (slot < 0) return false;
+ s.Slot = slot;
+ _streamSlot[slot] = true;
+ _channel[slot] = s.Channel;
+ _appliedGain[slot] = -1f;
+ _pausedByUs[slot] = false;
+ uint source = _sources[slot];
+ al.SetSourceProperty(source, SourceInteger.Buffer, 0);
+ al.SetSourceProperty(source, SourceBoolean.Looping, false);
+ al.SetSourceProperty(source, SourceBoolean.SourceRelative, true);
+ al.SetSourceProperty(source, SourceVector3.Position, System.Numerics.Vector3.Zero);
+ al.SetSourceProperty(source, SourceFloat.Pitch, 1f);
+ SendToReverb(source, positioned: false);
+ s.Buffers = al.GenBuffers(StreamBuffers);
+ foreach (uint b in s.Buffers) Fill(s, b);
+ fixed (uint* ids = s.Buffers) al.SourceQueueBuffers(source, StreamBuffers, ids);
+ if (EngineDebug.Audio) EngineDebug.Write("audio", $"stream {s.Source.GetType().Name} {s.Channel} on source {slot}");
+ return true;
+ }
+
+ private void Fill(StreamHandle s, uint buffer)
+ {
+ s.Source.Render(_streamMix, StreamFrames);
+ for (int i = 0; i < _streamMix.Length; i++) _streamPcm[i] = Pcm.ToShort(_streamMix[i]);
+ _al!.BufferData(buffer, BufferFormat.Stereo16, _streamPcm, Pcm.SampleRate);
+ }
+
+ private void Release(StreamHandle s)
+ {
+ if (s.Slot < 0) return;
+ var al = _al!;
+ uint source = _sources[s.Slot];
+ al.SourceStop(source);
+ al.SetSourceProperty(source, SourceInteger.Buffer, 0); // detaches the queue
+ al.DeleteBuffers(s.Buffers);
+ _streamSlot[s.Slot] = false;
+ s.Slot = -1;
+ }
+
+ /// <summary>Before the context goes (HRTF change): streams re-claim a source afterwards.</summary>
+ private void ReleaseStreams()
+ {
+ foreach (var s in _streams) Release(s);
+ }
+}
diff --git a/src/Godosa.Audio/OpenAlAudio.cs b/src/Godosa.Audio/OpenAlAudio.cs
new file mode 100644
index 0000000..7a0d59b
--- /dev/null
+++ b/src/Godosa.Audio/OpenAlAudio.cs
@@ -0,0 +1,360 @@
+using System;
+using System.Collections.Concurrent;
+using System.Collections.Generic;
+using System.Numerics;
+using System.Threading;
+using Silk.NET.OpenAL;
+
+namespace Godosa.Audio;
+
+/// <summary>
+/// OpenAL Soft output on its own thread (every AL call happens there; other threads post
+/// sounds, loops, streams and the listener pose). Mix policy comes from the game's
+/// <see cref="IAudioMix"/>. No device or any AL failure → silent (logged, never throws).
+/// </summary>
+public sealed unsafe partial class OpenAlAudio : IDisposable
+{
+ private const int SourceCount = 32;
+ private const int MaxQueued = 64;
+ private const int UpdateMs = 10;
+ public const float ReferenceDistance = 2f, MaxDistance = 60f;
+
+ // ALC_SOFT_HRTF (not wrapped by Silk.NET): context attribute + status query.
+ private const int AlcHrtfSoft = 0x1992, AlcHrtfStatusSoft = 0x1993, AlcDontCareSoft = 0x0002;
+ // Mix at our buffers' rate: no per-source resampling (~15 % of the mixer, measured).
+ private const int AlcFrequency = 0x1007;
+
+ public static OpenAlAudio? Current { get; private set; }
+
+ /// <summary>A one-shot sound. <paramref name="Render"/> runs once per <paramref name="Key"/>
+ /// (the buffer is cached); no position = heard in the head (2D). Falloff: inverse distance,
+ /// gain = Reference / distance, distance clamped to [Reference, Max] (world units of the game).
+ /// <paramref name="Falloff"/> (distance → gain factor, audio thread) replaces that curve for a positioned sound:
+ /// OpenAL only pans it, the factor is applied every update (the game's own attenuation).</summary>
+ public readonly record struct Sound(string Key, Func<PcmClip> Render, int Channel,
+ Vector3? Position = null, float Gain = 1f, float Pitch = 1f, float Reference = ReferenceDistance, float Max = MaxDistance,
+ Func<float, float>? Falloff = null);
+
+ /// <summary>A looping sound that follows something (a projectile in flight). Posted as the
+ /// full set every frame: new keys start, missing keys stop.</summary>
+ public readonly record struct LoopEmitter(object Key, Sound Sound, Vector3 Position, Vector3 Velocity);
+
+ private readonly IAudioMix _mix;
+ private readonly object _loopLock = new();
+ private List<LoopEmitter> _loopsPosted = new(), _loopsWorking = new();
+ private readonly Dictionary<object, int> _loopSlots = new(ReferenceEqualityComparer.Instance);
+ private readonly HashSet<object> _loopSeen = new(ReferenceEqualityComparer.Instance);
+ private readonly List<object> _loopGone = new();
+ private readonly ConcurrentQueue<Sound> _queue = new();
+ private readonly ConcurrentQueue<int> _stops = new();
+ private readonly Thread _thread;
+ private readonly object _poseLock = new();
+ private volatile bool _running = true;
+ private Vector3 _listenerPosition, _listenerForward = -Vector3.UnitZ, _listenerUp = Vector3.UnitY;
+ private int _state; // AudioPauseState, written by the UI thread
+
+ private ALContext? _alc;
+ private AL? _al;
+ private Device* _device;
+ private Context* _context;
+ private HrtfMode _hrtfApplied;
+ private readonly uint[] _sources = new uint[SourceCount];
+ private readonly int[] _channel = new int[SourceCount];
+ private readonly float[] _gain = new float[SourceCount];
+ private readonly float[] _appliedGain = new float[SourceCount]; // last gain sent to AL (skip repeats)
+ private readonly Func<float, float>?[] _falloff = new Func<float, float>?[SourceCount]; // null = OpenAL's curve
+ private readonly Vector3[] _position = new Vector3[SourceCount];
+ private Vector3 _appliedPosition, _appliedForward;
+ private readonly bool[] _pausedByUs = new bool[SourceCount];
+ private readonly Dictionary<string, uint> _buffers = new();
+
+ public static OpenAlAudio Start(IAudioMix mix) => Current = new OpenAlAudio(mix);
+
+ private OpenAlAudio(IAudioMix mix)
+ {
+ _mix = mix;
+ _thread = new Thread(Run) { IsBackground = true, Name = "audio" };
+ _thread.Start();
+ }
+
+ public AudioPauseState State
+ {
+ set => Volatile.Write(ref _state, (int)value);
+ }
+
+ public void Play(in Sound sound)
+ {
+ if (_queue.Count < MaxQueued) _queue.Enqueue(sound);
+ }
+
+ /// <summary>Stops the one-shot sounds playing on <paramref name="channel"/> (loops and streams keep going). Applied on
+ /// the audio thread before any sound queued after this call starts, so stop-then-play cuts cleanly (a voice line
+ /// replaced by the next one).</summary>
+ public void StopChannel(int channel) => _stops.Enqueue(channel);
+
+ /// <summary>Replaces the set of loop emitters (UI thread, every rendered frame).</summary>
+ public void SetLoops(List<LoopEmitter> loops)
+ {
+ lock (_loopLock)
+ {
+ _loopsPosted.Clear();
+ _loopsPosted.AddRange(loops);
+ }
+ }
+
+ /// <summary>Listener = the camera (UI thread, every rendered frame).</summary>
+ public void SetListener(Vector3 position, Vector3 forward, Vector3 up)
+ {
+ lock (_poseLock) (_listenerPosition, _listenerForward, _listenerUp) = (position, forward, up);
+ }
+
+ public void Dispose()
+ {
+ _running = false;
+ _thread.Join(500);
+ if (ReferenceEquals(Current, this)) Current = null;
+ }
+
+ private void Run()
+ {
+ try
+ {
+ Open();
+ }
+ catch (Exception e)
+ {
+ Console.Error.WriteLine($"audio: disabled ({e.Message})");
+ Close();
+ }
+ while (_running)
+ {
+ try
+ {
+ if (_al is not null) Update();
+ else _queue.Clear();
+ }
+ catch (Exception e)
+ {
+ Console.Error.WriteLine($"audio: disabled after error ({e.Message})");
+ Close();
+ }
+ Thread.Sleep(UpdateMs);
+ }
+ Close();
+ }
+
+ private void Open()
+ {
+ _alc = ALContext.GetApi(soft: true);
+ _al = AL.GetApi(soft: true);
+ _device = _alc.OpenDevice("");
+ if (_device == null) throw new InvalidOperationException("no audio device");
+ CreateContext(_mix.Hrtf);
+ }
+
+ private void CreateContext(HrtfMode mode)
+ {
+ int hrtf = mode switch { HrtfMode.On => 1, HrtfMode.Off => 0, _ => AlcDontCareSoft };
+ int* attributes = stackalloc int[] { AlcHrtfSoft, hrtf, AlcFrequency, Pcm.SampleRate, 0 };
+ _context = _alc!.CreateContext(_device, attributes);
+ if (_context == null) throw new InvalidOperationException("no audio context");
+ _alc.MakeContextCurrent(_context);
+ _hrtfApplied = mode;
+
+ var al = _al!;
+ al.DistanceModel(DistanceModel.InverseDistanceClamped);
+ fixed (uint* ids = _sources) al.GenSources(SourceCount, ids);
+ foreach (uint s in _sources)
+ {
+ al.SetSourceProperty(s, SourceFloat.ReferenceDistance, ReferenceDistance);
+ al.SetSourceProperty(s, SourceFloat.MaxDistance, MaxDistance);
+ al.SetSourceProperty(s, SourceFloat.RolloffFactor, 1f);
+ }
+ Array.Clear(_pausedByUs);
+ Array.Fill(_appliedGain, -1f);
+ (_appliedPosition, _appliedForward) = (new Vector3(float.NaN), Vector3.Zero);
+ InitReverb();
+
+ int status = -1;
+ _alc.GetContextProperty(_device, (GetContextInteger)AlcHrtfStatusSoft, 1, &status);
+ if (EngineDebug.Audio)
+ EngineDebug.Write("audio", $"device '{_alc.GetContextProperty(_device, GetContextString.DeviceSpecifier)}' hrtf request {mode} status {status}");
+ }
+
+ /// <summary>HRTF only changes on a new context; buffers belong to the device and stay.</summary>
+ private void DestroyContext()
+ {
+ if (_context == null) return;
+ ReleaseStreams();
+ ReleaseReverb();
+ fixed (uint* ids = _sources) _al!.DeleteSources(SourceCount, ids);
+ _loopSlots.Clear();
+ _alc!.MakeContextCurrent(null);
+ _alc.DestroyContext(_context);
+ _context = null;
+ }
+
+ private void Update()
+ {
+ var al = _al!;
+ if (_mix.Hrtf != _hrtfApplied)
+ {
+ DestroyContext();
+ CreateContext(_mix.Hrtf);
+ }
+
+ var state = (AudioPauseState)Volatile.Read(ref _state);
+ Vector3 position, forward, up;
+ lock (_poseLock) (position, forward, up) = (_listenerPosition, _listenerForward, _listenerUp);
+ if (position != _appliedPosition || forward != _appliedForward)
+ {
+ al.SetListenerProperty(ListenerVector3.Position, position);
+ float* orientation = stackalloc float[] { forward.X, forward.Y, forward.Z, up.X, up.Y, up.Z };
+ al.SetListenerProperty(ListenerFloatArray.Orientation, orientation);
+ (_appliedPosition, _appliedForward) = (position, forward);
+ }
+
+ bool pauseEffects = _mix.WorldPaused(state);
+ for (int i = 0; i < SourceCount; i++)
+ {
+ var s = SourceState(_sources[i]);
+ if (s is not (Silk.NET.OpenAL.SourceState.Playing or Silk.NET.OpenAL.SourceState.Paused)) continue;
+ SetGain(i, _gain[i] * Attenuation(i, position) * _mix.ChannelGain(_channel[i], state));
+ if (!_mix.Pausable(_channel[i])) continue;
+ if (pauseEffects && s == Silk.NET.OpenAL.SourceState.Playing)
+ {
+ al.SourcePause(_sources[i]);
+ _pausedByUs[i] = true;
+ }
+ else if (!pauseEffects && _pausedByUs[i])
+ {
+ al.SourcePlay(_sources[i]);
+ _pausedByUs[i] = false;
+ }
+ }
+
+ while (_stops.TryDequeue(out int stop))
+ for (int i = 0; i < SourceCount; i++)
+ if (_channel[i] == stop && !_streamSlot[i] && !_loopSlots.ContainsValue(i))
+ al.SourceStop(_sources[i]);
+ while (_queue.TryDequeue(out var sound))
+ {
+ if (pauseEffects && _mix.Pausable(sound.Channel)) continue; // a paused world makes no new sounds
+ int slot = FreeSource();
+ if (slot < 0) break;
+ Start(slot, sound, state);
+ }
+ UpdateLoops(state);
+ UpdateStreams(state);
+ UpdateReverb();
+ }
+
+ private void UpdateLoops(AudioPauseState state)
+ {
+ var al = _al!;
+ lock (_loopLock) (_loopsPosted, _loopsWorking) = (_loopsWorking, _loopsPosted);
+ _loopSeen.Clear();
+ foreach (var loop in _loopsWorking)
+ {
+ _loopSeen.Add(loop.Key);
+ if (!_loopSlots.TryGetValue(loop.Key, out int slot))
+ {
+ if (_mix.WorldPaused(state) && _mix.Pausable(loop.Sound.Channel)) continue;
+ slot = FreeSource();
+ if (slot < 0) continue;
+ Start(slot, loop.Sound, state, looping: true);
+ _loopSlots[loop.Key] = slot;
+ }
+ _gain[slot] = loop.Sound.Gain; // a fleeing critter gets louder and higher
+ al.SetSourceProperty(_sources[slot], SourceFloat.Pitch, loop.Sound.Pitch);
+ al.SetSourceProperty(_sources[slot], SourceVector3.Position, loop.Position);
+ _position[slot] = loop.Position;
+ al.SetSourceProperty(_sources[slot], SourceVector3.Velocity, loop.Velocity); // doppler
+ }
+ _loopGone.Clear();
+ foreach (var (key, slot) in _loopSlots)
+ if (!_loopSeen.Contains(key))
+ {
+ al.SourceStop(_sources[slot]);
+ al.SetSourceProperty(_sources[slot], SourceBoolean.Looping, false);
+ al.SetSourceProperty(_sources[slot], SourceVector3.Velocity, Vector3.Zero);
+ _loopGone.Add(key);
+ }
+ foreach (var key in _loopGone) _loopSlots.Remove(key);
+ }
+
+ /// <summary>The slot's own falloff at the listener; 1 without one.</summary>
+ private float Attenuation(int slot, Vector3 listener) =>
+ _falloff[slot] is { } f ? f(Vector3.Distance(_position[slot], listener)) : 1f;
+
+ private void SetGain(int slot, float gain)
+ {
+ if (gain == _appliedGain[slot]) return;
+ _al!.SetSourceProperty(_sources[slot], SourceFloat.Gain, gain);
+ _appliedGain[slot] = gain;
+ }
+
+ private Silk.NET.OpenAL.SourceState SourceState(uint source)
+ {
+ _al!.GetSourceProperty(source, GetSourceInteger.SourceState, out int s);
+ return (Silk.NET.OpenAL.SourceState)s;
+ }
+
+ private int FreeSource()
+ {
+ for (int i = 0; i < SourceCount; i++)
+ {
+ if (_streamSlot[i]) continue; // a starved stream stops, but its source stays taken
+ var s = SourceState(_sources[i]);
+ if (s is Silk.NET.OpenAL.SourceState.Initial or Silk.NET.OpenAL.SourceState.Stopped) return i;
+ }
+ return -1;
+ }
+
+ private void Start(int slot, in Sound sound, AudioPauseState state, bool looping = false)
+ {
+ var al = _al!;
+ uint source = _sources[slot];
+ if (!_buffers.TryGetValue(sound.Key, out uint buffer))
+ {
+ buffer = al.GenBuffer();
+ var clip = sound.Render();
+ al.BufferData(buffer, BufferFormat.Mono16, clip.Samples, clip.SampleRate);
+ _buffers[sound.Key] = buffer;
+ }
+ _channel[slot] = sound.Channel;
+ _gain[slot] = sound.Gain;
+ _pausedByUs[slot] = false;
+ al.SetSourceProperty(source, SourceInteger.Buffer, buffer);
+ al.SetSourceProperty(source, SourceFloat.Pitch, sound.Pitch);
+ al.SetSourceProperty(source, SourceFloat.ReferenceDistance, sound.Reference);
+ al.SetSourceProperty(source, SourceFloat.MaxDistance, sound.Max);
+ bool positioned = sound.Position is not null;
+ _falloff[slot] = positioned ? sound.Falloff : null;
+ _position[slot] = sound.Position ?? Vector3.Zero;
+ al.SetSourceProperty(source, SourceFloat.RolloffFactor, _falloff[slot] is null ? 1f : 0f);
+ _appliedGain[slot] = -1f; // force
+ SetGain(slot, sound.Gain * Attenuation(slot, _appliedPosition) * _mix.ChannelGain(sound.Channel, state));
+ al.SetSourceProperty(source, SourceBoolean.SourceRelative, !positioned);
+ var at = sound.Position ?? Vector3.Zero;
+ al.SetSourceProperty(source, SourceVector3.Position, at);
+ al.SetSourceProperty(source, SourceBoolean.Looping, looping);
+ SendToReverb(source, positioned);
+ al.SourcePlay(source);
+ if (EngineDebug.Audio) EngineDebug.Write("audio", $"play {sound.Key} {_mix.ChannelName(sound.Channel)} {(positioned ? at.ToString("F1", null) : "2D")}");
+ }
+
+ private void Close()
+ {
+ if (_al is not null && _alc is not null && _device != null)
+ {
+ if (_context != null)
+ foreach (uint b in _buffers.Values) _al.DeleteBuffer(b); // AL calls need the context
+ _buffers.Clear();
+ DestroyContext();
+ _alc.CloseDevice(_device);
+ }
+ _device = null;
+ _al = null;
+ }
+}
diff --git a/src/Godosa.Core.Tests/Audio/AudioMixTests.cs b/src/Godosa.Core.Tests/Audio/AudioMixTests.cs
new file mode 100644
index 0000000..93a0bed
--- /dev/null
+++ b/src/Godosa.Core.Tests/Audio/AudioMixTests.cs
@@ -0,0 +1,22 @@
+using Godosa.Core.Audio;
+using Xunit;
+
+namespace Godosa.Core.Tests.Audio;
+
+public class AudioMixTests
+{
+ private sealed class Plain : IAudioMix
+ {
+ public HrtfMode Hrtf => default;
+ public float ChannelGain(int channel, AudioPauseState state) => 1f;
+ public bool Pausable(int channel) => true;
+ public bool WorldPaused(AudioPauseState state) => false;
+ }
+
+ [Fact]
+ public void ChannelName_DefaultsToTheNumber()
+ {
+ IAudioMix mix = new Plain();
+ Assert.Equal("2", mix.ChannelName(2));
+ }
+}
diff --git a/src/Godosa.Core.Tests/Audio/Mp3Tests.cs b/src/Godosa.Core.Tests/Audio/Mp3Tests.cs
new file mode 100644
index 0000000..9cdca1e
--- /dev/null
+++ b/src/Godosa.Core.Tests/Audio/Mp3Tests.cs
@@ -0,0 +1,90 @@
+using System;
+using Godosa.Core.Audio;
+using Xunit;
+
+namespace Godosa.Core.Tests.Audio;
+
+/// <summary>Fixtures: our own sine tones (ffmpeg lavfi sine, amplitude 1/8, 0.25 s) encoded with lame 3.100: mono
+/// 440 Hz at 22050 Hz (64 kbit/s, --resample 22.05); stereo 440 Hz left / 880 Hz right at 44100 Hz (128 kbit/s,
+/// --resample 44.1). Oracle: a sine of f Hz crosses zero 2·f times a second; counted in a 0.1 s window inside the tone
+/// (lame's encoder delay, ≈ 1105 samples, and frame padding are near-silent and cross zero at random).</summary>
+public class Mp3Tests
+{
+ private static readonly byte[] Mono = Convert.FromBase64String("//OAxAAAAAAAAAAAAEluZm8AAAAPAAAADAAACpsAFRUVFRUVFRUqKioqKioqKkBAQEBAQEBAVVVVVVVVVVVVampqampqamqAgICAgICAgJWVlZWVlZWVlaqqqqqqqqqqwMDAwMDAwMDV1dXV1dXV1dXq6urq6urq6v//////////AAAAOUxBTUUzLjEwMAFuAAAAACwcAAAUQCQDNyIAAEAAAAqbZuNtUQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP/zgMQAGXhihD9PGAAL1tu36HmmaZpmmh7mwIYQcQsB3AIwDsFWOM62d48ePHjwEATB8Hwf5zg/4YlPfynn+U8/ynv6OCAPg+8HwcBAMKDAPg/LgQEOD79Hg+D4Pg+DgIAgCAYB8HwfB8CAgCAYjwfP0TgwNcGSMHRBihkACAAK6gCMA0AjTEgxJYwkpCdMCGApDGbhG4wkwAOMQqUPjLEQmAwk0JoMx5AuDBQADYxfUKqMB8AhwNBPMDcTFA140RjiJkV4GMzUBmsxAZjORMn/84LEXT8LcigBn6gAoZbgYUHYGPB2BjojgYHERk6K/AxKIgMSiwAAJgYWC4GFg6ySqPXANBAGDQYBg0IALAMDAgFAwMCQbpL/+ygMAgADAICDZQbBoNg0OiCyILIhHLf/6sMUhgEUkGrg1cMaIKiCo5QoIWUQ0XL///t8XKQ4c4gxRHNJ0yIsXjpMnlF5Pda//X/1f0TjTFKUkUSZMjIhxiXSAlIvEC6v9v01VYyqiYAYAEgQAHCwCoYGyB6GB7iehg7oeUYfeDIGF7joBlOJHP/zgsQkLBPOBAHfGAEH1Hq4ZlbBCKDhzgwJkB+MENBNjAoQC0wGoBZMAzAODAAwBkBAHMt/Hmtk1QqS22FpeNlWPioRu2FEE3FKmZkk/lBPhAunsxZN7W6tv/63ue+RHzyIH8wxRFzelzU56kmdaH+Vu/U8NXSS2lK/9PY+Y5bGd1SovmdJjHIoTCg0zM5DSl+jC3tYaPy8ylWAiYVWn8t3KrGjAZAtcQAAaPQGACjANgCc4pgsKOgARYqThb6DpedFRqEp21m2dmddWQ+ilOnE//OCxDcesnYU9P7EZXb+351mFovqvZLSuiUVXPotLo+VyyF8+qPUhKazkmtDedn6mkpbrvYuksSf+B3ZTb0o/Dmj7e7HyfHqX917evUOfJdhIHW9VY8k6YF2IbAUAQAgAGYAoAGmAvgKR5ZKTWfyJBCyLByoXuigQEUVHXd6dtDO1ykc11PumfIGEZmdSTNRGYYdzKyBtrvvoZrXQpX7m9D96MlZN6siutdadkRL0J1ulLq+mzLr1Z366lZ0lJz72uc1bseQrvjuIMtgQ7CYZ5//84LEgCC7nggK/splKg8c/WNaASEAAMDyExzAAgBcwAEAGMAdAFDAbgG8+V5c4MEeAHwMA1CQAqmc1aB69zPtL6NqU7URccSpKIhWmQpHQZeju+9HbY8thobRp01eujlmnzM/P7QSm+d24ncJvV9iS87smVIg631WdMmk4mpTiEWveelO7KZk05pMy1k3a7qamWbnJ5FQu0LHXmJm6JC6KkxBTUUzLjEwMKqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqu46uwUOAP/zgsTBJBO+BAD5Rt0CYIaJdGACgCxgAQAOYA4ALmA2gPR8abK6cbEgCNIkFkzWhQ5KLurGS2TpeZOfS0PefkvHzKtghefDPMy35DyY4HFadILmXyFwNSuZnrlmc9mPQ/6m5Bi3aHEnOpfWrmXT/I5rsvJdbbYt7kUpHPRyv/9Jl6gykWvJkFaehSGu3pcTrvMX2qRzVUxBTUUzLjEwMFVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVd5Y00dQ//OCxMoji84EAP8GafjBJBK8wAYAXMAFACDAHQBgwGwCHPg2bajjApMZgAOCqZzQYHm5rnU4v62u+f6S6muO+V7mG2rgLpcU/ddd1UTMLNDTJneep//rarj4TeeUiJia/plhfqhqbc3q/FeixN7QV5tbk2rAbLu0aBn1q+yD9NXvGp7I48V7tmVMkkZz0dNtMi06gxZdTEFNRTMuMTAwVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVX/84LEySNydgQA/xBpVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVcABG3Pd//+t5QSZdxymTDUVTAEwCA05EpRAwE8ga20VnryLpWcuz6Eqy6XV1pXXXUdP5vy7qyjI/nBr1FtnWhGStTK7KCxxbXrS1zDhovFyX0xSUvbY9poTE59dIWyKHjjK0RhMQU0vGpMwAAAQMAOAIjAVAFQwCECfMFQE1wCGHmJXh9pjBxBgZBUBEH1vOqBkI4UmYbQHfGEtAyhgwP/zgsSfGPlWJbTnxEgBwGA2gYosBLDgBGYCKAEmAHABC9P1rnmQhKIJsqnWhTlKSVTlZKxh4flae1jVK2clkVwwlioe50low941CupjuRmRiuImIWY2wxc4g+MSZToREQioSjmEVdKFJMV36kZEVTEut7Kqoea7UddonmaYXMhhyOcqGKRh5mVGs5CuaYTNgQjfnBB09SUBcwHhVDJUIgMHIdkwZxGDFfCUMaAH80t9yjWBO3MoWk8xiC2zE4ItOuIZAxCwizx4FNMpcYExfgRj//OCxPwwG84AAV8oARHRCjEQEL3uefcJZyt4mNQ8YzOgCVZl5AsHv5mxyyYiPRngZGChqY4CpgscQPEJx1DCRMMaAMyKLTFgTTeMGhBEdDKbn5frhjUOhYBAYxmCg8DhIluYGBJfVC0EgjPDCczv2zBAOLUmFQKDAGEBdAIVgtmSKTR5U3FYrScOdw/m+5gABLcBwATbDAGrKq9IhST2I9Oky5uO3ZisR1z/5zfe65h6KEpTUXmtdkb9LocZgkNtAZXKGtRKLOjVjNSrchqVUe//84LE/1O72igBnuAB/////////+B3LiTiRKBH/g52779xyHI5RRuGastoPrU1bOU1bNbVbv//f//1////////FYf1F5RLJRZl9Lbwo6TCk3b3nhlulx5WhXizFCoVAGPOGpaG9gHBcGrJDpU3VU71UHLzbVzxXTlLxIoZFEatQaU4AkSHxkTxpURmxieYIBmIEmEAo+pMBtAAIBiTVFC3C5FymTxpIVO9i4fUYlcrldO2nKdM6dNE6WXTEnkOmYlcro2a1rv/23mC93BevYuoT//zgsR0JlF5rAHaeACfahPnx4FToldEqwVcDQcBoGioKjwVd8GlA0eEp0SnYNf6P/1nakxBTUUzLjEwMKqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq");
+ private static readonly byte[] Stereo = Convert.FromBase64String("//uQBAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAASW5mbwAAAA8AAAALAAATlgAXFxcXFxcXFxcuLi4uLi4uLi5FRUVFRUVFRUVdXV1dXV1dXV10dHR0dHR0dHSLi4uLi4uLi4uioqKioqKioqK6urq6urq6urrR0dHR0dHR0dHo6Ojo6Ojo6Oj///////////8AAAA5TEFNRTMuMTAwAaoAAAAALCcAABSAJAQvRgAAgAAAE5awSG/GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA//uQBAAAAp0NzJ1kwABUofnTrCQAC+ytNznFAAF8lWeLONAAAUbbkNVU00TNFTPCwxqPHNQczxpCF61h0h0i1TsTa+/8vCCAAAAAIEEIB8HwfB+UBA5icH+CDpzp85y/nOX93T7uXB8HwfD4IBiUAYP6QQOZf3dIBjbcip1TqnVOqdicDvouxCWARmdZteb2mkZa9dbvhgUCgUCgUIEEJzmjB8H3lAQDGXP8EHTnT5zl/Ocv5zp93Lg+Hy4PggCDhAAwfPw+CBzLh/u6QAMwAEMEMIIADDZGMQ+pIwxfgF6GPz8buKIVAB0oOPieWBDZTH4cMNhxzuutsA0AT4AUQQN34CwgQbQbf8QQFYCkKoNv/j0iHw+NN//FUiHw+IR6Pf6gaEoSBr/iU6DUCECGAAXARgcBaRdydEx2PDCEhYYZeHBMIDGlmNbhIwiEg7TJMGVwszkEdAvfA9RPhGvxGiRHqOH/HcMMJcSI9f/HqZF4vGJd//LpkXi8iXS7/EQVBURHv9YKiIKgqIkATSIoYA4AdmAPAHZgTgFyYAgBPGAj//uSBAsIAswVRDd8QABPQWjt7zwBCqhVES/sRoFqFWEV34kQgcBltKwGZHsKjmFcA3JgpAHOYEmBeGBUgUhgXIEUYBiAVQNRw7jk36ez9+Vw4i3iRxijh78q3/rXb9NWjq/enG///vAAAAEjcRBBP3YdlKqBgIgKGAqGkYIITRhRgZGDqJ8Y6oxR1bn2mPQLeTCSAIAowGwIBIAsuKLcf6E+qvG2Ws7OS/7Ff6/6ftq/Tq/3K/+kCGhZUtmYA8ARGAmgK5gTwGMYMSFEmUyJ7plEoUWYMgBvHZx5q7GacrGm0JlZKlZD4AchH7fmRPndAYYHYt/JUd+H7voIuPKsbeiz9y/0J/XMPd1o6IAABvLdyZUeMdtBCwDl/jAkADAQgDE52gyWMC6ABRIA2QtaFB0XDoptNO2v3s6+nRV+I7V+351mFiuL0Z0UULgezzrSufWRWCCrQIlBawvMIqW66at9bMVTNvUwEkBSMBQAcTAcwLgwMgFvMHdEgDMuIzgzGMRmMG0BOzZUoyQxNzXjlsk2A1DBZpsus9VNvpCr6cijaP/7kgQdjwLNFcKD+xGwPQPo7RfCKAxoVwgOfqJBhJVgwf2JSJnztYA3zHeINXvTkFfeitiWs5KPbfrXVsfv6N3NbUQgIBmzWyCSStUCzARDMfZrKmpbY6lzyRoDpoMihM9YRdK76dtHtfvr365Bgx0au//9n/rd1/eQ+P5hv/95g4EGSySaYTxyeqmFbCFho+cB8aMIIYGFhgv4HQoABqReAaTVYGkJUBlYqAFA8UARcuF9Nn+3OfT9Z7SLsfmGV17C5JjxzbG98otcZS7Ze+5zb8sbsIJjGJvI9mUIUZFLpgdd+sa0RTuMC0DqQQAFlnjADwAgwE4BPOw2R4D1BEIRhoFWc+UjDglel9G1Y7UTjakpQrXyOidN96W2W1gY+qTXIx1hvqtHllOnFnYxrGhTAYTgqxpkUnCTBAgrxq1TTFFNCgDLKjRkxeZaiGgO5vPoYJYGKmQfsKpkAgYGYIiBpAbJeBlSQHGZgd/wBuywWZHcav0vb0/f3nrJUOE3xfKPUjTOuR6e7/f/s7L5NoyYL2pD5Vv06CwAqB7j+5pbJlb/+5IELwgCpxXEM3+gkF5hWEV3+0ALKFcMr+xGwOsP5DQ/CKTlQNAYuCYFAOYCCAlHM4okZ24cGGqETNYVLKl3TgCLMJUB9cyXZAM/Uo+8jffgIcD1ZVNSLBqZVHCkzuJMq1LdF2oX2HTShUqLrINa4VSwoHm8BOKGBMYBgBMwDYAsMBzAbzA4APQwh0MRM4YX1zNwwxUwiQEWP1vTd4s2Z+Nj5TOT0uw/8op7BH1ZPkVfsrxnsKX3p6n96Mo7675JJpG3G3q9orR8/11sQdVvv/m6wGBFbJZCiXzlBGAgEjDLWlBi/pvzHnDQDrzTsDTttU9O2/9n/erenxdVPtEMl/vfr/+3/1f9y932+pUAACOLMYjgxoVTJaONDSEwUsOYMkAcRDIww4QwSoD9M1QDIBM1k6NyhzSB0WBn5nrKVo1F2aihDpT7KvpfVZ/b2Kcvu/ZyKHs9NG5LrXWN7umAQAd/VWaWSZm4wBQGLYmBQEmAfgLByyae6YFiALA4AxRGZLA04IDTMjrdu+mrdL30Y9Fuucelu837brhiu9q1IJ57//uSBEoIMogIw8Of2JBXBWhFd+VEi5hXCg5+okFSFWEB34kQ7GudW5d91W4QRdNo+oolz1r/84kFCAyKMzRR6OFw0wnYNYNBhZ+jP/A14wn8E3A442ANFKQDOqXAzdDAMfEoA4Bi5CLlwzTQ9Tea/+cbVYxViqkeUV9HXeV73XsQOzO8lail+xExqcn11q2l0XXmlmv1u5MrKM2sNAAClsjAsCjAPgGE5RJUmMCuAGQcAZIis2hM+DMX2ojdDtr1qzd/0ef396r323GxHbmYtFkswKIG6mpOWcUtYxeVsN7MCdy17mNl9IGVAEyiqmJheYpIxjo+mbYAYIgGamPEsopjqQZGYHmBwAaB6BnQwHMVgeXYBvyQWkjuKr/7+ky9+k5kyeaujtbvwl+j5bX3/7P7vRRJnPv9QA9/WM0zEzJqACgAXdMBgHMAzAVjgEU6c5MUCCFJJtYtYFX25PYlB7TS/JV00ajYw7s8X8Z7yt7IpFbMelCZxGvfKKqkK3IWFRinRRFrgXDJGnImv8DDDPlTmRjDRENNdCK01rRETDSBWP/7kgRcCEJzFcQzn6CQTeEoUXf7QAiQHxute0JBQY/hVd+JEOm5M6nMoiMnPMGJdynBMMPW7WzmWDK7bk0Vu9Ov/t0L/v1s/9/+3/+mAgN63qZZ8ZrUqBABLvGA4EGAYALpvxilgYEgALg4AiSRbaK3wZlKnr+vdH36U1WvxEd+xCBGL7K86fk16uyu8kvelyRRtWuNQxHff1WeVRSqAAAjizEouMTEkxufjMkFMEBDQjHJmbAxvUMyMDiA5zMwDQATopjzZTfERoFAs9jYrq11B30q7a/o12f6NK6u/9naUt/rsv33fQyAAAY/+PqtUCpgriMMOclQEwBwHD4nnjAwMSsL9S21xO/9emj/3stdNdBnv3/T/ahf/exeS02fu/0//5cS6f62tuSBdZawwVA8xQGQ6n7Q6UGYxTBc7IAggaEKUUwp8DkPZ/f192yMYd+UZ6az+ytTKX0fp6/9/bd/T//V2JgGUIg/38qrpGsPGgFTGAIAMAcASTXakXQ8g8MFLVfqdHnqqmV8jMUdKtlPjKdFHjNKdLXan2f9q9H0aGv/+5IEgggCSwjEQ5/QkDyj+LwfwigIMFcnruBGoQaEYdnP6QA6dzdTtNUAACNAMWDsxaWDHyXM3zIwRYPLMe3dtzHgA7cwPMEQAylUAK0B4lYH73Ac0oCISNIqn1K/V5g6H+kfu7koU2sjT/+/Spbdn/1f/2c0AABBA5f+6zcTNeHi2yDpgEA5gEoC4bRCpanzLA4irpxYte0da5zbqFIqRBCKJqf/rQTT/Z/cQ6tjN++qUv6e5Xc1NrGp1rqBi+fmTZAQEhgZgOmDgCwYkIn5voULm8+KGYkANx9Z5rXpnnBm+ZhyrXKchyX9fq363OO+Wbv+/0ZJ5bd9mr3/t/9/+z7v6sQAAVLpnC1HzX63NGkYrkv8w4CAKnRtEoDgNWTQ7LrPVTT+xdV2fZvbnv+PTm/r/6Nrf/X9tK8hr/s3f+8wEMBUMBAAfTAUwM4wI4GdMFZFBDJcJxoyS0TsMEqBczE34FSJxZwdZHm3D4sPNrIrPVbfrSM/p0OPoUR7WVL8uyp5Gv9dQkW/pcuWvazRGWejVZdPO0bmJ7xhIAQAd3rG//uSBLwIAl8WQ8OfoJBHYThod/pACNhXFy9oRoD0D6N1vwjYaZCaa1MYDgEWZMCgTMA1AijgW3N8wJUAqMASAAUNmWw9ZQBN7300Rqt3pdKOv2yiiuvz6d79Ri38iPYvqL2kC68XvqptcguZhlWemm0/02ZQWuNCqaViIVMtCTXDI7eFMbwcQ7v+BDt2HMMb8Kk7p+NOZjOWMzC4MULFTxCvUB92yl98gH3abd7KgzQ+qxDH2/OV6qu9X23810fZxiFJAo9VBEJA7yx1MsiNQKVMCABLNGBYJGAZASJvwTuYYEgAWmAIgACKLLodvlMV+hFdn6yU7UdqKlT7GeD+/6druiFEbXP29VPpbuv9D/2NsC98mkCKKnU0KQuKnFoODU1Bk1MTLVE1KULKAAACdUshZJQEAgkYAEpj7hghCmmSnVqZIAo5gagpAk8HCAPCB7cA1IaJsavV+v2v610Tpt963CT5j+ds93jnfR/q//tphUAACIgy/LGmZCal0AYEgABgBMCgVMAxAlDen3q8wI0AuMAQAA0UmWw9ZQpmZk+j7v/7kgTviILcFcKD+xGwWOVYRXfiRIn8IxDN+2JBkKGhFd+JEIq2JT3r6p6jWW910VvUGlBi666sLi8uhwni7bwAp7WFq+4g4oOs2pQUuuaL3nrx51tClx7nIpyWRAAAwVwVTBLBZMHEBkwyhcjGyM8PICt07f06TKfHTMTkFUwwxITENDjMNcM0wIwQTAaA0MDACYFAKRl4DBDAQ5eNk6ehiGGggBlBkc5JjgSRIQllo2hpyKWKrmMQaRQQJDJdcwRQEGle3BAOpvKmcPxBa52XtwLZoA2nxxrDOHEhzcs5K4b1KIYlk25DuTleV09/+4YYfvOnz1T04EOJEYPqBAaCC4YEgYLg++ifEYfq6jgkOJ1ROHy71YP0EPghWAQAA5rrYyADABAFMA0BMwEQC0DjAsBaMGoGIwvAfzCAAFMLcM8xXBkjEEFDOri44www/DFsEMMJAGAwVAIACAWp0YCJmUjYGA0KxEGGQGBnR0ZgKBc3OEjDfloIWDCyExcPIgMGAgODBGCmRExnBUYwDLCmBh5ioyYuKgobR0MJCQUFu0n/+5IE+ggCMhVH616YmGTlWEh34kQYcLkTteyAA++iYva9sAAItRhZZ9AeloYaHmGgqSb0JCIOKaM0oXcnXbcemcBUjTHTLuIoPJDblu/f+njEYjF6vDblxeYZ279+kllivbwp6e3+FSksbpLGOeesMN0lJz888//PusPzzzwp7Y4MOWuCA0EHNlAxB8+D6qS59QAczMFAwn1iegAFGIKEIAAAADKkF7NLl4YwGQRAcDd/m2ecwYz44BgQAfmAMBH/mD2HqYWAUg8CCWi/wPADA/KYAEYF1eB4F4HSzgnMEsJo34GbggYUOFLoUjDmiEpFBafAx44AwaCQoM6AaBmKDmS/gKFAAhIAIACgEBwELfCkZJqX/EFxkhOouYQmGfFmGKKl6v5UGkO0XKOodZTJAk0quuv/jnEBJQjifIITpFiZIaTH1f/+Rc+TpeIsWSYQLZaIqUimdNF6/q///yuXi6ZAAAEaMKQQAAAGfxSmkKmAEEgcAf+St2ZhiyYIAuYDA7/nA0AmaRoBgGBADf4GcAiBoEUAYLEQCgLwNcGMDTi7//uSBIOABjtoRs56gADWrRkJztQAAAABpBwAACAAADSDgAAEAijAgAA5RX4GGR2BgMChQohQdC5RHJABIfAwuFgBAqCAIBCAgGAWTJo5NJ/AOEAGCgGBgMDAUAoCwFBvhSMk1I/wbNhgoGxIYqBt4MfCzhiigvV/IgLcO0VqM8M+Ux4HtJauuv/jNDkj8RxFyCERIMTJDSYX6lf/8i5bIiTxAiVJhAtloipNFM6VFrr+r///J8niZKX/60xBTUUzLjEwMKqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqg==");
+
+ private static (float[] Left, float[] Right) Render(Mp3Stream s, int frames)
+ {
+ var buf = new float[2 * frames];
+ s.Render(buf, frames);
+ var (l, r) = (new float[frames], new float[frames]);
+ for (var i = 0; i < frames; i++)
+ (l[i], r[i]) = (buf[2 * i], buf[2 * i + 1]);
+ return (l, r);
+ }
+
+ [Fact]
+ public void Reader_decodes_mono_at_the_file_rate()
+ {
+ var clip = Mp3Reader.Read(Mono)!.Value;
+ Assert.Equal(22050, clip.SampleRate);
+ Assert.InRange(clip.Samples.Length, 5512, 5512 + 3 * 1152);
+ var f = Array.ConvertAll(clip.Samples, s => s / 32767f);
+ Assert.InRange(SignalStats.ZeroCrossings(f, 2400, 2400 + 2205), 86, 90); // 0.1 s of 440 Hz → 88
+ }
+
+ [Fact]
+ public void Reader_downmixes_stereo_to_mono()
+ {
+ var clip = Mp3Reader.Read(Stereo)!.Value;
+ Assert.Equal(44100, clip.SampleRate);
+ Assert.InRange(clip.Samples.Length, 11025, 11025 + 3 * 1152);
+ }
+
+ [Fact]
+ public void Reader_rejects_garbage() => Assert.Null(Mp3Reader.Read(new byte[300]));
+
+ /// <summary>Oracle: both tones are 0.25 s; the LAME tag's encoder delay and padding are not counted (the Info header's
+ /// 12 and 11 whole frames would be 0.313 and 0.287 s). Within 1 sample of rounding.</summary>
+ [Fact]
+ public void Seconds_come_from_the_headers_without_encoder_delay()
+ {
+ Assert.InRange(Mp3Reader.Seconds(Mono)!.Value, 0.25, 0.25 + 1 / 22050.0);
+ Assert.InRange(Mp3Reader.Seconds(Stereo)!.Value, 0.25, 0.25 + 1 / 44100.0);
+ Assert.Null(Mp3Reader.Seconds(new byte[300]));
+ }
+
+ [Fact]
+ public void Stream_keeps_channels_apart()
+ {
+ using var s = new Mp3Stream(Stereo, loop: false);
+ var (l, r) = Render(s, 11025);
+ Assert.InRange(SignalStats.ZeroCrossings(l, 4000, 4000 + 4410), 86, 90); // 440 Hz → 88 in 0.1 s
+ Assert.InRange(SignalStats.ZeroCrossings(r, 4000, 4000 + 4410), 174, 178); // 880 Hz → 176
+ }
+
+ [Fact]
+ public void Stream_resamples_mono_to_44100_and_duplicates_it()
+ {
+ using var s = new Mp3Stream(Mono, loop: false);
+ var (l, r) = Render(s, 11025);
+ Assert.InRange(SignalStats.ZeroCrossings(l, 4000, 4000 + 4410), 86, 90); // still 440 Hz (176 if played as 44.1 kHz)
+ Assert.Equal(l, r);
+ }
+
+ [Fact]
+ public void Once_only_goes_silent_and_finishes_looping_does_not()
+ {
+ using var once = new Mp3Stream(Mono, loop: false);
+ Render(once, 44100); // 1 s, past the 0.25 s file
+ Assert.True(once.Finished);
+ var tail = new float[200];
+ Array.Fill(tail, 1f); // the stream must write the silence, not rely on a cleared buffer
+ once.Render(tail, 100);
+ Assert.All(tail, x => Assert.Equal(0f, x));
+ using var loop = new Mp3Stream(Mono, loop: true);
+ var (l, _) = Render(loop, 44100);
+ Assert.False(loop.Finished);
+ Assert.InRange(SignalStats.Rms(l, 22050), 0.05f, 0.1f); // second half still sounding (sine 1/8: RMS 0.088 less gaps)
+ }
+}
diff --git a/src/Godosa.Core.Tests/Audio/SynthTests.cs b/src/Godosa.Core.Tests/Audio/SynthTests.cs
new file mode 100644
index 0000000..7c2c29b
--- /dev/null
+++ b/src/Godosa.Core.Tests/Audio/SynthTests.cs
@@ -0,0 +1,122 @@
+using System;
+using System.Linq;
+using Godosa.Core.Audio;
+using Xunit;
+
+namespace Godosa.Core.Tests.Audio;
+
+public class SynthTests
+{
+ [Fact]
+ public void Random_IsDeterministicAndInUnitRange()
+ {
+ var a = new SynthRandom(42);
+ var b = new SynthRandom(42);
+ var values = Enumerable.Range(0, 1000).Select(_ => a.Next01()).ToArray();
+
+ Assert.Equal(values, Enumerable.Range(0, 1000).Select(_ => b.Next01()).ToArray());
+ Assert.All(values, v => Assert.InRange(v, 0f, 0.99999f));
+ Assert.InRange(values.Average(), 0.45f, 0.55f);
+ }
+
+ [Fact]
+ public void Random_IsXorshift32OfTheMixedSeed() // pinned: seeded songs must sound the same in every run
+ {
+ // oracle: independent Python murmur3 fmix32 + xorshift32 — fmix32(1) = 0x514E28B7, first state 0x1F48D1FB
+ Assert.Equal(0x514E28B7u, SynthRandom.Mix(1));
+ Assert.Equal(2050257f / 16777216f, new SynthRandom(1).Next01());
+ }
+
+ [Fact]
+ public void Random_NearbySeeds_StartFarApart()
+ {
+ var firsts = Enumerable.Range(0, 20).Select(s => new SynthRandom((uint)s).Next01()).ToList();
+
+ Assert.True(firsts.Max() - firsts.Min() > 0.5f);
+ }
+
+ [Fact]
+ public void Loop_CrossfadesTheTailIntoTheHead()
+ {
+ var x = new float[] { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 };
+
+ var y = Synth.Loop(x, overlapSamples: 2);
+
+ Assert.Equal(new float[] { 8, 5, 2, 3, 4, 5, 6, 7 }, y); // y[^1] = 7 wraps to y[0] = 8, like x[7] → x[8]
+ }
+
+ [Fact]
+ public void Hash_IsFnv1a() // stable across runs, unlike string.GetHashCode
+ {
+ Assert.Equal(0xE40C292Cu, SynthRandom.Hash("a"));
+ Assert.Equal(0x1A47E90Bu, SynthRandom.Hash("abc"));
+ }
+
+ [Fact]
+ public void Sine_SteadyPitch()
+ {
+ var x = Synth.Sine(1f, 100f);
+
+ Assert.Equal(44100, x.Length);
+ Assert.InRange(SignalStats.ZeroCrossings(x), 198, 201);
+ }
+
+ [Fact]
+ public void Sine_SweepIsExponential()
+ {
+ var x = Synth.Sine(1f, 100f, 400f); // f = 100·4^t: 144 crossings in the first half, 289 in the second
+
+ Assert.InRange(SignalStats.ZeroCrossings(x, 0, 22050), 141, 148);
+ Assert.InRange(SignalStats.ZeroCrossings(x, 22050), 285, 292);
+ }
+
+ [Fact]
+ public void LowPass_PassesLowCutsHigh()
+ {
+ float low = SignalStats.Rms(Synth.Sine(0.5f, 200f).LowPass(1000f), 2000) / SignalStats.Rms(Synth.Sine(0.5f, 200f), 2000);
+ float high = SignalStats.Rms(Synth.Sine(0.5f, 8000f).LowPass(1000f), 2000) / SignalStats.Rms(Synth.Sine(0.5f, 8000f), 2000);
+
+ Assert.InRange(low, 0.89f, 1.05f); // within 1 dB
+ Assert.InRange(high, 0f, 0.1f); // −20 dB or more (2nd order: ≈ −36 dB at 3 octaves)
+ }
+
+ [Fact]
+ public void HighPass_PassesHighCutsLow()
+ {
+ float low = SignalStats.Rms(Synth.Sine(0.5f, 100f).HighPass(1000f), 4000) / SignalStats.Rms(Synth.Sine(0.5f, 100f), 4000);
+ float high = SignalStats.Rms(Synth.Sine(0.5f, 6000f).HighPass(1000f), 4000) / SignalStats.Rms(Synth.Sine(0.5f, 6000f), 4000);
+
+ Assert.InRange(low, 0f, 0.05f);
+ Assert.InRange(high, 0.89f, 1.05f);
+ }
+
+ [Fact]
+ public void BandPass_PassesCentreOnly()
+ {
+ float Ratio(float hz) => SignalStats.Rms(Synth.Sine(0.5f, hz).BandPass(1000f, 2f), 4000) / SignalStats.Rms(Synth.Sine(0.5f, hz), 4000);
+
+ Assert.InRange(Ratio(1000f), 0.85f, 1.05f);
+ Assert.InRange(Ratio(100f), 0f, 0.1f);
+ Assert.InRange(Ratio(8000f), 0f, 0.1f);
+ }
+
+ [Fact]
+ public void Perc_RisesThenDecaysByTimeConstant()
+ {
+ var x = Enumerable.Repeat(1f, 44100).ToArray().Perc(attack: 0.01f, decay: 0.1f);
+
+ Assert.Equal(0f, x[0]);
+ Assert.InRange(x[441], 0.99f, 1f); // top at the end of the attack
+ Assert.InRange(x[441 + 4410], 0.36f, 0.375f); // e^-1 one time constant later
+ }
+
+ [Fact]
+ public void ToPcm_NormalisesToLevel_AndFadesEnds()
+ {
+ var pcm = Synth.ToPcm(Enumerable.Repeat(0.3f, 4410).ToArray(), level: 0.5f);
+
+ Assert.Equal((short)16384, pcm.Max());
+ Assert.Equal(0, pcm[0]);
+ Assert.Equal(0, pcm[^1]);
+ }
+}
diff --git a/src/Godosa.Core.Tests/Audio/WavReaderTests.cs b/src/Godosa.Core.Tests/Audio/WavReaderTests.cs
new file mode 100644
index 0000000..6ed5485
--- /dev/null
+++ b/src/Godosa.Core.Tests/Audio/WavReaderTests.cs
@@ -0,0 +1,127 @@
+using System;
+using System.IO;
+using Godosa.Core.Audio;
+using Xunit;
+
+namespace Godosa.Core.Tests.Audio;
+
+public class WavReaderTests
+{
+ private static byte[] Wav(short format, short channels, int rate, short bits, byte[] data, bool extensible = false)
+ {
+ using var ms = new MemoryStream();
+ using var w = new BinaryWriter(ms);
+ int fmtSize = extensible ? 40 : 16;
+ w.Write("RIFF"u8); w.Write(4 + 8 + fmtSize + 8 + data.Length); w.Write("WAVE"u8);
+ w.Write("fmt "u8); w.Write(fmtSize);
+ w.Write(extensible ? (short)-2 : format); w.Write(channels); w.Write(rate);
+ w.Write(rate * channels * bits / 8); w.Write((short)(channels * bits / 8)); w.Write(bits);
+ if (extensible)
+ {
+ w.Write((short)22); w.Write(bits); w.Write(0);
+ w.Write((int)format); w.Write(new byte[12]); // sub-format GUID: first 4 bytes = format tag
+ }
+ w.Write("data"u8); w.Write(data.Length); w.Write(data);
+ return ms.ToArray();
+ }
+
+ private static byte[] Shorts(params short[] s)
+ {
+ var b = new byte[s.Length * 2];
+ Buffer.BlockCopy(s, 0, b, 0, b.Length);
+ return b;
+ }
+
+ [Fact]
+ public void Pcm16Stereo_DownmixesToMono()
+ {
+ var clip = WavReader.Read(Wav(1, 2, 22050, 16, Shorts(1000, 3000, -2000, -4000)));
+
+ Assert.NotNull(clip);
+ Assert.Equal(22050, clip!.Value.SampleRate);
+ Assert.Equal(new short[] { 2000, -3000 }, clip.Value.Samples);
+ }
+
+ [Fact]
+ public void Pcm24Mono()
+ {
+ // 0x400000 = +0.5 FS, 0xC00000 = −0.5 FS (little-endian 3-byte)
+ var clip = WavReader.Read(Wav(1, 1, 44100, 24, new byte[] { 0, 0, 0x40, 0, 0, 0xC0 }));
+
+ Assert.Equal(new short[] { 16384, -16384 }, clip!.Value.Samples);
+ }
+
+ [Fact]
+ public void Float32_Extensible()
+ {
+ var data = new byte[8];
+ BitConverter.GetBytes(0.5f).CopyTo(data, 0);
+ BitConverter.GetBytes(-1f).CopyTo(data, 4);
+
+ var clip = WavReader.Read(Wav(3, 1, 48000, 32, data, extensible: true));
+
+ Assert.Equal(new short[] { 16384, -32767 }, clip!.Value.Samples);
+ Assert.Equal(48000, clip.Value.SampleRate);
+ }
+
+ [Theory]
+ [InlineData(new byte[0])]
+ [InlineData(new byte[] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12 })]
+ public void Garbage_IsNull(byte[] bytes) => Assert.Null(WavReader.Read(bytes));
+
+ [Fact]
+ public void Pcm8_UnsignedCentredOn128()
+ {
+ var clip = WavReader.Read(Wav(1, 1, 8000, 8, new byte[] { 128, 192, 0 }));
+ Assert.Equal(new short[] { 0, 16384, -32768 }, clip!.Value.Samples);
+ }
+
+ // MS ADPCM fixtures: our own tones encoded by ffmpeg 7 (adpcm_ms, block 256, standard 7 coefficient pairs). Oracle:
+ // ffmpeg's own adpcm_ms decode (pcm_s16le), stereo averaged with truncation, cut to the fact chunk's frame count.
+ private static readonly byte[] AdpcmMono = Convert.FromBase64String("UklGRnQEAABXQVZFZm10IDIAAAACAAEAIlYAAIA+AAAAAQQAIAD0AQcAAAEAAAACAP8AAAAAwABAAPAAAADMATD/iAEY/2ZhY3QEAAAACAYAAExJU1QaAAAASU5GT0lTRlQNAAAATGF2ZjYxLjcuMTAzAABkYXRhAAQAAAAQAPwHAAB3d3MhAQAP/dzNvd3M7d3d3u8BNFNTRDNSMzQjIyEQ/tvM293M3dzd7d7vASREUzUzQ0MzNCIiAf7b283b3c3dzd7e7wEjVEQ1JDM0MzMzIhH+zL3MzdzN3dzt7f/xE1Q1NEM0M0MzMjIQDtrczM3czd3c7d7+ACNFQ0RDNDNDMzMiEQ7bzMzc3N3Nzt7e/gATRTRENDNDQyMzIhEO6829zN3M7c7t7u/xEkREQ0NEJCMjMiIgD+vb3M3Nzdzd3d7f8BNTVDRDQ0MzNCMiEQ7trb3b3c3c3d3d7/8iU1Q0Q0M0NCMjIiAf3bzM3M3czt3d7e7wEkU0REMASgLk/uj2QkIyMyIRH+zMzb3N3Nze3d3u8BI2Q0RDQzQzQyMiIQ/tvM292+3O3d3d7vADNjU0QzUjNCMyMiEP/Nrc3M3Nztzt7u7wETRTRENDNDQjMyIhH/zbzb3czd3N3d3e/xI1REQ0NDQzNCMjEgDtzL3M3Nzdze3d3+8RNUQ1NDQzQzNCIxEQ/czMzczdzc7d3t7/AjRFNTNDQ0MzMzMhEP28zNzM7N7O3e3u/wE0RERDQ0M0IzMzIhDuvbzc293c3dzt7v8BM1RENDRDM0IzMiIQ/sy9zNzc3N3d3d7vASRUNTQ0NDM0MjIiEP7a3Mzczd3N3O3u/wAjVDU0AEkC1PUA7jRCMzMzIhD+zMzM3M3dzc7e7u8AI1NTUzQ0MzQzIyIR/tvMzczc3c3dzu7vARNEU0NEM0M0MjIiEf/b283Nzc3N7c7u//ESREQ1M0M0M0IyIhAP3Mvc2+ztzt3e3u4AIlQ1NEM0NDMzMzIh/9vMzNzczs7e3e7uABNEU0Q0NDNDMzMjEQ7rzbzdvd3N3O3t/vESRERDRDNDQzIzIxEP3Mvczb7c7d3O3v4PEkQ1Q0NDQzQjMjIRD+2tvc3NzO3c7e7v8BFDVDRDQ0M0MjMiIQ/trczNzN3Ozt7t7/ACNUNTQ0NDNDIzIxIP7a3Mzczd3Nzt7e/wAjRTUwDmAfTseOVEMzQzMzIiAOzMzM3M3c3d3O3u8AJ3cQDx8fHx8fHx8fAQ8BDx8B8Q8fAfHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8="); // 440 Hz ×4 gain, 22050 Hz, fact 1544
+ private static readonly byte[] AdpcmStereo = Convert.FromBase64String("UklGRnQFAABXQVZFZm10IDIAAAACAAIAIlYAAIA+AAAAAQQAIAD0AAcAAAEAAAACAP8AAAAAwABAAPAAAADMATD/iAEY/2ZhY3QEAAAATwQAAExJU1QaAAAASU5GT0lTRlQNAAAATGF2ZjYxLjcuMTAzAABkYXRhAAUAAAAAEAAQAP8Bs/gAAJLxeHd4RyATGycbEQ8EDeTs0sHAz87RwsLa0NPN39TP4NDi+eDxAh8uQ0JOTj5QPUcwMUtELjIwMT4wPSQgHQ7w5820ztC/38Pew9DN1d7SzN/U793k4v4BESpPQ14+Q08wQT5GPUMxMj9OLz4vJy8PEf/j27LMwt6y38Pa08Dez9Pf1dzU3+zj7g8VIE5RPl80TUAxQjtDPzNOMC8wMDUtLBUOAOHAwsC93cbdxM/S2sHQwu3Q1O3Q4uHwDR8TQExETEQwXzI6Qz8wTjNNITMpMSAQIwD+/dKw0rrB38XRzcLd0dDD3NTcwe7i/vTxHBQ8QUAAAEoACA649VUMRPRlAm4/NlAsQS8zPzExSyEvMR8THv700c2w37LSztHKwt7T38zW39Lf3dLg4w4CKDFjMF4/QUI9RE8/PjZNMDM+Mz8oMREd8fPMws3DzcTC28/R0MHO5N7U3t/e4e/n8RsSMVBNQ0A9UDU/TzJAOjBCPiQ9MiAhHxIN/NKz0rjP0rHhzNDS0s3SzuTd4tvg4uACCxMxQEpQNE5BPkNNPzQ/TjI/QiwhLiUgDgDx48/DysDO1sDO3tDB38PQ3e/X0NHu8vvyIitRP1NAMEBNPkc/MU8/QC4yMC4vIiUQDf/nwMyw0cLN0MLez9XP0tzS0M7j4eHvAABTAH8GBvDJ2Q3wlegXEDFMRE9OQTFCP04+TzEyPT8xQSAnER0MBe3Gz87RsMHS0LDo0cDi3t/U393Q4eXtDxQwTk9SPl81TTNAP0I+Pk0kPDMwJDsSLw7w4sXAvdHCydKx7cDg0dTcz9Tg0ODg7fT8ES80bzFPQUExTE89P0AwN04iPi42ICEeAfL5wMDDwM3UzsLfwdvB0NDS3dDD4uDp8QEQET1RPFM9UjIwTzdMMUM7IUAhIi0gIw8f/vviwr3exMLQuuPfwN/d0cHX7t7V7+7w8BUtM19ATTJUPkAyTTFPMTAwQTwlLz0fJgAA7OK+w9K4083A0t3BwOTd3wAAhACACpH0huoO9tQc1O/g4P31HR8yQ1k0XTBDPUBDPTFOIEMlLD8hIBUtDwTw3dOg0bzfw9LN3cHQ3s3Q19DV39zg8fIPIj5bREBPTUUwTTIwTUIvMTQ9NTwiPh8UAu/hsM3NxtC91N3D3MTc08Df7dLQ7uPqBQ4fMENcQTJRO0ExQTFPPj4wRD8jPCMvHBEA8uvAtt+w0sDQysLQ0M3S0NLQzuHu5O0DCREhMm0+UD9HPk9BMzwzQT5BLT4kLCERHgUP0dG+wMHD2LPf393Ewe/f09Dd0NDb9f3/ICRNUUM8VDBNQzwzQDE/TCMwPDIyLhArBPDdw8Kw2rHTy9IAAIUA0RBwAFYCbwKB6s7SzeHU0dzR3uPs8fAUME9DWjFQPjRRPjE+T0QgPTQ7IjAgIB4O8+zToNW70cDC3s7V3s7Swu/e0OXR7ODwAwskTk5CQ0swUTA/RD1PMz0wNU0jLDQeHxT/7dTAvcLO1MDcv+PQz+Hd1N7UzeHi/fwDBxA7QVAwUT4wUiBBHwAAAAEPAQ8BDwEAH/AR8B/wEf8R8A8AAQAPAQ8AAQ8BAA8BDwEPAQ8AAQ8BDwEPAQ8BDwEPAQ8BDwEPAQ8BDwEPAQ8BDwEPAQ8BDwEPAQ8BDwEPAQ8BDwEPAQ8BDwEPAQ8BDwEPAQ8BDwEPAQ8BDwEPAQ8B"); // 440 Hz left, noise right, fact 1103
+
+ private static readonly byte[] AdpcmQuiet = Convert.FromBase64String("UklGRnQBAABXQVZFZm10IDIAAAACAAEAESsAAIA+AAAAAQQAIAD0AQcAAAEAAAACAP8AAAAAwABAAPAAAADMATD/iAEY/2ZhY3QEAAAASwEAAExJU1QaAAAASU5GT0lTRlQNAAAATGF2ZjYxLjcuMTAzAABkYXRhAAEAAAAQAAMAAAAAAQAAAADwAAAPAAAAAAAAAQAAAQAAAAAPAAAPAAAAAAAAABAAABAAAAAA8AAA8AAAAAAAAAEAAAEAAAAADwAADwAAAAAAAAEAAAAQAAAADwAAAPAAAAAAAAAQAAAQAAAAAPAAAPAAAAAAAAABAAABAAAAAA8AAA8AAAAAAAAAEAAAEAAAAADwAADwAAAAAAAAEAAAAQAAAADwAAAPAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="); // 300 Hz ×0.004, 11025 Hz, fact 331
+
+ [Fact]
+ public void MsAdpcm_QuietKeepsTheStepFloorOf16()
+ {
+ var s = WavReader.Read(AdpcmQuiet)!.Value.Samples; // ffmpeg: steps stay at the floor, samples move by 16
+ Assert.Equal(331, s.Length);
+ Assert.Equal(new short[] { 0, 3, 3, 3, 3, 19, 19, 19, 19, 19, 19, 19 }, s[..12]);
+ Assert.Equal((302, 3682), (Sum(s), AbsSum(s)));
+ }
+
+ private static int Sum(short[] s) { int n = 0; foreach (var x in s) n += x; return n; }
+ private static int AbsSum(short[] s) { int n = 0; foreach (var x in s) n += Math.Abs(x); return n; }
+
+ [Fact]
+ public void MsAdpcm_Mono_MatchesFfmpeg()
+ {
+ var s = WavReader.Read(AdpcmMono)!.Value.Samples;
+ Assert.Equal(1544, s.Length);
+ Assert.Equal(new short[] { 0, 2044, 2156, 2422, 3059, 4585 }, s[..6]);
+ Assert.Equal(new short[] { -6016, -4057, -2328, -284, 2060, 3464 }, s[498..504]); // across the block edge at 500
+ Assert.Equal(new short[] { -16364, -16364, -15876 }, s[^3..]);
+ Assert.Equal((74278, 16090432), (Sum(s), AbsSum(s)));
+ }
+
+ [Fact]
+ public void MsAdpcm_Stereo_MatchesFfmpeg()
+ {
+ var clip = WavReader.Read(AdpcmStereo)!.Value;
+ Assert.Equal(22050, clip.SampleRate);
+ var s = clip.Samples;
+ Assert.Equal(1103, s.Length);
+ Assert.Equal(new short[] { -1847, -679, -687, -386, -527, 1116 }, s[..6]);
+ Assert.Equal(new short[] { 277, 3542, 2416, 255, -1687, -2380 }, s[498..504]);
+ Assert.Equal(new short[] { -1115, -937, 606 }, s[^3..]);
+ Assert.Equal((-183764, 2966742), (Sum(s), AbsSum(s)));
+ }
+
+ [Fact]
+ public void Downmix_InterleavedFloat()
+ {
+ Assert.Equal(new short[] { 16384, 0 }, Pcm.Downmix(new[] { 0.25f, 0.75f, 1f, -1f }, 2));
+ }
+}
diff --git a/src/Godosa.Core.Tests/Diagnostics/DebugFlagsTests.cs b/src/Godosa.Core.Tests/Diagnostics/DebugFlagsTests.cs
new file mode 100644
index 0000000..c67042e
--- /dev/null
+++ b/src/Godosa.Core.Tests/Diagnostics/DebugFlagsTests.cs
@@ -0,0 +1,59 @@
+using Godosa.Core.Diagnostics;
+using Xunit;
+
+namespace Godosa.Core.Tests.Diagnostics;
+
+public class DebugFlagsTests
+{
+ [Fact]
+ public void Categories_AreCommaSeparated_Trimmed_CaseInsensitive()
+ {
+ var flags = new DebugFlags(" input , AUDIO");
+ Assert.True(flags.On("input"));
+ Assert.True(flags.On("audio"));
+ Assert.False(flags.On("frames"));
+ }
+
+ [Fact]
+ public void All_TurnsOnEveryCategory_ButNotExactSwitches()
+ {
+ var flags = new DebugFlags("all");
+ Assert.True(flags.On("script"));
+ Assert.False(flags.Exact("nohud"));
+ }
+
+ [Fact]
+ public void Exact_MatchesOnlyTheNamedSwitch()
+ {
+ var flags = new DebugFlags("frames,NoHud");
+ Assert.True(flags.Exact("nohud"));
+ Assert.False(flags.Exact("input"));
+ }
+
+ [Fact]
+ public void MissingOrEmpty_TurnsNothingOn()
+ {
+ Assert.False(new DebugFlags(null).On("input"));
+ Assert.False(new DebugFlags(" , ,").On("input"));
+ }
+
+ [Fact]
+ public void EngineDebug_Apply_SetsEachLibCategory()
+ {
+ try
+ {
+ EngineDebug.Apply(new DebugFlags("script"));
+ Assert.True(EngineDebug.Script);
+ Assert.False(EngineDebug.Input);
+ Assert.False(EngineDebug.Audio);
+ EngineDebug.Apply(new DebugFlags("input,audio"));
+ Assert.True(EngineDebug.Input);
+ Assert.True(EngineDebug.Audio);
+ Assert.False(EngineDebug.Script);
+ }
+ finally
+ {
+ EngineDebug.Apply(new DebugFlags(null));
+ }
+ }
+}
diff --git a/src/Godosa.Core.Tests/Diagnostics/FrameTimeStatsTests.cs b/src/Godosa.Core.Tests/Diagnostics/FrameTimeStatsTests.cs
new file mode 100644
index 0000000..44b6758
--- /dev/null
+++ b/src/Godosa.Core.Tests/Diagnostics/FrameTimeStatsTests.cs
@@ -0,0 +1,97 @@
+using System;
+using Godosa.Core.Diagnostics;
+using Xunit;
+
+namespace Godosa.Core.Tests.Diagnostics;
+
+public class FrameTimeStatsTests
+{
+ /// <summary>99 smooth 10 ms frames plus one 100 ms hitch.</summary>
+ private static FrameTimeStats OneHitch()
+ {
+ var stats = new FrameTimeStats(capacity: 100);
+ for (int i = 0; i < 99; i++)
+ stats.Record(0.010, workSeconds: 0.002, simTicks: 0, simSeconds: 0);
+ stats.Record(0.100, workSeconds: 0.002, simTicks: 0, simSeconds: 0);
+ return stats;
+ }
+
+ [Fact]
+ public void Fps_IsFramesOverTotalTime_NotMeanOfInstantaneousFps()
+ {
+ var s = OneHitch().Summarize();
+
+ Assert.Equal(100, s.Frames);
+ // 100 frames / 1.09 s. (Mean of per-frame FPS would be (99*100 + 10)/100 = 99.1.)
+ Assert.Equal(91.743, s.Fps, 3);
+ Assert.Equal(10.9, s.AvgMs, 6);
+ }
+
+ [Fact]
+ public void Percentiles_UseNearestRank_AndMaxCatchesTheHitch()
+ {
+ var s = OneHitch().Summarize();
+
+ Assert.Equal(10.0, s.P50Ms, 6);
+ Assert.Equal(10.0, s.P95Ms, 6);
+ Assert.Equal(10.0, s.P99Ms, 6); // rank ceil(0.99*100) = 99th of 100
+ Assert.Equal(100.0, s.MaxMs, 6);
+ }
+
+ [Fact]
+ public void OnePercentLow_AveragesTheSlowestOnePercentOfFrames()
+ {
+ var s = OneHitch().Summarize();
+
+ Assert.Equal(10.0, s.OnePercentLowFps, 6); // worst 1 of 100 frames = 100 ms
+ }
+
+ [Fact]
+ public void Hitches_CountFramesOverTwiceTheMedian()
+ {
+ var stats = new FrameTimeStats(capacity: 10);
+ foreach (var ms in new[] { 10, 10, 10, 10, 10, 20, 21, 35 })
+ stats.Record(ms / 1000.0, 0, 0, 0);
+
+ Assert.Equal(2, stats.Summarize().Hitches); // median 10 → 21 and 35 (20 is not > 20)
+ }
+
+ [Fact]
+ public void Window_DropsOldestFrames()
+ {
+ var stats = new FrameTimeStats(capacity: 4);
+ foreach (var ms in new[] { 100, 100, 3, 4, 5, 6 })
+ stats.Record(ms / 1000.0, 0, 0, 0);
+
+ Span<float> intervals = stackalloc float[4];
+ int n = stats.CopyIntervalsMs(intervals);
+
+ Assert.Equal(4, n);
+ Assert.Equal(new float[] { 3, 4, 5, 6 }, intervals.ToArray());
+ Assert.Equal(6.0, stats.Summarize().MaxMs, 4);
+ }
+
+ [Fact]
+ public void Sim_ReportsAchievedTickRateAndCostPerTick()
+ {
+ var stats = new FrameTimeStats(capacity: 60);
+ for (int i = 0; i < 60; i++) // 1 s at 60 fps; a tick every other frame, 1 ms each
+ stats.Record(1.0 / 60.0, workSeconds: 0.004, simTicks: i % 2, simSeconds: (i % 2) * 0.001);
+
+ var s = stats.Summarize();
+
+ Assert.Equal(30.0, s.SimTickRate, 3);
+ Assert.Equal(1.0, s.AvgTickMs, 6);
+ Assert.Equal(4.0, s.AvgWorkMs, 6);
+ }
+
+ [Fact]
+ public void Empty_SummarizesToZerosWithoutDividingByZero()
+ {
+ var s = new FrameTimeStats().Summarize();
+
+ Assert.Equal(0, s.Frames);
+ Assert.Equal(0.0, s.Fps);
+ Assert.Equal(0.0, s.AvgTickMs);
+ }
+}
diff --git a/src/Godosa.Core.Tests/Diagnostics/GpuAndCsvTests.cs b/src/Godosa.Core.Tests/Diagnostics/GpuAndCsvTests.cs
new file mode 100644
index 0000000..46cf5ed
--- /dev/null
+++ b/src/Godosa.Core.Tests/Diagnostics/GpuAndCsvTests.cs
@@ -0,0 +1,53 @@
+using System.Globalization;
+using System.Threading;
+using Godosa.Core.Diagnostics;
+using Xunit;
+
+namespace Godosa.Core.Tests.Diagnostics;
+
+public class GpuAndCsvTests
+{
+ [Fact]
+ public void GpuSamples_AverageSeparatelyFromFrames()
+ {
+ var stats = new FrameTimeStats(capacity: 4);
+ stats.Record(0.016, 0.002, 1, 0.0001);
+ Assert.Equal(0, stats.Summarize().GpuSamples);
+ Assert.Equal(0.0, stats.Summarize().AvgGpuMs);
+
+ stats.RecordGpu(0.001);
+ stats.RecordGpu(0.003);
+
+ var s = stats.Summarize();
+ Assert.Equal(2, s.GpuSamples);
+ Assert.Equal(2.0, s.AvgGpuMs, precision: 9);
+ }
+
+ [Fact]
+ public void GpuSamples_RollOverTheWindow()
+ {
+ var stats = new FrameTimeStats(capacity: 2);
+ stats.RecordGpu(0.010);
+ stats.RecordGpu(0.001);
+ stats.RecordGpu(0.003);
+
+ Assert.Equal(2.0, stats.Summarize().AvgGpuMs, precision: 9);
+ }
+
+ [Fact]
+ public void CsvRow_IsInvariantCulture_MsWithThreeDecimals_GpuBlankWhenUnknown()
+ {
+ var previous = Thread.CurrentThread.CurrentCulture;
+ Thread.CurrentThread.CurrentCulture = new CultureInfo("de-DE"); // comma decimals must not leak in
+ try
+ {
+ Assert.Equal("t_s,frame_ms,work_ms,sim_ticks,sim_ms,gpu_ms", PerfCsv.Header());
+ Assert.Equal("12.500,16.667,2.000,1,0.100,1.250", PerfCsv.Row(12.5, 0.0166667, 0.002, 1, 0.0001, 0.00125));
+ Assert.Equal("0.000,16.667,2.000,0,0.000,", PerfCsv.Row(0, 0.0166667, 0.002, 0, 0, null));
+ }
+ finally
+ {
+ Thread.CurrentThread.CurrentCulture = previous;
+ }
+ }
+}
diff --git a/src/Godosa.Core.Tests/Diagnostics/PerfCsvTests.cs b/src/Godosa.Core.Tests/Diagnostics/PerfCsvTests.cs
new file mode 100644
index 0000000..fd64778
--- /dev/null
+++ b/src/Godosa.Core.Tests/Diagnostics/PerfCsvTests.cs
@@ -0,0 +1,38 @@
+using System.Globalization;
+using Godosa.Core.Diagnostics;
+using Xunit;
+
+namespace Godosa.Core.Tests.Diagnostics;
+
+public class PerfCsvTests
+{
+ [Fact]
+ public void Header_AppendsCallerColumnsInOrder()
+ {
+ Assert.Equal("t_s,frame_ms,work_ms,sim_ticks,sim_ms,gpu_ms", PerfCsv.Header());
+ Assert.Equal("t_s,frame_ms,work_ms,sim_ticks,sim_ms,gpu_ms,draw_calls,triangles",
+ PerfCsv.Header("draw_calls", "triangles"));
+ }
+
+ [Fact]
+ public void Row_AppendsCallerValuesInOrder()
+ {
+ Assert.Equal("1.500,16.000,4.000,2,1.000,,12,2.500,",
+ PerfCsv.Row(1.5, 0.016, 0.004, 2, 0.001, null, "12", PerfCsv.Ms(0.0025), PerfCsv.Ms(null)));
+ }
+
+ [Fact]
+ public void Ms_IsInvariant_ThreeDecimals()
+ {
+ var previous = Thread.CurrentThread.CurrentCulture;
+ try
+ {
+ Thread.CurrentThread.CurrentCulture = CultureInfo.GetCultureInfo("de-DE");
+ Assert.Equal("12.500", PerfCsv.Ms(0.0125));
+ }
+ finally
+ {
+ Thread.CurrentThread.CurrentCulture = previous;
+ }
+ }
+}
diff --git a/src/Godosa.Core.Tests/Godosa.Core.Tests.csproj b/src/Godosa.Core.Tests/Godosa.Core.Tests.csproj
new file mode 100644
index 0000000..76a07ab
--- /dev/null
+++ b/src/Godosa.Core.Tests/Godosa.Core.Tests.csproj
@@ -0,0 +1,13 @@
+<Project Sdk="Microsoft.NET.Sdk">
+ <PropertyGroup>
+ <IsPackable>false</IsPackable>
+ </PropertyGroup>
+ <ItemGroup>
+ <PackageReference Include="Microsoft.NET.Test.Sdk" />
+ <PackageReference Include="xunit" />
+ <PackageReference Include="xunit.runner.visualstudio" />
+ </ItemGroup>
+ <ItemGroup>
+ <ProjectReference Include="../Godosa.Core/Godosa.Core.csproj" />
+ </ItemGroup>
+</Project>
diff --git a/src/Godosa.Core.Tests/Input/GamepadScriptTests.cs b/src/Godosa.Core.Tests/Input/GamepadScriptTests.cs
new file mode 100644
index 0000000..9c0c9dc
--- /dev/null
+++ b/src/Godosa.Core.Tests/Input/GamepadScriptTests.cs
@@ -0,0 +1,56 @@
+using System.Numerics;
+using Godosa.Core.Input;
+using Xunit;
+
+namespace Godosa.Core.Tests.Input;
+
+public class GamepadScriptTests
+{
+ [Theory]
+ [InlineData("PadA", PadButtons.A)]
+ [InlineData("PadB", PadButtons.B)]
+ [InlineData("PadX", PadButtons.X)]
+ [InlineData("PadY", PadButtons.Y)]
+ [InlineData("PadBack", PadButtons.Back)]
+ [InlineData("PadStart", PadButtons.Start)]
+ [InlineData("PadUp", PadButtons.DpadUp)]
+ [InlineData("PadDown", PadButtons.DpadDown)]
+ [InlineData("PadLeft", PadButtons.DpadLeft)]
+ [InlineData("PadRight", PadButtons.DpadRight)]
+ [InlineData("PadLB", PadButtons.LeftShoulder)]
+ [InlineData("PadRB", PadButtons.RightShoulder)]
+ public void Button_DownHolds_UpReleases(string name, PadButtons button)
+ {
+ var down = GamepadScript.Apply(default, name, true)!.Value;
+ Assert.Equal(button, down.Buttons);
+ Assert.True(down.Held(button));
+ Assert.Equal(PadButtons.None, GamepadScript.Apply(down, name, false)!.Value.Buttons);
+ }
+
+ [Fact]
+ public void Button_LeavesOtherButtonsAndSticksAlone()
+ {
+ var start = new GamepadState(new Vector2(0.5f, 0f), Vector2.Zero, 0f, 0f, PadButtons.B);
+ var next = GamepadScript.Apply(start, "PadA", true)!.Value;
+ Assert.Equal(PadButtons.A | PadButtons.B, next.Buttons);
+ Assert.Equal(new Vector2(0.5f, 0f), next.LeftStick);
+ Assert.Equal(PadButtons.A, GamepadScript.Apply(next, "PadB", false)!.Value.Buttons);
+ }
+
+ [Fact]
+ public void Triggers_GoFullOnAndOff()
+ {
+ var lt = GamepadScript.Apply(default, "PadLT", true)!.Value;
+ Assert.Equal(1f, lt.LeftTrigger);
+ Assert.Equal(0f, lt.RightTrigger);
+ Assert.Equal(0f, GamepadScript.Apply(lt, "PadLT", false)!.Value.LeftTrigger);
+ Assert.Equal(1f, GamepadScript.Apply(default, "PadRT", true)!.Value.RightTrigger);
+ }
+
+ [Fact]
+ public void Names_AreCaseInsensitive_UnknownIsNull()
+ {
+ Assert.True(GamepadScript.Apply(default, "padstart", true)!.Value.Held(PadButtons.Start));
+ Assert.Null(GamepadScript.Apply(default, "PadZ", true));
+ }
+}
diff --git a/src/Godosa.Core.Tests/Input/InputScriptTests.cs b/src/Godosa.Core.Tests/Input/InputScriptTests.cs
new file mode 100644
index 0000000..d208240
--- /dev/null
+++ b/src/Godosa.Core.Tests/Input/InputScriptTests.cs
@@ -0,0 +1,73 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Godosa.Core.Input;
+using Xunit;
+
+namespace Godosa.Core.Tests.Input;
+
+public class InputScriptTests
+{
+ [Fact]
+ public void Parse_AbsoluteRelativeHoldPressClick_SortedByTime()
+ {
+ var script = InputScript.Parse("""
+ # walk to the Elder
+ 1.0 hold W 2.5
+ +0.5 press E
+ 5 click Accept
+ 4.0 down Space
+ 4.2 up Space
+ """);
+
+ Assert.Equal(new[]
+ {
+ new ScriptEvent(1.0, ScriptAction.Down, "W"),
+ new ScriptEvent(1.5, ScriptAction.Down, "E"),
+ new ScriptEvent(1.5 + InputScript.PressSeconds, ScriptAction.Up, "E"),
+ new ScriptEvent(3.5, ScriptAction.Up, "W"),
+ new ScriptEvent(4.0, ScriptAction.Down, "Space"),
+ new ScriptEvent(4.2, ScriptAction.Up, "Space"),
+ new ScriptEvent(5.0, ScriptAction.Click, "Accept"),
+ }, script.Events);
+ }
+
+ [Fact]
+ public void Type_KeepsTheWholeText() =>
+ Assert.Equal(new ScriptEvent(1.0, ScriptAction.Type, "Ada Lovelace"), InputScript.Parse("1 type Ada Lovelace").Events.Single());
+
+ [Fact]
+ public void Click_KeepsTheWholeLabel() =>
+ Assert.Equal("Take all", InputScript.Parse("2 click Take all").Events.Single().Arg);
+
+ [Theory]
+ [InlineData("1.0 jump W")]
+ [InlineData("abc down W")]
+ [InlineData("1.0 down")]
+ [InlineData("1.0 hold W")]
+ [InlineData("+0.5 down W")]
+ public void Parse_BadLine_ReportsItsLineNumber(string bad)
+ {
+ var ex = Assert.Throws<FormatException>(() => InputScript.Parse("# ok\n" + bad));
+ Assert.Contains("line 2", ex.Message);
+ }
+
+ [Fact]
+ public void Due_ReturnsEachEventOnceWhenItsTimeComes()
+ {
+ var script = InputScript.Parse("1 down W\n2 up W\n2 press E");
+ var got = new List<ScriptEvent>();
+
+ script.Due(0.99, got);
+ Assert.Empty(got);
+ script.Due(1.0, got);
+ Assert.Single(got);
+ script.Due(2.0, got);
+ Assert.Equal(3, got.Count);
+ script.Due(10.0, got);
+ Assert.Equal(4, got.Count);
+ script.Due(20.0, got);
+ Assert.Equal(4, got.Count);
+ Assert.True(script.Finished);
+ }
+}
diff --git a/src/Godosa.Core.Tests/Input/MenuCursorTests.cs b/src/Godosa.Core.Tests/Input/MenuCursorTests.cs
new file mode 100644
index 0000000..79a1184
--- /dev/null
+++ b/src/Godosa.Core.Tests/Input/MenuCursorTests.cs
@@ -0,0 +1,42 @@
+using Godosa.Core.Input;
+using Xunit;
+
+namespace Godosa.Core.Tests.Input;
+
+public class MenuCursorTests
+{
+ [Fact]
+ public void Move_WrapsAround()
+ {
+ var c = new MenuCursor();
+ c.Clamp(3);
+
+ c.Move(-1, 3);
+ Assert.Equal(2, c.Index);
+ c.Move(+1, 3);
+ Assert.Equal(0, c.Index);
+ c.Move(+1, 3);
+ Assert.Equal(1, c.Index);
+ }
+
+ [Fact]
+ public void Clamp_KeepsIndexInsideAShrinkingList_AndEmptyMeansNone()
+ {
+ var c = new MenuCursor();
+ c.Clamp(5);
+ c.Move(+1, 5);
+ c.Move(+1, 5);
+ c.Move(+1, 5);
+
+ c.Clamp(2);
+ Assert.Equal(1, c.Index);
+
+ c.Clamp(0);
+ Assert.Equal(-1, c.Index);
+ c.Move(+1, 0);
+ Assert.Equal(-1, c.Index);
+
+ c.Clamp(4);
+ Assert.Equal(0, c.Index);
+ }
+}
diff --git a/src/Godosa.Core.Tests/Runs/RouteLegsTests.cs b/src/Godosa.Core.Tests/Runs/RouteLegsTests.cs
new file mode 100644
index 0000000..31295ab
--- /dev/null
+++ b/src/Godosa.Core.Tests/Runs/RouteLegsTests.cs
@@ -0,0 +1,116 @@
+using Godosa.Core.Runs;
+using Xunit;
+
+namespace Godosa.Core.Tests.Runs;
+
+public class RouteLegsTests
+{
+ // 5×5 grid, rows top to bottom; '#' blocked, 's' soft (sealed by the game's scripts, opened later).
+ private static Func<long, long, bool> Grid(params string[] rows) => (x, y) => rows[y][(int)x] is '#' or 's';
+ private static Func<long, long, bool> Soft(params string[] rows) => (x, y) => rows[y][(int)x] == 's';
+
+ [Fact]
+ public void Path_GoesRoundAWall_StartExcluded()
+ {
+ var blocked = Grid(
+ ".#...",
+ ".#...",
+ ".#...",
+ ".....",
+ ".....");
+ // From 0,0 the only way past the wall is under it: down the left column to row 3, then up-right diagonally.
+ var path = RouteLegs.Path((0, 0), (2, 0), 5, 5, blocked);
+ Assert.NotNull(path);
+ Assert.Equal((2, 0), path![^1]);
+ Assert.DoesNotContain((0L, 0L), path);
+ Assert.Equal(6, path.Count); // 0,1 0,2 1,3 2,2 2,1 2,0
+ Assert.All(path, c => Assert.False(blocked(c.X, c.Y)));
+ }
+
+ [Fact]
+ public void Path_NoWay_Null()
+ {
+ var blocked = Grid(
+ "..#..",
+ "..#..",
+ "..#..",
+ "..#..",
+ "..#..");
+ Assert.Null(RouteLegs.Path((0, 0), (4, 0), 5, 5, blocked));
+ }
+
+ // The game's router takes a leg only within 2 cells (Chebyshev): from each stop the farthest accepted path cell.
+ [Fact]
+ public void Legs_FarthestAcceptedStopEachTime()
+ {
+ var path = new List<(long X, long Y)> { (1, 0), (2, 0), (3, 0), (4, 0), (5, 0), (6, 0), (7, 0) };
+ var legs = RouteLegs.Legs((0, 0), path, (a, b) => Math.Max(Math.Abs(a.X - b.X), Math.Abs(a.Y - b.Y)) <= 2);
+ Assert.Equal([(2L, 0L), (4L, 0L), (6L, 0L), (7L, 0L)], legs);
+ }
+
+ [Fact]
+ public void Legs_RouterAcceptsNothing_NullWithTheStuckStop()
+ {
+ var path = new List<(long X, long Y)> { (1, 0), (2, 0) };
+ Assert.Null(RouteLegs.Legs((0, 0), path, (a, b) => b == (1, 0), out var stuck));
+ Assert.Equal((1, 0), stuck);
+ }
+
+ // Closed off only by a sealed (soft) cell: the seals a way would cross once open, start to goal.
+ [Fact]
+ public void Seals_ListedInOrder()
+ {
+ string[] rows =
+ [
+ "..s..",
+ "#####",
+ ".....",
+ ".....",
+ ".....",
+ ];
+ Assert.Null(RouteLegs.Path((0, 0), (4, 0), 5, 5, Grid(rows)));
+ Assert.Equal([(2L, 0L)], RouteLegs.Seals((0, 0), (4, 0), 5, 5, Grid(rows), Soft(rows)));
+ Assert.Empty(RouteLegs.Seals((0, 0), (4, 4), 5, 5, Grid(rows), Soft(rows))); // walled, not sealed
+ }
+
+ [Fact]
+ public void Seals_CappedAndNoneWhenOpenAlready()
+ {
+ string[] twice =
+ [
+ ".s.s.",
+ "#####",
+ ".....",
+ ".....",
+ ".....",
+ ];
+ Assert.Equal([(1L, 0L), (3L, 0L)], RouteLegs.Seals((0, 0), (4, 0), 5, 5, Grid(twice), Soft(twice)));
+ Assert.Equal([(1L, 0L)], RouteLegs.Seals((0, 0), (4, 0), 5, 5, Grid(twice), Soft(twice), max: 1));
+ // Open the long way round (row 2): a seal on the short way is no reason to wait.
+ string[] detour =
+ [
+ "..s..",
+ "#.#.#",
+ ".....",
+ ".....",
+ ".....",
+ ];
+ Assert.Empty(RouteLegs.Seals((0, 0), (4, 0), 5, 5, Grid(detour), Soft(detour)));
+ }
+
+ // Hops: walk to a portal, click it, arrive at its exit; BFS over portal exits.
+ [Fact]
+ public void Hops_ThroughTwoPortals()
+ {
+ // Three rooms on a line, x 0–9 / 10–19 / 20–29 (walking stays inside a room); portal A in room 1 at 5 → 12, B in
+ // room 2 at 18 → 25; a dead-end portal C in room 1 at 2 → 40.
+ static int Room(long x) => (int)(x / 10);
+ bool Reaches(long from, long to) => Room(from) == Room(to);
+ var portals = new[] { ("A", 5L, 12L), ("B", 18L, 25L), ("C", 2L, 40L) };
+ var hops = RouteLegs.Hops(1L, 27L, portals, p => p.Item2, p => p.Item3, Reaches);
+ Assert.NotNull(hops);
+ Assert.Equal(["A", "B"], hops!.Select(p => p.Item1));
+ Assert.Null(RouteLegs.Hops(1L, 35L, portals, p => p.Item2, p => p.Item3, Reaches));
+ Assert.Empty(RouteLegs.Hops(1L, 7L, portals, p => p.Item2, p => p.Item3, Reaches)!);
+ }
+}
diff --git a/src/Godosa.Core.Tests/Runs/RunsTests.cs b/src/Godosa.Core.Tests/Runs/RunsTests.cs
new file mode 100644
index 0000000..1b6d6d4
--- /dev/null
+++ b/src/Godosa.Core.Tests/Runs/RunsTests.cs
@@ -0,0 +1,149 @@
+using Godosa.Core.Runs;
+using Xunit;
+
+namespace Godosa.Core.Tests.Runs;
+
+public class RunsTests
+{
+ private static RunFlags Parse(params string[] args)
+ {
+ var flags = new RunFlags();
+ for (var i = 0; i < args.Length; i++)
+ Assert.True(RunFlags.TryTake(args, ref i, ref flags), args[i]);
+ return flags;
+ }
+
+ [Fact]
+ public void Flags_DeterministicWithAndWithoutSeed()
+ {
+ Assert.Equal(new RunFlags(Deterministic: true, Seed: 7), Parse("--deterministic", "7"));
+ Assert.Equal(new RunFlags(Deterministic: true), Parse("--deterministic"));
+ Assert.Equal(1UL, Parse("--deterministic").SeedOr(1));
+ Assert.Equal(7UL, Parse("--deterministic", "7").SeedOr(1));
+ }
+
+ [Fact]
+ public void Flags_SeedNotTakenWhenNextIsAnOption()
+ {
+ string[] args = ["--deterministic", "--mute"];
+ var flags = new RunFlags();
+ var i = 0;
+ Assert.True(RunFlags.TryTake(args, ref i, ref flags));
+ Assert.Equal(0, i);
+ i = 1;
+ Assert.False(RunFlags.TryTake(args, ref i, ref flags));
+ }
+
+ [Fact]
+ public void Flags_DigestEveryAndHeadless()
+ {
+ Assert.Equal(new RunFlags(Digest: true), Parse("--digest"));
+ Assert.Equal(new RunFlags(Digest: true, DigestEvery: 60), Parse("--digest", "every=60"));
+ Assert.Equal(new RunFlags(Deterministic: true, Headless: true), Parse("--headless"));
+ Assert.Equal(new RunFlags(Deterministic: true, Seed: 3, Headless: true), Parse("--headless", "--deterministic", "3"));
+ }
+
+ [Fact]
+ public void Flags_BadDigestEvery_Throws() =>
+ Assert.Throws<ArgumentException>(() => Parse("--digest", "every=0"));
+
+ [Fact]
+ public void Clock_DeterministicFixedStep()
+ {
+ var clock = new FrameClock(true);
+ Assert.Equal(1.0 / 60, clock.Next(0.5));
+ Assert.Equal(1.0 / 60, clock.Next(0.001));
+ Assert.Equal(2, clock.Frame);
+ Assert.Equal(2.0 / 60, clock.Seconds, 12);
+ }
+
+ [Fact]
+ public void Clock_WallClamped()
+ {
+ var clock = new FrameClock(false);
+ Assert.Equal(0.1, clock.Next(0.5));
+ Assert.Equal(0.02, clock.Next(0.02));
+ Assert.Equal(0, clock.Next(-1));
+ Assert.Equal(3, clock.Frame);
+ }
+
+ [Fact]
+ public void Clock_Held_NoTimeNoFrame()
+ {
+ var clock = new FrameClock(true) { Held = true };
+ Assert.Equal(0, clock.Next(1.0 / 60));
+ Assert.Equal(0, clock.Frame);
+ Assert.Equal(0, clock.Seconds);
+ clock.Held = false;
+ clock.Next(0);
+ Assert.Equal(1, clock.Frame);
+ }
+
+ // Published FNV-1a 64 vectors (isthe.com/chongo/tech/comp/fnv).
+ [Theory]
+ [InlineData("", 0xcbf29ce484222325UL)]
+ [InlineData("a", 0xaf63dc4c8601ec8cUL)]
+ [InlineData("foobar", 0x85944171f73967e8UL)]
+ public void Digest_FnvVectors(string text, ulong expected)
+ {
+ var d = new StateDigest();
+ d.Add(text);
+ Assert.Equal(expected, d.Value);
+ Assert.Equal(expected, StateDigest.Of(System.Text.Encoding.UTF8.GetBytes(text)));
+ }
+
+ // Hand-checked: FNV-1a 64 of the little-endian bytes 01 00 00 00 and of 1.5 as an IEEE double.
+ [Fact]
+ public void Digest_NumbersLittleEndian()
+ {
+ var u = new StateDigest();
+ u.Add(1u);
+ Assert.Equal(0xad2aca7747985764UL, u.Value);
+ var d = new StateDigest();
+ d.Add(1.5);
+ Assert.Equal(0xaa95e93229a27c80UL, d.Value);
+ Assert.Equal("aa95e93229a27c80", d.Hex);
+ }
+
+ [Fact]
+ public void Line_Format() =>
+ Assert.Equal("digest 120 3.5 000000000000beef", DigestLine.Format(120, 3.5, 0xbeef));
+
+ [Fact]
+ public void Line_FormatRoundsGameTime() =>
+ Assert.Equal("digest 1 0.017 0000000000000001", DigestLine.Format(1, 1.0 / 60, 1));
+
+ [Fact]
+ public void Line_Due()
+ {
+ Assert.True(DigestLine.Due(60, 60));
+ Assert.False(DigestLine.Due(59, 60));
+ Assert.False(DigestLine.Due(0, 60));
+ Assert.False(DigestLine.Due(60, 0));
+ }
+
+ // SplitMix64 seeded 0: outputs e220a8397b1dcdaf, 6e789e6aa1b965f4 (published reference sequence).
+ [Fact]
+ public void Seeds_SplitMixReference()
+ {
+ Assert.Equal(0xe220a8397b1dcdafUL, SeedStreams.Of(0, 0));
+ Assert.Equal(0x6e789e6aa1b965f4UL, SeedStreams.Of(0, 1));
+ Assert.Equal(0x63cbe1e459320dd7UL, SeedStreams.Of(7, 0));
+ }
+
+ [Fact]
+ public void Loop_StopsWhenFrameReturnsFalse()
+ {
+ var seen = new List<long>();
+ var (frames, capped) = HeadlessLoop.Run(n => { seen.Add(n); return n < 3; }, null);
+ Assert.Equal([1L, 2, 3], seen);
+ Assert.Equal(3, frames);
+ Assert.False(capped);
+ }
+
+ [Fact]
+ public void Loop_FramesLimit() => Assert.Equal((5L, false), HeadlessLoop.Run(_ => true, 5));
+
+ [Fact]
+ public void Loop_Cap() => Assert.Equal((10L, true), HeadlessLoop.Run(_ => true, null, cap: 10));
+}
diff --git a/src/Godosa.Core.Tests/Runs/Scripts/ActionInputTests.cs b/src/Godosa.Core.Tests/Runs/Scripts/ActionInputTests.cs
new file mode 100644
index 0000000..1b53910
--- /dev/null
+++ b/src/Godosa.Core.Tests/Runs/Scripts/ActionInputTests.cs
@@ -0,0 +1,47 @@
+using Godosa.Core.Runs;
+using Godosa.Core.Runs.Scripts;
+using Xunit;
+using static Godosa.Core.Tests.Runs.Scripts.ScriptRunnerTests;
+
+namespace Godosa.Core.Tests.Runs.Scripts;
+
+/// <summary>Multi-click verbs (walk-path, fight, travel-to): a poll may hand the runner more input; the plan plays from
+/// the next frame (its frame 0), the action is not polled until it is sent.</summary>
+public sealed class ActionInputTests
+{
+ [Fact]
+ public void PollInput_PlaysFromTheNextFrame_ThenPollsAgain()
+ {
+ var g = new FakeGame();
+ var polls = new List<int>();
+ var follow = new InputPlan([new(0, new InputEvent(InputKind.KeyDown, Key: "B")), new(2, new InputEvent(InputKind.KeyUp, Key: "B"))]);
+ g.ActionTable["walk"] = new FakeAction(VerbKind.Player,
+ _ => new ActionStart(new InputPlan([new(0, new InputEvent(InputKind.KeyDown, Key: "A"))])),
+ n =>
+ {
+ polls.Add(n);
+ return n == 1 ? StepStatus.Input(follow) : StepStatus.Done;
+ });
+ var (r, output) = Run("format 1\nmode player\nwalk\nlog done", g);
+
+ Assert.True(r.Outcome == RunOutcome.Passed, string.Join("\n", output));
+ Assert.Equal(["A", "B", "B"], g.Sent.Select(s => s.Event.Key));
+ var a = g.Sent[0].Frame;
+ Assert.Equal(a + 1, g.Sent[1].Frame); // polled after frame a → B on the next frame
+ Assert.Equal(a + 3, g.Sent[2].Frame); // its frame 2
+ Assert.Equal([1, 2], polls); // no poll while the follow-up plan is unsent
+ Assert.Contains("done", output);
+ }
+
+ [Fact]
+ public void PollInput_HalfSent_ReleasedOnFailure()
+ {
+ var g = new FakeGame();
+ var follow = new InputPlan([new(0, new InputEvent(InputKind.KeyDown, Key: "B")), new(50, new InputEvent(InputKind.KeyUp, Key: "B"))]);
+ g.ActionTable["walk"] = new FakeAction(VerbKind.Player, _ => new ActionStart(), n => StepStatus.Input(follow));
+ var (r, _) = Run("format 1\nmode player\nwalk", g, new ScriptRunOptions { StepCap = 10 });
+
+ Assert.Equal(RunOutcome.Failed, r.Outcome);
+ Assert.Equal(InputKind.KeyUp, g.Sent[^1].Event.Kind); // the held key is let go
+ }
+}
diff --git a/src/Godosa.Core.Tests/Runs/Scripts/CheckpointCacheTests.cs b/src/Godosa.Core.Tests/Runs/Scripts/CheckpointCacheTests.cs
new file mode 100644
index 0000000..a84cf94
--- /dev/null
+++ b/src/Godosa.Core.Tests/Runs/Scripts/CheckpointCacheTests.cs
@@ -0,0 +1,185 @@
+using Godosa.Core.Runs.Scripts;
+using Xunit;
+
+namespace Godosa.Core.Tests.Runs.Scripts;
+
+public sealed class CheckpointCacheTests : IDisposable
+{
+ private readonly string _dir = Path.Combine(AppContext.BaseDirectory, "rs-tmp", Guid.NewGuid().ToString("N"));
+
+ public void Dispose()
+ {
+ if (Directory.Exists(_dir))
+ Directory.Delete(_dir, recursive: true);
+ }
+
+ private (ScriptRunner Runner, RunResult Result, List<string> Output) Run(string main, FakeGame g, ScriptRunOptions? o = null, Dictionary<string, string>? files = null)
+ {
+ var all = new Dictionary<string, string>(files ?? []) { ["main.steps"] = main };
+ var output = new List<string>();
+ var runner = new ScriptRunner(ScriptLoader.Load("main.steps", new MemoryScriptSource(all), new Dictionary<string, string>()), g,
+ o ?? new ScriptRunOptions { CacheDir = _dir }, output.Add);
+ return (runner, runner.Run(10_000), output);
+ }
+
+ private const string Script = "format 1\nmode player\nkey A\ncheckpoint one\nkey B\ncheckpoint two\nkey C";
+
+ // --no-snapshots: checkpoints are markers only — no digest (a whole-world serialization: seconds late in a big game,
+ // the window froze), nothing cached; a later run resumes from nothing.
+ [Fact]
+ public void NoSnapshots_CheckpointsNeitherDigestNorCache()
+ {
+ var g = new FakeGame();
+ var (r, res, output) = Run(Script, g, new ScriptRunOptions { CacheDir = _dir, Snapshots = false });
+ Assert.Equal(RunOutcome.Passed, res.Outcome);
+ Assert.Equal(0, g.Digests);
+ Assert.Equal(["marker", "marker"], r.Report!.Checkpoints.Select(c => c.Source));
+ Assert.Contains("checkpoint two f=4", output);
+ Assert.False(Directory.Exists(_dir) && Directory.EnumerateFiles(_dir, "*", SearchOption.AllDirectories).Any());
+ var (_, stopped, _) = Run(Script, new FakeGame(), new ScriptRunOptions { CacheDir = _dir, Snapshots = false, Until = "one" });
+ Assert.Equal((RunOutcome.Stopped, 2L), (stopped.Outcome, stopped.Frames));
+ }
+
+ [Fact]
+ public void SecondRun_ResumesAtTheLastCachedCheckpoint()
+ {
+ var first = new FakeGame();
+ first.OnAdvance = f => first.State += 1;
+ var (r1, _, _) = Run(Script, first);
+ Assert.Equal(6, r1.Report!.Frames);
+ Assert.Equal(["replayed", "replayed"], r1.Report.Checkpoints.Select(c => c.Source));
+
+ var second = new FakeGame();
+ second.OnAdvance = f => second.State += 1;
+ var (r2, res, output) = Run(Script, second);
+ Assert.Equal(RunOutcome.Passed, res.Outcome);
+ Assert.Contains("resumed at two (cache) f=4", output);
+ Assert.Equal(["C"], second.Sent.Where(s => s.Event.Kind == InputKind.KeyDown).Select(s => s.Event.Key));
+ Assert.Equal((6L, 6), (res.Frames, second.State)); // restored frame 4 / state 4, key C = 2 frames → 6 / 6
+ Assert.Equal(1, r2.Report!.CacheHits);
+ }
+
+ // A window restore rebuilds the game view after the frame: the runner pauses (Restoring) for that frame, no game frame
+ // passes, and the run ends as an immediate restore would.
+ [Fact]
+ public void DeferredRestore_PausesOneFrame_SameResult()
+ {
+ var first = new FakeGame();
+ first.OnAdvance = f => first.State += 1;
+ Run(Script, first);
+
+ var second = new FakeGame { RestoreDeferred = true };
+ second.OnAdvance = f => second.State += 1;
+ var runner = new ScriptRunner(ScriptLoader.Load("main.steps", new MemoryScriptSource(new Dictionary<string, string> { ["main.steps"] = Script }),
+ new Dictionary<string, string>()), second, new ScriptRunOptions { CacheDir = _dir }, _ => { });
+ runner.BeforeFrame();
+ Assert.True(runner.Restoring);
+ Assert.Empty(second.Sent);
+
+ var third = new FakeGame { RestoreDeferred = true };
+ third.OnAdvance = f => third.State += 1;
+ var (r3, res, _) = Run(Script, third);
+ Assert.False(r3.Restoring);
+ Assert.Equal((6L, 6), (res.Frames, third.State)); // the paused frame advanced nothing
+ }
+
+ [Fact]
+ public void ChangedStepBeforeACheckpoint_InvalidatesIt_LaterStepsDoNot()
+ {
+ Run(Script, new FakeGame());
+ var (r, _, output) = Run(Script.Replace("key B", "key X"), new FakeGame());
+ Assert.Contains("resumed at one (cache) f=2", output);
+ var (r3, _, output3) = Run(Script.Replace("key C", "key Y"), new FakeGame());
+ Assert.Contains("resumed at two (cache) f=4", output3);
+ }
+
+ [Fact]
+ public void CommentEdits_KeepTheCache()
+ {
+ Run(Script, new FakeGame());
+ var (_, _, output) = Run("# new comment\n" + Script.Replace("key A", "key A # aim"), new FakeGame());
+ Assert.Contains("resumed at two (cache) f=4", output);
+ }
+
+ [Fact]
+ public void SeedContentOrVars_InvalidateEverything()
+ {
+ Run(Script, new FakeGame());
+ var (_, _, output) = Run(Script, new FakeGame(), new ScriptRunOptions { CacheDir = _dir, Seed = 5 });
+ Assert.DoesNotContain(output, l => l.StartsWith("resumed"));
+ var (_, _, output2) = Run(Script, new FakeGame { ContentVersion = "c2" });
+ Assert.DoesNotContain(output2, l => l.StartsWith("resumed"));
+ }
+
+ [Fact]
+ public void NoCache_ReplaysAndWarnsOnDigestMismatch()
+ {
+ Run(Script, new FakeGame());
+ var drift = new FakeGame { State = 100 };
+ var (r, _, output) = Run(Script, drift, new ScriptRunOptions { CacheDir = _dir, UseCache = false });
+ Assert.DoesNotContain(output, l => l.StartsWith("resumed"));
+ Assert.Contains(r.Report!.Warnings, w => w.StartsWith("checkpoint one digest differs from cache"));
+ // Cache replaced: a cached run now resumes from the new state.
+ var after = new FakeGame();
+ Run(Script, after);
+ Assert.Equal(100, after.State);
+ }
+
+ [Fact]
+ public void From_RequiresACachedCheckpoint_Until_StopsEarly()
+ {
+ var (_, missing, out1) = Run(Script, new FakeGame(), new ScriptRunOptions { CacheDir = _dir, From = "two" });
+ Assert.Equal((RunOutcome.Invalid, 2), (missing.Outcome, missing.ExitCode));
+ Assert.Contains("checkpoint two not cached", out1);
+ var (_, unknown, out2) = Run(Script, new FakeGame(), new ScriptRunOptions { CacheDir = _dir, From = "nope" });
+ Assert.Contains("unknown checkpoint nope", out2);
+ var g = new FakeGame();
+ var (_, stopped, _) = Run(Script, g, new ScriptRunOptions { CacheDir = _dir, Until = "one" });
+ Assert.Equal((RunOutcome.Stopped, 0, 2L), (stopped.Outcome, stopped.ExitCode, stopped.Frames));
+ var (_, from, outFrom) = Run(Script, new FakeGame(), new ScriptRunOptions { CacheDir = _dir, From = "one" });
+ Assert.Equal(RunOutcome.Passed, from.Outcome);
+ Assert.Contains("resumed at one (cache) f=2", outFrom);
+ }
+
+ // Review focus 3: a checkpoint inside an include + def call restores vars, output level and labels.
+ [Fact]
+ public void Resume_InsideIncludeAndCall_RestoresScopes()
+ {
+ var files = new Dictionary<string, string>
+ {
+ ["ch.steps"] = "format 1\noutput steps\ndef d(who):\n set inner = ${who}-x\n checkpoint deep\n log \"${who} ${inner} ${top}\"\nstep s:\n call d(gil)\n key Q",
+ };
+ const string main = "format 1\nmode player\nset top = T\ninclude ch.steps\nkey Z";
+ var (_, _, replay) = Run(main, new FakeGame(), files: files);
+ var (_, _, resumed) = Run(main, new FakeGame(), files: files);
+ Assert.Contains("resumed at deep (cache) f=0", resumed);
+ string[] Tail(List<string> o) => [.. o.Where(l => !l.StartsWith("resumed") && !l.StartsWith("checkpoint") && !l.StartsWith("digest"))];
+ Assert.Equal(Tail(replay), Tail(resumed));
+ Assert.Contains("gil gil-x T", resumed);
+ Assert.Contains(resumed, l => l.StartsWith("[ch.steps:9 s] key Q done")); // output level steps + label restored
+ }
+
+ [Fact]
+ public void Eviction_KeepsTotalUnderTheCap()
+ {
+ var cache = new CheckpointCache(_dir, capBytes: 300);
+ var state = new RunnerState(0, 0, [], []);
+ for (var i = 0; i < 5; i++)
+ {
+ cache.Save($"k{i}", new CachedCheckpoint(state, new byte[100]));
+ File.SetLastWriteTimeUtc(Path.Combine(_dir, $"k{i}.snap"), new DateTime(2026, 1, 1, 0, 0, i, DateTimeKind.Utc));
+ }
+ var left = Directory.GetFiles(_dir, "*.snap").Select(Path.GetFileNameWithoutExtension).Order().ToList();
+ Assert.True(Directory.GetFiles(_dir, "*.snap").Sum(f => new FileInfo(f).Length) <= 300);
+ Assert.Contains("k4", left);
+ Assert.DoesNotContain("k0", left);
+ }
+
+ [Fact]
+ public void NoSnapshotSupport_NoCache()
+ {
+ var (r, _, _) = Run(Script, new FakeGame { SupportsSnapshot = false });
+ Assert.False(Directory.Exists(_dir) && Directory.GetFiles(_dir).Length > 0);
+ Assert.Equal(0, r.Report!.CacheMisses);
+ }
+}
diff --git a/src/Godosa.Core.Tests/Runs/Scripts/ConditionTests.cs b/src/Godosa.Core.Tests/Runs/Scripts/ConditionTests.cs
new file mode 100644
index 0000000..d14ea6a
--- /dev/null
+++ b/src/Godosa.Core.Tests/Runs/Scripts/ConditionTests.cs
@@ -0,0 +1,63 @@
+using Godosa.Core.Runs.Scripts;
+using Xunit;
+
+namespace Godosa.Core.Tests.Runs.Scripts;
+
+public class ConditionTests
+{
+ private sealed class Ctx : IConditionContext
+ {
+ public Dictionary<string, RunValue> Values { get; } = new()
+ {
+ ["quest(1)"] = RunValue.Of(2), ["map"] = RunValue.Of("tarant"), ["dialog-open"] = RunValue.Of(true),
+ ["global(gold)"] = RunValue.Of(500), ["alive"] = RunValue.Of(false),
+ };
+
+ public bool HasProbe(string name) => name is "quest" or "map" or "dialog-open" or "global" or "alive";
+
+ public RunValue Probe(string name, IReadOnlyList<string> args) =>
+ Values[args.Count == 0 ? name : $"{name}({string.Join(",", args)})"];
+
+ public long? Constant(string name) => name switch { "accepted" => 2, "completed" => 4, _ => null };
+ }
+
+ private static bool Test(string text) => ConditionParser.Parse(text).Test(new Ctx());
+
+ [Theory]
+ [InlineData("quest(1) >= accepted", true)]
+ [InlineData("quest(1) = completed", false)]
+ [InlineData("map = \"tarant\"", true)]
+ [InlineData("map != \"ashbury\"", true)]
+ [InlineData("dialog-open", true)]
+ [InlineData("alive", false)]
+ [InlineData("not alive", true)]
+ [InlineData("dialog-open and global(gold) > 499", true)]
+ [InlineData("alive or global(gold) < 100", false)]
+ [InlineData("not (alive or dialog-open)", false)]
+ [InlineData("alive or dialog-open and quest(1) = 2", true)] // and binds tighter than or
+ [InlineData("global(gold) <= 500", true)]
+ public void Evaluates(string text, bool expected) => Assert.Equal(expected, Test(text));
+
+ [Fact]
+ public void MismatchedTypes_Fault() =>
+ Assert.Equal("cannot compare tarant with 3", Assert.Throws<ScriptFault>(() => Test("map > 3")).Message);
+
+ [Theory]
+ [InlineData("quest(1) >=")]
+ [InlineData("(alive")]
+ [InlineData("alive alive")]
+ [InlineData("\"open")]
+ public void BadSyntax_Throws(string text) => Assert.Throws<FormatException>(() => ConditionParser.Parse(text));
+
+ [Fact]
+ public void ListsProbesAndBareNames()
+ {
+ var c = ConditionParser.Parse("quest(1) >= accepted and not dialog-open");
+ Assert.Equal(["quest(1)", "accepted", "dialog-open"], c.Probes().Select(p => p.Args.Count == 0 ? p.Name : $"{p.Name}({string.Join(",", p.Args)})"));
+ Assert.Equal(["accepted", "dialog-open"], c.Bare());
+ }
+
+ [Fact]
+ public void Describe_ListsProbeValues() =>
+ Assert.Equal("quest(1)=2 map=tarant", Cond.Describe(ConditionParser.Parse("quest(1) > 3 and map = \"x\""), new Ctx()));
+}
diff --git a/src/Godosa.Core.Tests/Runs/Scripts/FakeGame.cs b/src/Godosa.Core.Tests/Runs/Scripts/FakeGame.cs
new file mode 100644
index 0000000..8318148
--- /dev/null
+++ b/src/Godosa.Core.Tests/Runs/Scripts/FakeGame.cs
@@ -0,0 +1,100 @@
+using Godosa.Core.Runs.Scripts;
+
+namespace Godosa.Core.Tests.Runs.Scripts;
+
+/// <summary>Scriptable game for runner tests: probes are a dictionary, actions are lambdas, input is recorded with the
+/// frame it arrived on.</summary>
+public sealed class FakeGame : IRunGame, IInputRouter
+{
+ public long Frame { get; private set; }
+ public List<(long Frame, InputEvent Event)> Sent { get; } = [];
+ public Dictionary<string, Func<IReadOnlyList<string>, RunValue>> ProbeFns { get; } = new();
+ public Dictionary<string, IRunAction> ActionTable { get; } = new();
+ public Dictionary<string, (int X, int Y)> Objects { get; } = new();
+ public Dictionary<string, long> ConstantTable { get; } = new() { ["accepted"] = 2 };
+ public Action<long>? OnAdvance { get; set; }
+ public int State { get; set; }
+ public FakeRng? RngImpl { get; set; } = new();
+
+ public string Name => "fake";
+ public string ContentVersion { get; set; } = "c1";
+ public IInputRouter Input => this;
+ public IReadOnlyDictionary<string, IProbe> Probes => ProbeFns.ToDictionary(p => p.Key, p => (IProbe)new FnProbe(p.Value));
+ public IReadOnlyDictionary<string, long> Constants => ConstantTable;
+ public IReadOnlyDictionary<string, ILocator> Locators => new Dictionary<string, ILocator> { ["obj"] = new ObjLocator(this) };
+ public IReadOnlyDictionary<string, IRunAction> Actions => ActionTable;
+ public IRunRng? Rng => RngImpl;
+ public bool SupportsSnapshot { get; set; } = true;
+ public bool RestoreDeferred { get; set; }
+
+ public Action<InputEvent>? OnSend { get; set; }
+ public bool RestoreThrows { get; set; }
+ public string? Refusal { get; set; }
+ public string? SnapshotRefusal() => Refusal;
+
+ public void Send(InputEvent e)
+ {
+ OnSend?.Invoke(e);
+ Sent.Add((Frame, e));
+ }
+
+ public byte[] SnapshotSave() => BitConverter.GetBytes(State).Concat(BitConverter.GetBytes(Frame)).ToArray();
+
+ public void SnapshotRestore(byte[] data)
+ {
+ if (RestoreThrows)
+ throw new IOException("restore broke");
+ State = BitConverter.ToInt32(data, 0);
+ Frame = BitConverter.ToInt64(data, 4);
+ }
+
+ public int Digests { get; private set; }
+ public ulong Digest() { Digests++; return (ulong)State * 1_000_003UL + (ulong)Frame; }
+ public string Dump() => $"state={State} frame={Frame}";
+
+ public void Advance()
+ {
+ Frame++;
+ OnAdvance?.Invoke(Frame);
+ }
+
+ private sealed class FnProbe(Func<IReadOnlyList<string>, RunValue> fn) : IProbe
+ {
+ public int MinArgs => 0;
+ public int MaxArgs => 4;
+ public RunValue Eval(IReadOnlyList<string> args) => fn(args);
+ }
+
+ private sealed class ObjLocator(FakeGame g) : ILocator
+ {
+ public LocatorResult Locate(IReadOnlyList<string> args) =>
+ g.Objects.TryGetValue(args[0], out var p) ? LocatorResult.Ok(p.X, p.Y) : LocatorResult.Fail($"no object {args[0]} on screen");
+ }
+}
+
+public sealed class FakeRng : IRunRng
+{
+ public List<string> Calls { get; } = [];
+ public void Seed(long seed) => Calls.Add($"seed {seed}");
+ public void ForceNext(string kind, long value) => Calls.Add($"next {kind} {value}");
+}
+
+/// <summary>An action: optional input plan, then Poll() answers from a function of the frames polled so far.</summary>
+public sealed class FakeAction(VerbKind kind, Func<IReadOnlyList<string>, ActionStart> begin, Func<int, StepStatus>? poll = null,
+ int min = 0, int max = 4) : IRunAction
+{
+ private int _polls;
+ public VerbKind Kind => kind;
+ public int MinArgs => min;
+ public int MaxArgs => max;
+ public List<IReadOnlyList<string>> Begun { get; } = [];
+
+ public ActionStart Begin(IReadOnlyList<string> args)
+ {
+ Begun.Add(args);
+ _polls = 0;
+ return begin(args);
+ }
+
+ public StepStatus Poll() => (poll ?? (_ => StepStatus.Done))(++_polls);
+}
diff --git a/src/Godosa.Core.Tests/Runs/Scripts/ReviewFixTests.cs b/src/Godosa.Core.Tests/Runs/Scripts/ReviewFixTests.cs
new file mode 100644
index 0000000..1b7257b
--- /dev/null
+++ b/src/Godosa.Core.Tests/Runs/Scripts/ReviewFixTests.cs
@@ -0,0 +1,157 @@
+using Godosa.Core.Runs;
+using Godosa.Core.Runs.Scripts;
+using Xunit;
+using static Godosa.Core.Tests.Runs.Scripts.ScriptRunnerTests;
+
+namespace Godosa.Core.Tests.Runs.Scripts;
+
+/// <summary>Final-review findings (lib core): each test failed before its fix.</summary>
+public sealed class ReviewFixTests : IDisposable
+{
+ private readonly string _dir = Path.Combine(AppContext.BaseDirectory, "rs-tmp", Guid.NewGuid().ToString("N"));
+
+ public void Dispose()
+ {
+ if (Directory.Exists(_dir))
+ Directory.Delete(_dir, recursive: true);
+ }
+
+ private const string Script = "format 1\nmode player\nkey A\ncheckpoint one\nkey B\ncheckpoint two\nkey C";
+
+ [Fact]
+ public void NoArgVerb_AndEmptySet_Run()
+ {
+ var g = new FakeGame();
+ g.ActionTable["close-panel"] = new FakeAction(VerbKind.Player, _ => new ActionStart(Status: StepStatus.Done), min: 0, max: 0);
+ var (r, output) = Run("format 1\nmode player\nset x =\nclose-panel\nlog \"[${x}]\"", g);
+ Assert.Equal(RunOutcome.Passed, r.Outcome);
+ Assert.Contains("[]", output);
+ }
+
+ // A probe value (dialog text) with quotes inside a quoted argument stays text: the line is split into tokens first.
+ [Fact]
+ public void QuotedArgument_ValueWithQuotes_StaysOneToken()
+ {
+ var (r, output) = Run("format 1\nmode player\nset x = \"\\\"Hi,\\\" he said\"\nlog \"R: ${x}\"", new FakeGame());
+ Assert.Equal(RunOutcome.Passed, r.Outcome);
+ Assert.Contains("R: \"Hi,\" he said", output);
+ }
+
+ [Fact]
+ public void SubstitutedValues_AreNotRescanned()
+ {
+ var values = new Dictionary<string, string> { ["d"] = "$", ["e"] = "{x}", ["raw"] = "${y}" };
+ Assert.Equal("${x}", VarExpander.Expand("${d}${e}", n => values.GetValueOrDefault(n), null));
+ Assert.Equal("say ${y}!", VarExpander.Expand("say ${raw}!", n => values.GetValueOrDefault(n), null));
+ }
+
+ [Theory]
+ [InlineData("log Done:", "log", "Done:")]
+ [InlineData("type name:", "type", "name:")]
+ public void LineEndingInColon_IsAVerbUnlessABlockKeyword(string line, string verb, string args)
+ {
+ var f = ScriptParser.Parse("main.steps", $"format 1\nmode debug\n{line}");
+ Assert.Empty(f.Errors);
+ var v = Assert.IsType<VerbNode>(f.Body.Single());
+ Assert.Equal((verb, args), (v.Verb, v.Args));
+ }
+
+ [Fact]
+ public void ThrowingInputRouter_FailsTheRun()
+ {
+ var g = new FakeGame { OnSend = _ => throw new InvalidOperationException("router down") };
+ var (r, output) = Run("format 1\nmode player\nkey R", g);
+ Assert.Equal(RunOutcome.Failed, r.Outcome);
+ Assert.Contains(output, l => l == "FAIL main.steps:3 key R: InvalidOperationException: router down");
+ }
+
+ [Fact]
+ public void CorruptCacheEntry_IsAMiss()
+ {
+ Run(Script, new FakeGame(), new ScriptRunOptions { CacheDir = _dir });
+ foreach (var f in Directory.GetFiles(_dir, "*.snap"))
+ File.WriteAllBytes(f, [.. "GRS1"u8, 3, 0, 0, 0, (byte)'{', (byte)'x', (byte)'!']);
+ var (r, output) = Run(Script, new FakeGame(), new ScriptRunOptions { CacheDir = _dir });
+ Assert.Equal(RunOutcome.Passed, r.Outcome);
+ Assert.DoesNotContain(output, l => l.StartsWith("resumed"));
+ }
+
+ // A game state the snapshot can't hold (an open dialog) → checkpoint not cached, run unharmed.
+ [Fact]
+ public void RefusedSnapshot_CheckpointNotCached()
+ {
+ var g = new FakeGame { Refusal = "dialog open" };
+ var output = new List<string>();
+ var runner = new ScriptRunner(ScriptLoader.Load("main.steps", new MemoryScriptSource(new Dictionary<string, string>
+ {
+ ["main.steps"] = Script,
+ }), new Dictionary<string, string>()), g, new ScriptRunOptions { CacheDir = _dir }, output.Add);
+ Assert.Equal(RunOutcome.Passed, runner.Run(1000).Outcome);
+ Assert.Equal(["checkpoint one not cached: dialog open", "checkpoint two not cached: dialog open"], runner.Report!.Warnings);
+ Assert.False(Directory.Exists(_dir) && Directory.GetFiles(_dir, "*.snap", SearchOption.AllDirectories).Length > 0);
+ }
+
+ [Fact]
+ public void FailingRestore_FailsTheRun()
+ {
+ Run(Script, new FakeGame(), new ScriptRunOptions { CacheDir = _dir });
+ var (r, output) = Run(Script, new FakeGame { RestoreThrows = true }, new ScriptRunOptions { CacheDir = _dir });
+ Assert.Equal((RunOutcome.Failed, 1), (r.Outcome, r.ExitCode));
+ Assert.Contains(output, l => l.Contains("IOException: restore broke"));
+ }
+
+ [Fact]
+ public void UnwritableCacheAndReportDirs_WarnInsteadOfThrowing()
+ {
+ Directory.CreateDirectory(_dir);
+ var file = Path.Combine(_dir, "file");
+ File.WriteAllText(file, "x");
+ var g = new FakeGame();
+ g.ProbeFns["no"] = _ => RunValue.Of(false);
+ var output = new List<string>();
+ var runner = new ScriptRunner(ScriptLoader.Load("main.steps", new MemoryScriptSource(new Dictionary<string, string>
+ {
+ ["main.steps"] = "format 1\nmode player\ncheckpoint one\nexpect no",
+ }), new Dictionary<string, string>()), g, new ScriptRunOptions { CacheDir = Path.Combine(file, "cache"), ReportDir = Path.Combine(file, "report") }, output.Add);
+ var r = runner.Run(1000);
+ Assert.Equal(RunOutcome.Failed, r.Outcome);
+ Assert.Contains(runner.Report!.Warnings, w => w.StartsWith("checkpoint cache not written:"));
+ Assert.Contains(output, l => l.StartsWith("warning: report not written:"));
+ }
+
+ [Fact]
+ public void Until_UnknownName_Invalid()
+ {
+ var (r, output) = Run(Script, new FakeGame(), new ScriptRunOptions { Until = "nowhere" });
+ Assert.Equal((RunOutcome.Invalid, 2), (r.Outcome, r.ExitCode));
+ Assert.Contains("unknown label or checkpoint nowhere", output);
+ }
+
+ [Fact]
+ public void Until_ResumesOnlyBeforeIt()
+ {
+ Run(Script, new FakeGame(), new ScriptRunOptions { CacheDir = _dir }); // caches one and two
+ var output = new List<string>();
+ var r = new ScriptRunner(ScriptLoader.Load("main.steps", new MemoryScriptSource(new Dictionary<string, string> { ["main.steps"] = Script }),
+ new Dictionary<string, string>()), new FakeGame(), new ScriptRunOptions { CacheDir = _dir, Until = "two" }, output.Add).Run(1000);
+ Assert.Contains("resumed at one (cache) f=2", output);
+ Assert.Equal((RunOutcome.Stopped, 4L), (r.Outcome, r.Frames));
+ }
+
+ [Fact]
+ public void ScriptSeedHeader_SeedsTheGame_CommandLineWins()
+ {
+ var g = new FakeGame();
+ var output = new List<string>();
+ var runner = new ScriptRunner(ScriptLoader.Load("main.steps", new MemoryScriptSource(new Dictionary<string, string> { ["main.steps"] = "format 1\nmode player\nseed 7\nkey A" }),
+ new Dictionary<string, string>()), g, new ScriptRunOptions(), output.Add);
+ runner.Run(1000);
+ Assert.Equal(["seed 7"], g.RngImpl!.Calls);
+ Assert.Equal(7UL, runner.Report!.Seed);
+ var g2 = new FakeGame();
+ var runner2 = new ScriptRunner(ScriptLoader.Load("main.steps", new MemoryScriptSource(new Dictionary<string, string> { ["main.steps"] = "format 1\nmode player\nseed 7\nkey A" }),
+ new Dictionary<string, string>()), g2, new ScriptRunOptions { Seed = 9 }, output.Add);
+ runner2.Run(1000);
+ Assert.Equal(["seed 9"], g2.RngImpl!.Calls);
+ }
+}
diff --git a/src/Godosa.Core.Tests/Runs/Scripts/RunReportTests.cs b/src/Godosa.Core.Tests/Runs/Scripts/RunReportTests.cs
new file mode 100644
index 0000000..00ee4b0
--- /dev/null
+++ b/src/Godosa.Core.Tests/Runs/Scripts/RunReportTests.cs
@@ -0,0 +1,83 @@
+using System.Text.Json;
+using Godosa.Core.Runs;
+using Godosa.Core.Runs.Scripts;
+using Xunit;
+
+namespace Godosa.Core.Tests.Runs.Scripts;
+
+public sealed class RunReportTests : IDisposable
+{
+ private readonly string _dir = Path.Combine(AppContext.BaseDirectory, "rs-tmp", Guid.NewGuid().ToString("N"));
+
+ public void Dispose()
+ {
+ if (Directory.Exists(_dir))
+ Directory.Delete(_dir, recursive: true);
+ }
+
+ private static (ScriptRunner Runner, RunResult Result, List<string> Output) Run(string main, FakeGame g, ScriptRunOptions o, Dictionary<string, string>? files = null)
+ {
+ var all = new Dictionary<string, string>(files ?? []) { ["main.steps"] = main };
+ var output = new List<string>();
+ var runner = new ScriptRunner(ScriptLoader.Load("main.steps", new MemoryScriptSource(all), new Dictionary<string, string>()), g, o, output.Add);
+ return (runner, runner.Run(10_000), output);
+ }
+
+ [Fact]
+ public void Failure_ReportHoldsChainStepReasonRecentDumpVars()
+ {
+ var g = new FakeGame { State = 7 };
+ g.ProbeFns["quest"] = _ => RunValue.Of(1);
+ var (runner, _, _) = Run("format 1\nmode player\nset who = Gilbert\nkey A\ninclude act2.steps", g, new ScriptRunOptions { ReportDir = _dir },
+ new() { ["act2.steps"] = "format 1\nstep talk-gilbert:\n key B\n expect quest(5) >= accepted" });
+ var f = runner.Report!.Failure!;
+ Assert.Equal("act2.steps:4", f.At);
+ Assert.Equal(["main.steps:5"], f.Chain);
+ Assert.Equal(("expect quest(5) >= accepted", "talk-gilbert"), (f.Step, f.Label));
+ Assert.Equal("expect failed: quest(5) >= accepted (quest(5)=1)", f.Reason);
+ Assert.Equal(["main.steps:4 key A f=2", "act2.steps:3 key B f=4"], f.Recent);
+ Assert.Equal("state=7 frame=4", f.Dump);
+ Assert.Equal("Gilbert", f.Vars["who"]);
+ Assert.True(File.Exists(Path.Combine(_dir, "report.txt")));
+ Assert.True(File.Exists(Path.Combine(_dir, "fail.snap")));
+ var json = JsonDocument.Parse(File.ReadAllText(Path.Combine(_dir, "report.json")));
+ Assert.Equal("Failed", json.RootElement.GetProperty("Outcome").GetString());
+ var text = File.ReadAllText(Path.Combine(_dir, "report.txt"));
+ Assert.Contains("main.steps:5 → act2.steps:4", text);
+ Assert.Contains("state=7 frame=4", text);
+ }
+
+ [Fact]
+ public void Recent_KeepsLast20()
+ {
+ var g = new FakeGame();
+ g.ProbeFns["no"] = _ => RunValue.Of(false);
+ var steps = string.Join("\n", Enumerable.Range(1, 25).Select(n => $"key K{n}"));
+ var (runner, _, _) = Run($"format 1\nmode player\n{steps}\nexpect no", g, new ScriptRunOptions());
+ var recent = runner.Report!.Failure!.Recent;
+ Assert.Equal(20, recent.Count);
+ Assert.StartsWith("main.steps:8 key K6", recent[0]);
+ }
+
+ [Fact]
+ public void Summary_StepsFramesCheckpointsDigests()
+ {
+ var g = new FakeGame { State = 3 };
+ var (runner, r, output) = Run("format 1\nmode player\nkey A\ncheckpoint one\nstep s:\n key B", g, new ScriptRunOptions { Seed = 9 });
+ var rep = runner.Report!;
+ Assert.Equal((RunOutcome.Passed, 4L, 2, 9UL), (rep.Outcome, rep.Frames, rep.Steps, rep.Seed));
+ // Digest = State * 1_000_003 + Frame (FakeGame), at frame 2: 3 * 1000003 + 2 = 3000011.
+ Assert.Equal([new CheckpointRecord("one", 2, 3000011UL.ToString("x16"), "replayed")], rep.Checkpoints);
+ Assert.Contains(DigestLine.Format(2, 2 / 60.0, 3000011UL), output);
+ Assert.Contains(DigestLine.Format(4, 4 / 60.0, 3000013UL), output); // after the labelled step
+ }
+
+ [Fact]
+ public void NonDeterministic_NoDigestLines_FlagInReport()
+ {
+ var (runner, _, output) = Run("format 1\nmode player\ncheckpoint one", new FakeGame(), new ScriptRunOptions { Deterministic = false });
+ Assert.DoesNotContain(output, l => l.StartsWith("digest "));
+ Assert.False(runner.Report!.Deterministic);
+ Assert.Contains("deterministic: no", ReportWriter.Text(runner.Report));
+ }
+}
diff --git a/src/Godosa.Core.Tests/Runs/Scripts/ScriptLoaderTests.cs b/src/Godosa.Core.Tests/Runs/Scripts/ScriptLoaderTests.cs
new file mode 100644
index 0000000..f28f330
--- /dev/null
+++ b/src/Godosa.Core.Tests/Runs/Scripts/ScriptLoaderTests.cs
@@ -0,0 +1,103 @@
+using Godosa.Core.Runs.Scripts;
+using Xunit;
+
+namespace Godosa.Core.Tests.Runs.Scripts;
+
+public class ScriptLoaderTests
+{
+ private static ScriptProgram Load(Dictionary<string, string> files, Dictionary<string, string>? vars = null) =>
+ ScriptLoader.Load("main.steps", new MemoryScriptSource(files), vars ?? []);
+
+ private static string Errors(ScriptProgram p) => string.Join(" | ", p.Errors.Select(e => e.ToString()));
+
+ private static string Ops(ScriptProgram p) => string.Join(" ", p.Code.Select(i => i.Op switch
+ {
+ Op.Verb => $"{i.A}", Op.ScopeBegin => i.LocalVars ? "{L" : "{", Op.ScopeEnd => "}", Op.LabelBegin => $"[{i.A}",
+ Op.LabelEnd => "]", Op.TryBegin => "try(", Op.TryEnd => ")", Op.SetupBegin => "setup(", Op.SetupEnd => ")",
+ _ => i.Op.ToString().ToLowerInvariant(),
+ }));
+
+ [Fact]
+ public void Flattens_IncludesDefsBlocks()
+ {
+ var p = Load(new()
+ {
+ ["main.steps"] = "format 1\nmode player\nsetup:\n cheat gold\ndef rest(h):\n key R\nstep a:\n include ch/one.steps\n call rest(8)\ncheckpoint done",
+ ["ch/one.steps"] = "format 1\nclick 1,1\ninclude ../common.steps",
+ ["common.steps"] = "format 1\ntry:\n key Q",
+ });
+ Assert.Equal("", Errors(p));
+ Assert.Equal("setup( cheat ) [a { click { try( key ) } } {L key } ] checkpoint", Ops(p));
+ var tryBegin = p.Code.Single(i => i.Op == Op.TryBegin);
+ Assert.Equal(Op.TryEnd, p.Code[tryBegin.Jump].Op);
+ var q = p.Code.Single(i => i.Op == Op.Verb && i.A == "key" && i.B == "Q");
+ Assert.Equal("common.steps:3", q.At.ToString());
+ Assert.Equal(["main.steps:8", "ch/one.steps:3"], q.Chain.Select(c => c.ToString()));
+ var bind = p.Code.Single(i => i.Op == Op.ScopeBegin && i.LocalVars);
+ Assert.Equal([new KeyValuePair<string, string>("h", "8")], bind.Bindings);
+ }
+
+ [Fact]
+ public void VarsFiles_PathExpandedWithCommandLine_FileDoesNotOverrideIt()
+ {
+ var p = Load(new()
+ {
+ ["main.steps"] = "format 1\nmode player\nvars ui/${res}.vars\nclick ${ui.ok}",
+ ["ui/hd.vars"] = "ui.ok = 10,20\nres = sd",
+ }, new() { ["res"] = "hd" });
+ Assert.Equal("", Errors(p));
+ Assert.Equal(("10,20", "hd"), (p.Vars["ui.ok"], p.Vars["res"]));
+ Assert.Contains("res", p.Locked);
+ }
+
+ [Theory]
+ [InlineData("format 1\nclick 1,1", "main.steps:1: top-level file needs 'mode'")]
+ [InlineData("format 1\nmode player\ninclude x.steps", "main.steps:3: include not found: x.steps")]
+ [InlineData("format 1\nmode player\ncall nope()", "main.steps:3: unknown def nope")]
+ [InlineData("format 1\nmode player\ndef d(a):\n key R\ncall d()", "main.steps:5: def d takes 1 arguments, got 0")]
+ [InlineData("format 1\nmode player\ndef d():\n call d()\ncall d()", "main.steps:4: recursive call d")]
+ [InlineData("format 1\nmode player\ndef d():\n key R\ndef d():\n key Q", "main.steps:5: def d already defined")]
+ [InlineData("format 1\nmode player\ncheckpoint a\ncheckpoint a", "main.steps:4: duplicate checkpoint a")]
+ [InlineData("format 1\nmode player\nclick ${nowhere}", "main.steps:3: unresolved variable ${nowhere}")]
+ [InlineData("format 1\nmode player\nkey R\nsetup:\n cheat x", "main.steps:4: setup must be the first block")]
+ [InlineData("format 1\nmode player\ntry:\n checkpoint c", "main.steps:4: checkpoint inside try")]
+ public void Reports(string main, string error) => Assert.Contains(error, Errors(Load(new() { ["main.steps"] = main })));
+
+ [Fact]
+ public void IncludeCycle_NamesThePath() =>
+ Assert.Contains("b.steps:2: include cycle: main.steps → a.steps → b.steps → a.steps", Errors(Load(new()
+ {
+ ["main.steps"] = "format 1\nmode debug\ninclude a.steps",
+ ["a.steps"] = "format 1\ninclude b.steps",
+ ["b.steps"] = "format 1\ninclude a.steps",
+ })));
+
+ [Fact]
+ public void IncludedFile_ModeOrSetup_Rejected()
+ {
+ var p = Load(new()
+ {
+ ["main.steps"] = "format 1\nmode debug\ninclude a.steps",
+ ["a.steps"] = "format 1\nmode player\nsetup:\n cheat x",
+ });
+ Assert.Contains("a.steps:2: mode only in the top-level file", Errors(p));
+ Assert.Contains("a.steps:3: setup only in the top-level file", Errors(p));
+ }
+
+ [Fact]
+ public void LockedVariable_SetIsAnError() =>
+ Assert.Contains("main.steps:3: variable res is set on the command line",
+ Errors(Load(new() { ["main.steps"] = "format 1\nmode debug\nset res = x" }, new() { ["res"] = "hd" })));
+
+ [Fact]
+ public void DefParamsAndEarlierSets_AreKnownVariables() =>
+ Assert.Equal("", Errors(Load(new()
+ {
+ ["main.steps"] = "format 1\nmode debug\nset n = 3\ndef d(a):\n key ${a}\n wait-frames ${n}\ncall d(R)",
+ })));
+
+ [Fact]
+ public void IncludedFileKeepsItsOwnFormat() =>
+ Assert.Equal(1, Load(new() { ["main.steps"] = "format 1\nmode debug\ninclude a.steps", ["a.steps"] = "format 1\nkey R" })
+ .Code.Single(i => i.Op == Op.Verb).Format);
+}
diff --git a/src/Godosa.Core.Tests/Runs/Scripts/ScriptParserTests.cs b/src/Godosa.Core.Tests/Runs/Scripts/ScriptParserTests.cs
new file mode 100644
index 0000000..352ff03
--- /dev/null
+++ b/src/Godosa.Core.Tests/Runs/Scripts/ScriptParserTests.cs
@@ -0,0 +1,111 @@
+using Godosa.Core.Runs.Scripts;
+using Xunit;
+
+namespace Godosa.Core.Tests.Runs.Scripts;
+
+public class ScriptParserTests
+{
+ private static ScriptFile Parse(string text) => ScriptParser.Parse("main.steps", text);
+
+ private static string Errors(ScriptFile f) => string.Join(" | ", f.Errors.Select(e => e.ToString()));
+
+ [Fact]
+ public void Header_AndStatements()
+ {
+ var f = Parse("""
+ # comment
+ format 1
+ mode player
+ seed 7
+ vars ui/${res}.vars
+ click 10,20
+ wait dialog-open 2s
+ expect quest(1) >= accepted
+ set x = "a b"
+ include ch/one.steps
+ call rest(8, inn)
+ checkpoint done
+ """);
+ Assert.Equal("", Errors(f));
+ Assert.Equal((1, RunMode.Player, 7UL), (f.Format, f.Mode, f.Seed!.Value));
+ Assert.Equal(["ui/${res}.vars"], f.VarFiles);
+ var v = Assert.IsType<VerbNode>(f.Body[0]);
+ Assert.Equal(("click", "10,20", 6), (v.Verb, v.Args, v.At.Line));
+ var w = Assert.IsType<WaitNode>(f.Body[1]);
+ Assert.Equal(("dialog-open", 120), (w.Condition, w.Timeout));
+ Assert.Equal("quest(1) >= accepted", Assert.IsType<ExpectNode>(f.Body[2]).Condition);
+ var s = Assert.IsType<SetNode>(f.Body[3]);
+ Assert.Equal(("x", "a b"), (s.Name, s.Value));
+ Assert.Equal("ch/one.steps", Assert.IsType<IncludeNode>(f.Body[4]).Path);
+ var c = Assert.IsType<CallNode>(f.Body[5]);
+ Assert.Equal("rest", c.Name);
+ Assert.Equal(["8", "inn"], c.Args);
+ Assert.Equal("done", Assert.IsType<CheckpointNode>(f.Body[6]).Name);
+ }
+
+ [Fact]
+ public void Blocks_NestByIndent()
+ {
+ var f = Parse("""
+ format 1
+ mode debug
+ setup:
+ cheat gold 5
+ step talk-virgil:
+ click 1,2
+ try:
+ key R
+ def rest(hours):
+ key R
+ wait-frames ${hours}
+ key Q
+ """);
+ Assert.Equal("", Errors(f));
+ var setup = Assert.IsType<BlockNode>(f.Body[0]);
+ Assert.Equal((BlockKind.Setup, 1), (setup.Kind, setup.Body.Count));
+ var step = Assert.IsType<BlockNode>(f.Body[1]);
+ Assert.Equal((BlockKind.Step, "talk-virgil", 2), (step.Kind, step.Label, step.Body.Count));
+ Assert.Equal(BlockKind.Try, Assert.IsType<BlockNode>(step.Body[1]).Kind);
+ var def = Assert.IsType<DefNode>(f.Body[2]);
+ Assert.Equal(("rest", 2), (def.Name, def.Body.Count));
+ Assert.Equal(["hours"], def.Params);
+ Assert.Equal("Q", Assert.IsType<VerbNode>(f.Body[3]).Args);
+ }
+
+ [Theory]
+ [InlineData("mode player\nclick 1,1", "main.steps:1: first line must be 'format N'")]
+ [InlineData("format 9\nmode player", "main.steps:1: unknown format 9 (latest 1)")]
+ [InlineData("format 1\nmode sneaky", "main.steps:2: mode must be player or debug")]
+ [InlineData("format 1\nmode debug\nclick 1,1\nseed 3", "main.steps:4: header line after steps")]
+ [InlineData("format 1\nmode debug\nwait dialog-open 0.01s", "main.steps:3: bad timeout '0.01s'")]
+ [InlineData("format 1\nmode debug\nwait 2s", "main.steps:3: wait needs a condition and a timeout")]
+ [InlineData("format 1\nmode debug\nstep a:\n click 1,1", "main.steps:4: indent not a multiple of 2")]
+ [InlineData("format 1\nmode debug\nstep a:\n\tclick 1,1", "main.steps:4: tab in indentation")]
+ [InlineData("format 1\nmode debug\nstep a:\nclick 1,1", "main.steps:3: empty block")]
+ [InlineData("format 1\nmode debug\nloop:\n click 1,1", "main.steps:3: unknown block 'loop:'")]
+ [InlineData("format 1\nmode debug\nclick 1,1\n key R", "main.steps:4: unexpected indent")]
+ [InlineData("format 1\nmode debug\nlog \"abc", "main.steps:3: unterminated quote")]
+ [InlineData("format 1\nmode debug\nset 9x = 1", "main.steps:3: bad set (set name = value)")]
+ public void Errors_NameTheLine(string text, string error) => Assert.Contains(error, Errors(Parse(text)));
+
+ // Review focus 5: CRLF, BOM and trailing spaces parse as LF.
+ [Fact]
+ public void CrlfBomTrailingSpaces_SameAsLf()
+ {
+ var lf = Parse("format 1\nmode debug\nstep a:\n click 1,2\n");
+ var crlf = Parse("\uFEFFformat 1 \r\nmode debug\r\nstep a: \r\n click 1,2 \r\n");
+ Assert.Equal("", Errors(crlf));
+ var a = Assert.IsType<BlockNode>(lf.Body[0]);
+ var b = Assert.IsType<BlockNode>(crlf.Body[0]);
+ Assert.Equal(a.Text, b.Text);
+ Assert.Equal(((VerbNode)a.Body[0]).Text, ((VerbNode)b.Body[0]).Text);
+ Assert.Equal(((VerbNode)a.Body[0]).At, ((VerbNode)b.Body[0]).At);
+ }
+
+ [Fact]
+ public void ReportsEveryError_NotOnlyTheFirst()
+ {
+ var f = Parse("format 1\nmode debug\nwait x 2m\nset = 1\nloop:\n key R");
+ Assert.Equal(3, f.Errors.Count);
+ }
+}
diff --git a/src/Godosa.Core.Tests/Runs/Scripts/ScriptRunFlagsTests.cs b/src/Godosa.Core.Tests/Runs/Scripts/ScriptRunFlagsTests.cs
new file mode 100644
index 0000000..6f3be4e
--- /dev/null
+++ b/src/Godosa.Core.Tests/Runs/Scripts/ScriptRunFlagsTests.cs
@@ -0,0 +1,54 @@
+using Godosa.Core.Runs;
+using Godosa.Core.Runs.Scripts;
+using Xunit;
+
+namespace Godosa.Core.Tests.Runs.Scripts;
+
+public class ScriptRunFlagsTests
+{
+ private static ScriptRunFlags Parse(params string[] args)
+ {
+ var f = new ScriptRunFlags();
+ for (var i = 0; i < args.Length; i++)
+ Assert.True(ScriptRunFlags.TryTake(args, ref i, f), args[i]);
+ return f;
+ }
+
+ [Fact]
+ public void ParsesEveryOption()
+ {
+ var f = Parse("--script", "main.steps", "--var", "res=1920x1080", "--var", "who=gil", "--output", "trace",
+ "--no-cache", "--no-snapshots", "--from", "one", "--until", "two", "--step-cap", "500", "--report-dir", "out/runs/a");
+ Assert.True(f.NoSnapshots);
+ Assert.False(f.ToOptions(new RunFlags(), null).Snapshots);
+ Assert.Equal("main.steps", f.Script);
+ Assert.Equal(new Dictionary<string, string> { ["res"] = "1920x1080", ["who"] = "gil" }, f.Vars);
+ Assert.Equal((OutputLevel.Trace, true, "one", "two", 500, "out/runs/a"), (f.Output!.Value, f.NoCache, f.From, f.Until, f.StepCap!.Value, f.ReportDir));
+ }
+
+ [Fact]
+ public void NotOurs_ReturnsFalse_IndexUnchanged()
+ {
+ var i = 0;
+ Assert.False(ScriptRunFlags.TryTake(["--mute"], ref i, new ScriptRunFlags()));
+ Assert.Equal(0, i);
+ }
+
+ [Theory]
+ [InlineData("--var", "novalue")]
+ [InlineData("--output", "loud")]
+ [InlineData("--step-cap", "x")]
+ public void BadValues_Throw(string option, string value)
+ {
+ var i = 0;
+ Assert.Throws<ArgumentException>(() => ScriptRunFlags.TryTake([option, value], ref i, new ScriptRunFlags()));
+ }
+
+ [Fact]
+ public void ToOptions_DeterministicSeedOutput()
+ {
+ var o = Parse("--output", "steps", "--no-cache").ToOptions(new RunFlags(Deterministic: true, Seed: 7), "out/runs/cache");
+ Assert.Equal((true, (ulong?)7UL, OutputLevel.Steps, OutputLevel.Steps, false, "out/runs/cache"), (o.Deterministic, o.Seed, o.Output, o.Floor, o.UseCache, o.CacheDir));
+ Assert.False(Parse("--nondeterministic").ToOptions(new RunFlags(), null).Deterministic);
+ }
+}
diff --git a/src/Godosa.Core.Tests/Runs/Scripts/ScriptRunnerStructureTests.cs b/src/Godosa.Core.Tests/Runs/Scripts/ScriptRunnerStructureTests.cs
new file mode 100644
index 0000000..9129456
--- /dev/null
+++ b/src/Godosa.Core.Tests/Runs/Scripts/ScriptRunnerStructureTests.cs
@@ -0,0 +1,85 @@
+using Godosa.Core.Runs.Scripts;
+using Xunit;
+using static Godosa.Core.Tests.Runs.Scripts.ScriptRunnerTests;
+
+namespace Godosa.Core.Tests.Runs.Scripts;
+
+public class ScriptRunnerStructureTests
+{
+ [Fact]
+ public void Output_DefaultLabels_StepsLevelShowsEveryStep()
+ {
+ var (_, labels) = Run("format 1\nmode player\nstep a:\n key R\n log \"hi\"\nkey Q", new FakeGame());
+ Assert.Equal(["hi", "[main.steps:3 a] done f=2 (+2)", "digest 2 0.033 0000000000000002"], labels);
+ var (_, steps) = Run("format 1\nmode player\noutput steps\nstep a:\n key R\nkey Q", new FakeGame());
+ Assert.Equal(["[main.steps:5 a] key R done f=2 (+2)", "[main.steps:4 a] done f=2 (+2)", "digest 2 0.033 0000000000000002", "[main.steps:6] key Q done f=4 (+2)"], steps);
+ }
+
+ [Fact]
+ public void Output_ScopedToIncludedFile()
+ {
+ var (_, output) = Run("format 1\nmode player\ninclude loud.steps\nkey Q", new FakeGame(),
+ files: new() { ["loud.steps"] = "format 1\noutput steps\nkey R" });
+ Assert.Equal(["[loud.steps:3] key R done f=2 (+2)"], output); // key Q at labels level: silent
+ }
+
+ [Fact]
+ public void Output_FloorCannotBeLowered()
+ {
+ var (_, output) = Run("format 1\nmode player\noutput quiet\nkey R", new FakeGame(), new ScriptRunOptions { Floor = OutputLevel.Steps });
+ Assert.Equal(["[main.steps:4] key R done f=2 (+2)"], output);
+ }
+
+ [Fact]
+ public void DefCall_BindsParams_ParamUnknownOutside()
+ {
+ var g = new FakeGame();
+ var a = new FakeAction(VerbKind.Debug, _ => new ActionStart(Status: StepStatus.Done), min: 1, max: 3);
+ g.ActionTable["cheat"] = a;
+ var (ok, _) = Run("format 1\nmode debug\ndef d(x):\n set local = in\n cheat ${x} ${local}\ncall d(gold)", g);
+ Assert.Equal(RunOutcome.Passed, ok.Outcome);
+ Assert.Equal(["gold", "in"], a.Begun[0]);
+ var (bad, output) = Run("format 1\nmode debug\ndef d(x):\n cheat ${x}\ncall d(gold)\ncheat ${x}", g);
+ Assert.Equal(RunOutcome.Invalid, bad.Outcome);
+ Assert.Contains(output, l => l == "main.steps:6: unresolved variable ${x}");
+ }
+
+ [Fact]
+ public void Try_FaultInsideContinuesAfterTheBlock_NonDeterministic()
+ {
+ var g = new FakeGame();
+ g.ProbeFns["never"] = _ => RunValue.Of(false);
+ var (r, output) = Run("format 1\nmode player\ntry:\n wait never 3\n key A\nkey B", g, new ScriptRunOptions { Deterministic = false });
+ Assert.Equal(RunOutcome.Passed, r.Outcome);
+ Assert.Contains(output, l => l == "try failed at main.steps:4: timeout after 3 frames: never (never=0)");
+ Assert.DoesNotContain(g.Sent, s => s.Event.Key == "A");
+ Assert.Contains(g.Sent, s => s.Event.Key == "B");
+ }
+
+ // Review focus 1: a fault while a button is down releases it.
+ [Fact]
+ public void Fault_ReleasesHeldButtonAndKey()
+ {
+ var g = new FakeGame();
+ g.ProbeFns["never"] = _ => RunValue.Of(false);
+ g.ActionTable["walk-to"] = new FakeAction(VerbKind.Player,
+ _ => new ActionStart(new InputPlan([new PlannedInput(0, new InputEvent(InputKind.KeyDown, Key: "Shift")), new PlannedInput(0, new InputEvent(InputKind.Down, 9, 9)),
+ new PlannedInput(5, new InputEvent(InputKind.Up, 9, 9)), new PlannedInput(5, new InputEvent(InputKind.KeyUp, Key: "Shift"))])),
+ polls => StepStatus.Running, 1, 1);
+ g.OnAdvance = f => { if (f == 2) throw new InvalidOperationException("crash"); };
+ var (r, _) = Run("format 1\nmode player\nwalk-to 9,9", g);
+ Assert.Equal(RunOutcome.Failed, r.Outcome);
+ Assert.Contains(g.Sent, s => s.Event == new InputEvent(InputKind.Up, 9, 9));
+ Assert.Contains(g.Sent, s => s.Event == new InputEvent(InputKind.KeyUp, Key: "Shift"));
+ }
+
+ [Fact]
+ public void Setup_RunsFirstAtFrameZero()
+ {
+ var g = new FakeGame();
+ var order = new List<string>();
+ g.ActionTable["cheat"] = new FakeAction(VerbKind.Debug, a => { order.Add($"cheat@{g.Frame}"); return new ActionStart(Status: StepStatus.Done); }, min: 1, max: 3);
+ Run("format 1\nmode player\nsetup:\n cheat stats max\nkey R", g);
+ Assert.Equal(["cheat@0"], order);
+ }
+}
diff --git a/src/Godosa.Core.Tests/Runs/Scripts/ScriptRunnerTests.cs b/src/Godosa.Core.Tests/Runs/Scripts/ScriptRunnerTests.cs
new file mode 100644
index 0000000..0738f7b
--- /dev/null
+++ b/src/Godosa.Core.Tests/Runs/Scripts/ScriptRunnerTests.cs
@@ -0,0 +1,224 @@
+using Godosa.Core.Runs.Scripts;
+using Xunit;
+
+namespace Godosa.Core.Tests.Runs.Scripts;
+
+public class ScriptRunnerTests
+{
+ internal static (RunResult Result, List<string> Output) Run(string main, FakeGame game, ScriptRunOptions? options = null,
+ Dictionary<string, string>? files = null, Dictionary<string, string>? vars = null)
+ {
+ var all = new Dictionary<string, string>(files ?? []) { ["main.steps"] = main };
+ var program = ScriptLoader.Load("main.steps", new MemoryScriptSource(all), vars ?? []);
+ var output = new List<string>();
+ var runner = new ScriptRunner(program, game, options ?? new ScriptRunOptions(), output.Add);
+ return (runner.Run(cap: 10_000), output);
+ }
+
+ [Fact]
+ public void Click_DownAtStepFrameUpNextFrame()
+ {
+ var g = new FakeGame();
+ var (r, _) = Run("format 1\nmode player\nclick 100,200\nkey R", g);
+ Assert.Equal((RunOutcome.Passed, 0), (r.Outcome, r.ExitCode));
+ // Hand-derived: frame 0 (game.Frame before first Advance) move+down, frame 1 up; key starts on frame 2: down, frame 3 up.
+ Assert.Equal(
+ [
+ (0L, new InputEvent(InputKind.Move, 100, 200)), (0L, new InputEvent(InputKind.Down, 100, 200)),
+ (1L, new InputEvent(InputKind.Up, 100, 200)),
+ (2L, new InputEvent(InputKind.KeyDown, Key: "R")), (3L, new InputEvent(InputKind.KeyUp, Key: "R")),
+ ], g.Sent);
+ Assert.Equal(4, r.Frames);
+ }
+
+ // A window host ends its batch of game frames when a step completes, so the next step resolves on a fresh draw.
+ [Fact]
+ public void StepsDone_CountsCompletedSteps()
+ {
+ var program = ScriptLoader.Load("main.steps", new MemoryScriptSource(new Dictionary<string, string> { ["main.steps"] = "format 1\nmode player\nclick 100,200\nkey R" }), new Dictionary<string, string>());
+ var runner = new ScriptRunner(program, new FakeGame(), new ScriptRunOptions(), _ => { });
+ Assert.Equal(0, runner.StepsDone);
+ runner.Run(cap: 10_000);
+ Assert.Equal(2, runner.StepsDone);
+ }
+
+ // A window host skips the poll of a frame drawn later (a batch frame that closed a panel: a click resolved then
+ // would aim on the draw from before): the frame counts, the step is polled on the next one.
+ [Fact]
+ public void AfterFrame_NoPoll_CountsTheFrameOnly()
+ {
+ var program = ScriptLoader.Load("main.steps", new MemoryScriptSource(new Dictionary<string, string> { ["main.steps"] = "format 1\nmode player\nwait-frames 1" }), new Dictionary<string, string>());
+ var runner = new ScriptRunner(program, new FakeGame(), new ScriptRunOptions(), _ => { });
+ runner.BeforeFrame();
+ runner.AfterFrame(poll: false);
+ Assert.Equal((1, 0), (runner.FrameCount, runner.StepsDone));
+ runner.BeforeFrame();
+ runner.AfterFrame();
+ Assert.Equal((2, 1), (runner.FrameCount, runner.StepsDone));
+ }
+
+ [Fact]
+ public void Locator_AimsTheClick_OffScreenFaults()
+ {
+ var g = new FakeGame();
+ g.Objects["Virgil"] = (640, 360);
+ var (ok, _) = Run("format 1\nmode player\nclick @obj(Virgil)", g);
+ Assert.Equal(RunOutcome.Passed, ok.Outcome);
+ Assert.Contains((0L, new InputEvent(InputKind.Down, 640, 360)), g.Sent);
+ var (bad, output) = Run("format 1\nmode player\nclick @obj(Bates)", new FakeGame());
+ Assert.Equal((RunOutcome.Failed, 1), (bad.Outcome, bad.ExitCode));
+ Assert.Contains(output, l => l == "FAIL main.steps:3 click @obj(Bates): locator @obj(Bates): no object Bates on screen");
+ }
+
+ [Fact]
+ public void Wait_HoldsUntilConditionOrTimesOut()
+ {
+ var g = new FakeGame();
+ g.ProbeFns["ready"] = _ => RunValue.Of(g.Frame >= 5);
+ var (r, _) = Run("format 1\nmode player\nwait ready 10\nkey A", g);
+ Assert.Equal(RunOutcome.Passed, r.Outcome);
+ Assert.Equal(5L, g.Sent[0].Frame); // condition true after the 5th advance → key starts at frame 5
+
+ var g2 = new FakeGame();
+ g2.ProbeFns["ready"] = _ => RunValue.Of(false);
+ g2.ProbeFns["quest"] = _ => RunValue.Of(1);
+ var (t, output) = Run("format 1\nmode player\nwait ready and quest(3) > 1 4", g2);
+ Assert.Equal(RunOutcome.Failed, t.Outcome);
+ Assert.Equal(4, t.Frames);
+ Assert.Contains(output, l => l == "FAIL main.steps:3 wait ready and quest(3) > 1 4: timeout after 4 frames: ready and quest(3) > 1 (ready=0 quest(3)=1)");
+ }
+
+ [Fact]
+ public void Wait_AlreadyTrue_TakesNoFrame()
+ {
+ var g = new FakeGame();
+ g.ProbeFns["ready"] = _ => RunValue.Of(true);
+ var (r, _) = Run("format 1\nmode player\nwait ready 10\nkey A", g);
+ Assert.Equal(0L, g.Sent[0].Frame);
+ Assert.Equal(2, r.Frames);
+ }
+
+ [Fact]
+ public void Expect_FailsWithValues()
+ {
+ var g = new FakeGame();
+ g.ProbeFns["quest"] = _ => RunValue.Of(1);
+ var (r, output) = Run("format 1\nmode player\nexpect quest(1) >= accepted", g);
+ Assert.Equal(RunOutcome.Failed, r.Outcome);
+ Assert.Contains(output, l => l == "FAIL main.steps:3 expect quest(1) >= accepted: expect failed: quest(1) >= accepted (quest(1)=1)");
+ }
+
+ [Fact]
+ public void Action_PlanThenPollUntilDone()
+ {
+ var g = new FakeGame();
+ var talk = new FakeAction(VerbKind.Player, args => new ActionStart(new InputPlan([new PlannedInput(0, new InputEvent(InputKind.Down, 5, 6)), new PlannedInput(1, new InputEvent(InputKind.Up, 5, 6))])),
+ polls => polls >= 3 ? StepStatus.Done : StepStatus.Running, 1, 1);
+ g.ActionTable["talk"] = talk;
+ var (r, _) = Run("format 1\nmode player\ntalk @obj(x)\nkey Z", g);
+ Assert.Equal(RunOutcome.Passed, r.Outcome);
+ Assert.Equal(["@obj(x)"], talk.Begun.Single());
+ // Down sent f0, Up f1; polls run after advances to 2, 3, 4 (Done on the 3rd) → key starts at frame 4.
+ Assert.Equal(4L, g.Sent.Single(s => s.Event.Kind == InputKind.KeyDown).Frame);
+ }
+
+ [Fact]
+ public void Action_ArgsGetVariablesExpanded_LocatorsLeftToTheGame()
+ {
+ var g = new FakeGame();
+ var a = new FakeAction(VerbKind.Debug, _ => new ActionStart(Status: StepStatus.Done), min: 1, max: 3);
+ g.ActionTable["cheat"] = a;
+ Run("format 1\nmode debug\nset n = 50\ncheat gold ${n}", g);
+ Assert.Equal(["gold", "50"], a.Begun.Single());
+ }
+
+ [Fact]
+ public void Action_FaultStatusFailsTheRun()
+ {
+ var g = new FakeGame();
+ g.ActionTable["walk-to"] = new FakeAction(VerbKind.Player, _ => new ActionStart(), polls => polls == 2 ? StepStatus.Fault("stuck at 3,4") : StepStatus.Running, 1, 1);
+ var (r, output) = Run("format 1\nmode player\nwalk-to 3,4", g);
+ Assert.Equal((RunOutcome.Failed, 2L), (r.Outcome, r.Frames));
+ Assert.Contains(output, l => l == "FAIL main.steps:3 walk-to 3,4: stuck at 3,4");
+ }
+
+ // Review focus 2: a hook that never finishes hits the step cap.
+ [Fact]
+ public void StepCap_StopsAHangingAction()
+ {
+ var g = new FakeGame();
+ g.ActionTable["walk-to"] = new FakeAction(VerbKind.Player, _ => new ActionStart(), _ => StepStatus.Running, 1, 1);
+ var (r, output) = Run("format 1\nmode player\nwalk-to 3,4", g, new ScriptRunOptions { StepCap = 50 });
+ Assert.Equal((RunOutcome.Failed, 50L), (r.Outcome, r.Frames));
+ Assert.Contains(output, l => l == "FAIL main.steps:3 walk-to 3,4: step exceeded 50 frames");
+ }
+
+ [Fact]
+ public void GameHookException_IsAFailureNotACrash()
+ {
+ var g = new FakeGame();
+ g.ActionTable["talk"] = new FakeAction(VerbKind.Player, _ => throw new InvalidOperationException("boom"), min: 1, max: 1);
+ var (r, output) = Run("format 1\nmode player\ntalk x", g);
+ Assert.Equal(RunOutcome.Failed, r.Outcome);
+ Assert.Contains(output, l => l == "FAIL main.steps:3 talk x: InvalidOperationException: boom");
+ }
+
+ [Fact]
+ public void InvalidScript_Exit2_NoFrames()
+ {
+ var g = new FakeGame();
+ var (r, output) = Run("format 1\nmode player\ndance", g);
+ Assert.Equal((RunOutcome.Invalid, 2, 0L), (r.Outcome, r.ExitCode, r.Frames));
+ Assert.Contains(output, l => l == "main.steps:3: unknown verb dance");
+ Assert.Equal(0, g.Frame);
+ }
+
+ [Fact]
+ public void Rng_CallsTheGameHook_NoFrame()
+ {
+ var g = new FakeGame();
+ var (r, _) = Run("format 1\nmode player\nrng seed 9\nrng next hit 1", g);
+ Assert.Equal((RunOutcome.Passed, 0L), (r.Outcome, r.Frames));
+ Assert.Equal(["seed 9", "next hit 1"], g.RngImpl!.Calls);
+ }
+
+ [Fact]
+ public void WaitFrames_CountsFrames()
+ {
+ var g = new FakeGame();
+ var (r, _) = Run("format 1\nmode player\nwait-frames 7\nwait-frames 0\nwait-frames 0.5s", g);
+ Assert.Equal(37, r.Frames);
+ }
+
+ [Theory]
+ [InlineData("dclick 1,2", "Move@0 Down@0 Up@1 Down@2 Up@3")]
+ [InlineData("rclick 1,2", "Move@0 Down(R)@0 Up(R)@1")]
+ [InlineData("drag 0,0 30,60", "Move(0,0)@0 Down@0 Move(10,20)@1 Move(20,40)@2 Move(30,60)@3 Up@4")]
+ [InlineData("hold W 3", "KeyDown(W)@0 KeyUp(W)@3")]
+ [InlineData("chord Ctrl+S", "KeyDown(Ctrl)@0 KeyDown(S)@0 KeyUp(S)@1 KeyUp(Ctrl)@1")]
+ [InlineData("type \"hi there\"", "Text(hi there)@0")]
+ [InlineData("wheel -2 5,5", "Move@0 Wheel(-2)@0")]
+ public void InputPlans_Shapes(string line, string expected)
+ {
+ var g = new FakeGame();
+ Run($"format 1\nmode player\n{line}", g);
+ string Show((long Frame, InputEvent E) s) => s.E.Kind switch
+ {
+ InputKind.Move when line.StartsWith("drag") => $"Move({s.E.X},{s.E.Y})@{s.Frame}",
+ InputKind.Down or InputKind.Up when s.E.Button == MouseButton.Right => $"{s.E.Kind}(R)@{s.Frame}",
+ InputKind.KeyDown or InputKind.KeyUp => $"{s.E.Kind}({s.E.Key})@{s.Frame}",
+ InputKind.Text => $"Text({s.E.Text})@{s.Frame}",
+ InputKind.Wheel => $"Wheel({s.E.Delta})@{s.Frame}",
+ _ => $"{s.E.Kind}@{s.Frame}",
+ };
+ Assert.Equal(expected, string.Join(" ", g.Sent.Select(Show)));
+ }
+
+ [Fact]
+ public void BadPosition_Faults()
+ {
+ var (r, output) = Run("format 1\nmode player\nclick 10;20", new FakeGame());
+ Assert.Equal(RunOutcome.Failed, r.Outcome);
+ Assert.Contains(output, l => l == "FAIL main.steps:3 click 10;20: bad position '10;20'");
+ }
+}
diff --git a/src/Godosa.Core.Tests/Runs/Scripts/ScriptValidatorTests.cs b/src/Godosa.Core.Tests/Runs/Scripts/ScriptValidatorTests.cs
new file mode 100644
index 0000000..98dcc73
--- /dev/null
+++ b/src/Godosa.Core.Tests/Runs/Scripts/ScriptValidatorTests.cs
@@ -0,0 +1,63 @@
+using Godosa.Core.Runs.Scripts;
+using Xunit;
+
+namespace Godosa.Core.Tests.Runs.Scripts;
+
+public class ScriptValidatorTests
+{
+ private static FakeGame Game()
+ {
+ var g = new FakeGame();
+ g.ProbeFns["quest"] = _ => RunValue.Of(0);
+ g.ProbeFns["dialog-open"] = _ => RunValue.Of(false);
+ g.ActionTable["talk"] = new FakeAction(VerbKind.Player, _ => new ActionStart(), min: 1, max: 1);
+ g.ActionTable["cheat"] = new FakeAction(VerbKind.Debug, _ => new ActionStart(Status: StepStatus.Done), min: 1, max: 20);
+ return g;
+ }
+
+ private static string Validate(string main, bool deterministic = true, FakeGame? game = null) =>
+ string.Join(" | ", ScriptValidator.Validate(
+ ScriptLoader.Load("main.steps", new MemoryScriptSource(new Dictionary<string, string> { ["main.steps"] = main }), new Dictionary<string, string>()),
+ game ?? Game(), deterministic).Select(e => e.ToString()));
+
+ [Fact]
+ public void ValidPlayerScript_NoErrors() =>
+ Assert.Equal("", Validate("format 1\nmode player\nsetup:\n cheat gold 5\ntalk @obj(Virgil)\nwait dialog-open 2s\nexpect quest(1) >= accepted\nclick 1,2\nkey R\nlog \"hi\""));
+
+ [Theory]
+ [InlineData("format 1\nmode player\ndance", "main.steps:3: unknown verb dance")]
+ [InlineData("format 1\nmode player\nwalk-to 3,4", "main.steps:3: verb walk-to not supported by game fake")]
+ [InlineData("format 1\nmode player\nclick", "main.steps:3: verb click takes 1..1 arguments, got 0")]
+ [InlineData("format 1\nmode player\ncheat gold 5", "main.steps:3: debug verb cheat outside setup in player mode")]
+ [InlineData("format 1\nmode player\nwait nope 10", "main.steps:3: unknown probe or constant nope")]
+ [InlineData("format 1\nmode player\nexpect quest(1) >=", "main.steps:3: condition ends early")]
+ [InlineData("format 1\nmode player\ntalk @npc(Virgil)", "main.steps:3: unknown locator @npc")]
+ [InlineData("format 1\nmode player\ntry:\n key R", "main.steps:3: try needs a non-deterministic run")]
+ [InlineData("format 1\nmode player\noutput loud", "main.steps:3: output level must be quiet|checkpoints|labels|steps|trace")]
+ public void Errors(string main, string error) => Assert.Contains(error, Validate(main));
+
+ [Fact]
+ public void DebugMode_DebugVerbAnywhere() => Assert.Equal("", Validate("format 1\nmode debug\ncheat gold 5\nkey R"));
+
+ [Fact]
+ public void Try_AllowedNonDeterministic() => Assert.Equal("", Validate("format 1\nmode player\ntry:\n key R", deterministic: false));
+
+ [Fact]
+ public void Rng_NeedsTheGameHook()
+ {
+ var g = Game();
+ g.RngImpl = null;
+ Assert.Contains("main.steps:3: verb rng not supported by game fake", Validate("format 1\nmode player\nrng seed 4", game: g));
+ }
+
+ [Fact]
+ public void GameVerb_ShadowingALibVerbWithAnotherKind_Rejected()
+ {
+ var g = Game();
+ g.ActionTable["click"] = new FakeAction(VerbKind.Debug, _ => new ActionStart());
+ Assert.Contains("game verb click shadows a lib verb", Validate("format 1\nmode debug\nkey R", game: g));
+ }
+
+ [Fact]
+ public void LoaderErrorsComeThrough() => Assert.Contains("main.steps:3: unknown def x", Validate("format 1\nmode debug\ncall x()"));
+}
diff --git a/src/Godosa.Core.Tests/Runs/Scripts/StepLexerTests.cs b/src/Godosa.Core.Tests/Runs/Scripts/StepLexerTests.cs
new file mode 100644
index 0000000..0f787dc
--- /dev/null
+++ b/src/Godosa.Core.Tests/Runs/Scripts/StepLexerTests.cs
@@ -0,0 +1,58 @@
+using Godosa.Core.Runs.Scripts;
+using Xunit;
+
+namespace Godosa.Core.Tests.Runs.Scripts;
+
+public class StepLexerTests
+{
+ [Theory]
+ [InlineData("click 10,20 # aim", "click 10,20 ")]
+ [InlineData("log \"a # b\" # c", "log \"a # b\" ")]
+ [InlineData("# only", "")]
+ [InlineData("type \"say \\\"#1\\\"\"", "type \"say \\\"#1\\\"\"")]
+ public void StripComment_OutsideQuotesOnly(string line, string expected) =>
+ Assert.Equal(expected, StepLexer.StripComment(line));
+
+ [Fact]
+ public void Tokens_SplitOnSpaceQuotedStaysWhole()
+ {
+ var t = StepLexer.Tokens("use @inv(Lockpicks) on \"Big Door\"");
+ Assert.Equal(["use", "@inv(Lockpicks)", "on", "Big Door"], t.Select(x => x.Text));
+ Assert.Equal([false, false, false, true], t.Select(x => x.Quoted));
+ }
+
+ [Fact]
+ public void Tokens_EscapesInQuotes()
+ {
+ var t = StepLexer.Tokens("type \"a \\\"b\\\" \\\\ c\"");
+ Assert.Equal("a \"b\" \\ c", t[1].Text);
+ }
+
+ [Fact]
+ public void Tokens_ParenthesesKeepSpacesInOneToken()
+ {
+ // locator args may contain spaces: @npc(Gilbert Bates)
+ var t = StepLexer.Tokens("talk @npc(Gilbert Bates)");
+ Assert.Equal(["talk", "@npc(Gilbert Bates)"], t.Select(x => x.Text));
+ }
+
+ [Fact]
+ public void Tokens_UnterminatedQuote_Throws() =>
+ Assert.Equal("unterminated quote", Assert.Throws<FormatException>(() => StepLexer.Tokens("log \"abc")).Message);
+
+ [Theory]
+ [InlineData("120", 120)]
+ [InlineData("2s", 120)]
+ [InlineData("0.5s", 30)]
+ [InlineData("0", 0)]
+ [InlineData("1.25s", 75)]
+ public void Durations_FramesAndSeconds(string text, int frames) => Assert.Equal(frames, Durations.Frames(text));
+
+ [Theory]
+ [InlineData("0.01s")] // 0.6 frames: not whole
+ [InlineData("-5")]
+ [InlineData("2m")]
+ [InlineData("")]
+ [InlineData("1e3")]
+ public void Durations_Invalid_Null(string text) => Assert.Null(Durations.Frames(text));
+}
diff --git a/src/Godosa.Core.Tests/Runs/Scripts/VarsTests.cs b/src/Godosa.Core.Tests/Runs/Scripts/VarsTests.cs
new file mode 100644
index 0000000..c6d3286
--- /dev/null
+++ b/src/Godosa.Core.Tests/Runs/Scripts/VarsTests.cs
@@ -0,0 +1,84 @@
+using Godosa.Core.Runs.Scripts;
+using Xunit;
+
+namespace Godosa.Core.Tests.Runs.Scripts;
+
+public class VarsTests
+{
+ private static readonly Dictionary<string, string> Values = new()
+ {
+ ["res"] = "1920x1080", ["hours"] = "8", ["ui.rest.8"] = "400,300", ["a"] = "b", ["b"] = "deep",
+ };
+
+ private static string Expand(string text) => VarExpander.Expand(text, n => Values.GetValueOrDefault(n),
+ p => p == "global(gold)" ? RunValue.Of(1250) : throw new ScriptFault($"no probe {p}"));
+
+ [Theory]
+ [InlineData("ui/${res}.vars", "ui/1920x1080.vars")]
+ [InlineData("click ${ui.rest.${hours}}", "click 400,300")]
+ [InlineData("${${a}}", "deep")]
+ [InlineData("gold ${probe:global(gold)}", "gold 1250")]
+ [InlineData("cost $5 and $", "cost $5 and $")] // review focus 4: lone $ is literal
+ [InlineData("no vars", "no vars")]
+ public void Expand_Values(string text, string expected) => Assert.Equal(expected, Expand(text));
+
+ [Theory]
+ [InlineData("${missing}", "unresolved variable ${missing}")]
+ [InlineData("x ${a", "unclosed ${ in 'x ${a'")]
+ [InlineData("${}", "empty ${}")]
+ public void Expand_Errors_AreFaults(string text, string message) =>
+ Assert.Equal(message, Assert.Throws<ScriptFault>(() => Expand(text)).Message);
+
+ [Fact]
+ public void Names_InnermostPlainOnly() =>
+ Assert.Equal(["hours", "res"], VarExpander.Names("${ui.${hours}} ${res} ${probe:quest(1)}").Order());
+
+ [Fact]
+ public void VarsFile_ParsesAndReportsBadLines()
+ {
+ var (values, errors) = VarsFile.Parse("ui/hd.vars", "# hd\nui.answer1 = 412,520\n\nui.rest.8=400,300\nbroken line\n");
+ Assert.Equal("412,520", values["ui.answer1"]);
+ Assert.Equal("400,300", values["ui.rest.8"]);
+ Assert.Equal(["ui/hd.vars:5: bad vars line (name = value)"], errors.Select(e => e.ToString()));
+ }
+
+ [Fact]
+ public void Scopes_SetGoesToLocalOrRoot()
+ {
+ var s = new VarScopes(OutputLevel.Labels);
+ s.Set("g", "1");
+ s.Push(local: false, OutputLevel.Labels, []); // include
+ s.Set("inc", "2"); // global: root
+ s.Push(local: true, OutputLevel.Labels, [new("p", "x")]); // def call
+ s.Set("loc", "3");
+ Assert.Equal(("1", "2", "3", "x"), (s.Get("g"), s.Get("inc"), s.Get("loc"), s.Get("p")));
+ s.Pop();
+ Assert.Null(s.Get("loc"));
+ Assert.Null(s.Get("p"));
+ s.Pop();
+ Assert.Equal("2", s.Get("inc"));
+ }
+
+ [Fact]
+ public void Scopes_OutputLevelRestoredOnPop()
+ {
+ var s = new VarScopes(OutputLevel.Labels);
+ s.Push(local: false, s.Level, []);
+ s.Level = OutputLevel.Trace;
+ s.Pop();
+ Assert.Equal(OutputLevel.Labels, s.Level);
+ }
+
+ [Fact]
+ public void Scopes_SnapshotRestore_RoundTrips()
+ {
+ var s = new VarScopes(OutputLevel.Labels);
+ s.Set("g", "1");
+ s.Push(local: true, OutputLevel.Steps, [new("p", "x")]);
+ var copy = new VarScopes(OutputLevel.Quiet);
+ copy.Restore(s.Snapshot());
+ Assert.Equal(("1", "x", OutputLevel.Steps), (copy.Get("g"), copy.Get("p"), copy.Level));
+ s.Set("p", "changed");
+ Assert.Equal("x", copy.Get("p")); // deep copy
+ }
+}
diff --git a/src/Godosa.Core.Tests/SettingsFileTests.cs b/src/Godosa.Core.Tests/SettingsFileTests.cs
new file mode 100644
index 0000000..7308c2b
--- /dev/null
+++ b/src/Godosa.Core.Tests/SettingsFileTests.cs
@@ -0,0 +1,90 @@
+using Xunit;
+
+namespace Godosa.Core.Tests;
+
+public sealed class SettingsFileTests : IDisposable
+{
+ private readonly string _dir = Path.Combine(Path.GetTempPath(), "godosa-settings-" + Guid.NewGuid().ToString("N"));
+
+ public void Dispose()
+ {
+ if (Directory.Exists(_dir)) Directory.Delete(_dir, recursive: true);
+ }
+
+ private readonly List<string> _reports = new();
+
+ private SettingsFile<string> Store(string path, Func<string, string>? parse = null) =>
+ new(path, parse ?? (text => "parsed:" + text), value => value, () => "defaults", _reports.Add);
+
+ [Fact]
+ public void Missing_GivesDefaults()
+ {
+ var file = Store(Path.Combine(_dir, "settings.json"), _ => throw new InvalidOperationException("parsed"));
+ Assert.Equal("defaults", file.Load());
+ Assert.Empty(_reports); // a first run is not a problem
+ }
+
+ [Fact]
+ public void Save_CreatesFolder_ThenLoadParsesWhatWasWritten_NoTempLeft()
+ {
+ string path = Path.Combine(_dir, "sub", "settings.json");
+ var file = Store(path);
+ file.Save("{\"a\":1}");
+ Assert.Equal("{\"a\":1}", System.IO.File.ReadAllText(path));
+ Assert.Equal("parsed:{\"a\":1}", file.Load());
+ Assert.False(System.IO.File.Exists(path + ".tmp"));
+ }
+
+ [Fact]
+ public void ParseFailure_FallsBackToDefaults()
+ {
+ string path = Path.Combine(_dir, "settings.json");
+ Directory.CreateDirectory(_dir);
+ System.IO.File.WriteAllText(path, "\u0000junk");
+ var file = Store(path, _ => throw new FormatException("bad"));
+ Assert.Equal("defaults", file.Load());
+ Assert.Contains("load failed", Assert.Single(_reports));
+ }
+
+ [Fact]
+ public void SaveFailure_DoesNotThrow()
+ {
+ Directory.CreateDirectory(Path.Combine(_dir, "settings.json")); // a folder where the file should go
+ Store(Path.Combine(_dir, "settings.json")).Save("x");
+ Assert.Contains("save failed", Assert.Single(_reports));
+ Assert.False(System.IO.File.Exists(Path.Combine(_dir, "settings.json.tmp")), "temp file cleaned up");
+ }
+
+ [Fact]
+ public void BareRelativePath_SavesInTheWorkingFolder()
+ {
+ string name = "godosa-settings-" + Guid.NewGuid().ToString("N") + ".json";
+ try
+ {
+ Store(name).Save("x");
+ Assert.Empty(_reports);
+ Assert.Equal("x", System.IO.File.ReadAllText(Path.GetFullPath(name)));
+ }
+ finally
+ {
+ System.IO.File.Delete(Path.GetFullPath(name));
+ }
+ }
+
+ [Fact]
+ public void DefaultPath_UsesOverrideWhenSet_ElseAppDataFolder()
+ {
+ string variable = "GODOSA_TEST_SETTINGS_" + Guid.NewGuid().ToString("N");
+ string appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
+ Assert.Equal(Path.Combine(appData, "some-game", "settings.json"), SettingsFile<string>.DefaultPath(variable, "some-game"));
+ Environment.SetEnvironmentVariable(variable, "/tmp/x.json");
+ try
+ {
+ Assert.Equal("/tmp/x.json", SettingsFile<string>.DefaultPath(variable, "some-game"));
+ }
+ finally
+ {
+ Environment.SetEnvironmentVariable(variable, null);
+ }
+ }
+}
diff --git a/src/Godosa.Core/Audio/AudioTypes.cs b/src/Godosa.Core/Audio/AudioTypes.cs
new file mode 100644
index 0000000..1ef0730
--- /dev/null
+++ b/src/Godosa.Core/Audio/AudioTypes.cs
@@ -0,0 +1,45 @@
+namespace Godosa.Core.Audio;
+
+/// <summary>An endless stereo source the audio thread pulls blocks from.</summary>
+public interface IStereoStream
+{
+ /// <summary>Fills <paramref name="frames"/> interleaved L/R frames, −1…1.</summary>
+ void Render(float[] interleaved, int frames);
+}
+
+/// <summary>Headphone 3D (binaural). Auto lets OpenAL Soft decide from the output device.</summary>
+public enum HrtfMode { Auto, On, Off }
+
+/// <summary>Why the game is (not) paused, as far as sound cares.</summary>
+public enum AudioPauseState { Running, Menu, Inactive }
+
+/// <summary>The game's mix policy, read by the audio thread every update (≈ 10 ms).
+/// Channels are the game's own small ints (cast its enum).</summary>
+public interface IAudioMix
+{
+ HrtfMode Hrtf { get; }
+ float ChannelGain(int channel, AudioPauseState state);
+ /// <summary>True if sounds on this channel pause with the game world.</summary>
+ bool Pausable(int channel);
+ /// <summary>True if the game world (pausable channels) is paused in this state.</summary>
+ bool WorldPaused(AudioPauseState state);
+ /// <summary>The channel's name in debug logs (default: its number).</summary>
+ string ChannelName(int channel) => channel.ToString(System.Globalization.CultureInfo.InvariantCulture);
+}
+
+/// <summary>EFX standard reverb parameters (OpenAL Soft preset values, efx-presets.h).</summary>
+public readonly record struct ReverbPreset(
+ float Density, float Diffusion, float Gain, float GainHF, float DecayTime, float DecayHFRatio,
+ float ReflectionsGain, float ReflectionsDelay, float LateReverbGain, float LateReverbDelay,
+ float AirAbsorptionGainHF, float RoomRolloffFactor, bool DecayHFLimit)
+{
+ public static readonly ReverbPreset Forest = new(1f, 0.3f, 0.3162f, 0.0224f, 1.49f, 0.54f,
+ 0.0525f, 0.162f, 0.7682f, 0.088f, 0.9943f, 0f, false);
+
+ public static readonly ReverbPreset Plain = new(1f, 0.21f, 0.3162f, 0.1f, 1.49f, 0.5f,
+ 0.0585f, 0.179f, 0.1089f, 0.1f, 0.9943f, 0f, true);
+
+ /// <summary>EFX_REVERB_PRESET_CAVE: long, bright, dense.</summary>
+ public static readonly ReverbPreset Cave = new(1f, 1f, 0.3162f, 1f, 2.91f, 1.3f,
+ 0.5f, 0.015f, 0.7063f, 0.022f, 0.9943f, 0f, false);
+}
diff --git a/src/Godosa.Core/Audio/BiquadFilter.cs b/src/Godosa.Core/Audio/BiquadFilter.cs
new file mode 100644
index 0000000..d0c0f5e
--- /dev/null
+++ b/src/Godosa.Core/Audio/BiquadFilter.cs
@@ -0,0 +1,37 @@
+using System;
+
+namespace Godosa.Core.Audio;
+
+public enum FilterKind { LowPass, HighPass, BandPass }
+
+/// <summary>Stateful RBJ-cookbook biquad (transposed direct form II): keeps its state between
+/// calls, so a stream filters block after block without seams.</summary>
+public sealed class BiquadFilter
+{
+ private float _b0, _b1, _b2, _a1, _a2, _z1, _z2;
+
+ public void Set(FilterKind kind, float hz, float q) => SetAt(Pcm.SampleRate, kind, hz, q);
+
+ /// <summary>For a filter run at another rate (e.g. a control-rate modulator).</summary>
+ public void SetAt(float sampleRate, FilterKind kind, float hz, float q)
+ {
+ float f = Math.Clamp(hz, 1f, sampleRate * 0.45f);
+ float w = 2f * MathF.PI * f / sampleRate, cos = MathF.Cos(w), alpha = MathF.Sin(w) / (2f * q);
+ float a0 = 1f + alpha;
+ var (b0, b1, b2) = kind switch
+ {
+ FilterKind.LowPass => ((1f - cos) / 2f, 1f - cos, (1f - cos) / 2f),
+ FilterKind.HighPass => ((1f + cos) / 2f, -(1f + cos), (1f + cos) / 2f),
+ _ => (alpha, 0f, -alpha), // band-pass, 0 dB peak
+ };
+ (_b0, _b1, _b2, _a1, _a2) = (b0 / a0, b1 / a0, b2 / a0, -2f * cos / a0, (1f - alpha) / a0);
+ }
+
+ public float Process(float x)
+ {
+ float y = _b0 * x + _z1;
+ _z1 = _b1 * x - _a1 * y + _z2;
+ _z2 = _b2 * x - _a2 * y;
+ return y;
+ }
+}
diff --git a/src/Godosa.Core/Audio/Mp3.cs b/src/Godosa.Core/Audio/Mp3.cs
new file mode 100644
index 0000000..8ca1b57
--- /dev/null
+++ b/src/Godosa.Core/Audio/Mp3.cs
@@ -0,0 +1,133 @@
+using System;
+using System.IO;
+using NLayer;
+
+namespace Godosa.Core.Audio;
+
+/// <summary>Whole MP3 files decoded at once (NLayer), downmixed to mono at the file's rate like
+/// <see cref="WavReader"/>: for positioned sounds and loop emitters. Undecodable → null.</summary>
+public static class Mp3Reader
+{
+ /// <summary>Length in seconds from the frame headers (NLayer's scan, no decoding: ~1/60 of <see cref="Read"/>'s cost),
+ /// so a caller can time a sound and decode it elsewhere. Undecodable → null.</summary>
+ public static double? Seconds(byte[] file)
+ {
+ try
+ {
+ using var mp3 = new MpegFile(new MemoryStream(file, writable: false));
+ var samples = mp3.Length / (sizeof(float) * Math.Max(1, mp3.Channels));
+ return samples > 0 && mp3.SampleRate > 0 ? samples / (double)mp3.SampleRate : null;
+ }
+ catch (Exception e) when (e is InvalidDataException or IOException or InvalidOperationException
+ or ArgumentException or IndexOutOfRangeException or NullReferenceException)
+ {
+ return null;
+ }
+ }
+
+ public static PcmClip? Read(byte[] file)
+ {
+ try
+ {
+ using var mp3 = new MpegFile(new MemoryStream(file, writable: false));
+ var channels = Math.Max(1, mp3.Channels);
+ var samples = new System.Collections.Generic.List<float>();
+ var block = new float[4096 * channels];
+ int read;
+ while ((read = mp3.ReadSamples(block, 0, block.Length)) > 0)
+ samples.AddRange(block.AsSpan(0, read - read % channels).ToArray());
+ return samples.Count == 0 ? null : new PcmClip(Pcm.Downmix([.. samples], channels), mp3.SampleRate);
+ }
+ catch (Exception e) when (e is InvalidDataException or IOException or InvalidOperationException
+ or ArgumentException or IndexOutOfRangeException or NullReferenceException)
+ {
+ return null;
+ }
+ }
+}
+
+/// <summary>An MP3 as a stereo stream (NLayer decode on the audio thread; lifted from an earlier game of the same author): mono is duplicated,
+/// other sample rates are resampled linearly to <see cref="Pcm.SampleRate"/>; a once-only stream goes silent and reports
+/// <see cref="Finished"/>. For music and speech.</summary>
+public sealed class Mp3Stream : IStereoStream, IDisposable
+{
+ private readonly MpegFile _file;
+ private readonly bool _loop;
+ private readonly int _channels;
+ private readonly double _step;
+ private float[] _buffer = [];
+ private int _available, _read;
+ private double _position;
+ private volatile bool _finished;
+
+ public Mp3Stream(byte[] data, bool loop)
+ {
+ _file = new MpegFile(new MemoryStream(data, writable: false));
+ _loop = loop;
+ _channels = Math.Max(1, _file.Channels);
+ _step = _file.SampleRate / (double)Pcm.SampleRate;
+ }
+
+ public bool Finished => _finished;
+
+ public void Render(float[] interleaved, int frames)
+ {
+ for (var f = 0; f < frames; f++)
+ {
+ if (!Frame(out var left, out var right))
+ {
+ Array.Clear(interleaved, 2 * f, 2 * (frames - f));
+ return;
+ }
+ interleaved[2 * f] = left;
+ interleaved[2 * f + 1] = right;
+ _position += _step;
+ }
+ }
+
+ private bool Frame(out float left, out float right)
+ {
+ left = right = 0f;
+ var index = (int)_position;
+ while (index >= _available)
+ {
+ if (!Fill())
+ return false;
+ index = (int)_position;
+ }
+ var next = Math.Min(index + 1, _available - 1);
+ var t = (float)(_position - index);
+ left = Lerp(_buffer[index * _channels], _buffer[next * _channels], t);
+ right = _channels > 1 ? Lerp(_buffer[index * _channels + 1], _buffer[next * _channels + 1], t) : left;
+ return true;
+ }
+
+ private static float Lerp(float a, float b, float t) => a + (b - a) * t;
+
+ private bool Fill()
+ {
+ if (_finished)
+ return false;
+ _position -= _available;
+ if (_buffer.Length == 0)
+ _buffer = new float[4096 * _channels];
+ var read = _file.ReadSamples(_buffer, 0, _buffer.Length);
+ if (read <= 0 && _loop && _read > 0)
+ {
+ _file.Position = 0;
+ _read = 0;
+ read = _file.ReadSamples(_buffer, 0, _buffer.Length);
+ }
+ if (read <= 0)
+ {
+ _finished = true;
+ _available = 0;
+ return false;
+ }
+ _read += read;
+ _available = read / _channels;
+ return true;
+ }
+
+ public void Dispose() => _file.Dispose();
+}
diff --git a/src/Godosa.Core/Audio/Pcm.cs b/src/Godosa.Core/Audio/Pcm.cs
new file mode 100644
index 0000000..2af554a
--- /dev/null
+++ b/src/Godosa.Core/Audio/Pcm.cs
@@ -0,0 +1,24 @@
+using System;
+
+namespace Godosa.Core.Audio;
+
+/// <summary>16-bit mono PCM helpers.</summary>
+public static class Pcm
+{
+ public const int SampleRate = 44100;
+
+ /// <summary>Interleaved float frames (e.g. decoded OGG) → mono 16-bit.</summary>
+ public static short[] Downmix(float[] interleaved, int channels)
+ {
+ var pcm = new short[interleaved.Length / channels];
+ for (int f = 0; f < pcm.Length; f++)
+ {
+ float sum = 0f;
+ for (int c = 0; c < channels; c++) sum += interleaved[f * channels + c];
+ pcm[f] = ToShort(sum / channels);
+ }
+ return pcm;
+ }
+
+ public static short ToShort(float sample) => (short)Math.Clamp(MathF.Round(sample * short.MaxValue), -short.MaxValue, short.MaxValue);
+}
diff --git a/src/Godosa.Core/Audio/SignalStats.cs b/src/Godosa.Core/Audio/SignalStats.cs
new file mode 100644
index 0000000..34f30df
--- /dev/null
+++ b/src/Godosa.Core/Audio/SignalStats.cs
@@ -0,0 +1,51 @@
+namespace Godosa.Core.Audio;
+
+/// <summary>Signal measurements for audio tests and reports: zero crossings, RMS, spectral centroid.</summary>
+public static class SignalStats
+{
+ public static int ZeroCrossings(float[] x, int from = 0, int to = -1)
+ {
+ if (to < 0) to = x.Length;
+ int n = 0;
+ for (int i = from + 1; i < to; i++) if ((x[i - 1] < 0) != (x[i] < 0)) n++;
+ return n;
+ }
+
+ public static float Rms(float[] x, int from = 0)
+ {
+ double sum = 0;
+ for (int i = from; i < x.Length; i++) sum += x[i] * x[i];
+ return (float)Math.Sqrt(sum / (x.Length - from));
+ }
+
+ /// <summary>Power-weighted mean frequency (where the energy is) of the loudest 2048-sample
+ /// window, naive DFT. Magnitude weighting over-counts faint broadband tails.</summary>
+ public static float Centroid(short[] pcm, int rate = Pcm.SampleRate)
+ {
+ const int n = 2048;
+ int best = 0;
+ double bestEnergy = -1;
+ for (int start = 0; start + n <= Math.Max(pcm.Length, n); start += n / 2)
+ {
+ double e = 0;
+ for (int i = start; i < Math.Min(pcm.Length, start + n); i++) e += (double)pcm[i] * pcm[i];
+ if (e > bestEnergy) (bestEnergy, best) = (e, start);
+ }
+ double weighted = 0, total = 0;
+ for (int k = 1; k < n / 2; k++)
+ {
+ double re = 0, im = 0;
+ for (int i = 0; i < n; i++)
+ {
+ double v = best + i < pcm.Length ? pcm[best + i] : 0;
+ double a = 2 * Math.PI * k * i / n;
+ re += v * Math.Cos(a);
+ im -= v * Math.Sin(a);
+ }
+ double power = re * re + im * im;
+ weighted += power * k * rate / (double)n;
+ total += power;
+ }
+ return (float)(weighted / total);
+ }
+}
diff --git a/src/Godosa.Core/Audio/Synth.cs b/src/Godosa.Core/Audio/Synth.cs
new file mode 100644
index 0000000..cb663a9
--- /dev/null
+++ b/src/Godosa.Core/Audio/Synth.cs
@@ -0,0 +1,189 @@
+using System;
+
+namespace Godosa.Core.Audio;
+
+/// <summary>Deterministic xorshift32 for sound variants and seeded songs. The seed is mixed first
+/// (murmur3 fmix32): raw xorshift's first draws barely change between nearby seeds.</summary>
+public sealed class SynthRandom(uint seed)
+{
+ private uint _state = Mix(seed) is var s && s != 0 ? s : 0x9E3779B9u;
+
+ public static uint Mix(uint h)
+ {
+ h ^= h >> 16;
+ h *= 0x85EBCA6Bu;
+ h ^= h >> 13;
+ h *= 0xC2B2AE35u;
+ h ^= h >> 16;
+ return h;
+ }
+
+ public float Next01()
+ {
+ _state ^= _state << 13;
+ _state ^= _state >> 17;
+ _state ^= _state << 5;
+ return (_state >> 8) / 16777216f;
+ }
+
+ public float Range(float min, float max) => min + (max - min) * Next01();
+
+ /// <summary>Uniform in [−1, 1).</summary>
+ public float Signed() => Next01() * 2f - 1f;
+
+ /// <summary>FNV-1a: stable across runs (string.GetHashCode isn't).</summary>
+ public static uint Hash(string text)
+ {
+ uint h = 2166136261;
+ foreach (char c in text) h = (h ^ c) * 16777619;
+ return h;
+ }
+}
+
+public enum WaveShape { Sine, Saw, Square, Triangle }
+
+/// <summary>Placeholder sound synthesis (audio spec §3): float buffers at 44.1 kHz,
+/// oscillators with exponential sweeps, noise, envelopes, swept RBJ biquads.</summary>
+public static class Synth
+{
+ public const int Rate = Pcm.SampleRate;
+ private const int FilterBlock = 32;
+
+ public static int Samples(float seconds) => Math.Max(1, (int)(seconds * Rate));
+
+ public static float[] Sine(float seconds, float f0, float? f1 = null) => Wave(WaveShape.Sine, seconds, f0, f1 ?? f0);
+
+ /// <summary>Oscillator whose pitch sweeps exponentially from f0 to f1 over the buffer.</summary>
+ public static float[] Wave(WaveShape shape, float seconds, float f0, float f1)
+ {
+ var x = new float[Samples(seconds)];
+ double phase = 0, ratio = f1 / f0;
+ for (int i = 0; i < x.Length; i++)
+ {
+ double f = f0 * Math.Pow(ratio, i / (double)x.Length);
+ float p = (float)(phase - Math.Floor(phase));
+ x[i] = shape switch
+ {
+ WaveShape.Saw => 2f * p - 1f,
+ WaveShape.Square => p < 0.5f ? 1f : -1f,
+ WaveShape.Triangle => 1f - 4f * MathF.Abs(p - 0.5f),
+ _ => MathF.Sin(2f * MathF.PI * p),
+ };
+ phase += f / Rate;
+ }
+ return x;
+ }
+
+ /// <summary>FM: carrier f, modulator at f·ratio, index sweeping index0 → index1 (bells, chirps).</summary>
+ public static float[] Fm(float seconds, float carrier, float ratio, float index0, float index1, float? carrierEnd = null)
+ {
+ var x = new float[Samples(seconds)];
+ double pc = 0, pm = 0, cRatio = (carrierEnd ?? carrier) / carrier;
+ for (int i = 0; i < x.Length; i++)
+ {
+ float t = i / (float)x.Length;
+ double c = carrier * Math.Pow(cRatio, t);
+ float index = index0 + (index1 - index0) * t;
+ x[i] = (float)Math.Sin(2 * Math.PI * pc + index * Math.Sin(2 * Math.PI * pm));
+ pc += c / Rate;
+ pm += c * ratio / Rate;
+ }
+ return x;
+ }
+
+ public static float[] White(float seconds, SynthRandom rng)
+ {
+ var x = new float[Samples(seconds)];
+ for (int i = 0; i < x.Length; i++) x[i] = rng.Signed();
+ return x;
+ }
+
+ /// <summary>Pink-ish noise (Paul Kellet's economy filter): softer, like wind and leaves.</summary>
+ public static float[] Pink(float seconds, SynthRandom rng)
+ {
+ var x = new float[Samples(seconds)];
+ float b0 = 0, b1 = 0, b2 = 0;
+ for (int i = 0; i < x.Length; i++)
+ {
+ float w = rng.Signed();
+ b0 = 0.99765f * b0 + w * 0.0990460f;
+ b1 = 0.96300f * b1 + w * 0.2965164f;
+ b2 = 0.57000f * b2 + w * 1.0526913f;
+ x[i] = (b0 + b1 + b2 + w * 0.1848f) * 0.25f;
+ }
+ return x;
+ }
+
+ /// <summary>Linear attack to 1, then exponential decay with time constant <paramref name="decay"/>.</summary>
+ public static float[] Perc(this float[] x, float attack, float decay)
+ {
+ for (int i = 0; i < x.Length; i++)
+ {
+ float t = i / (float)Rate;
+ x[i] *= t < attack ? t / attack : MathF.Exp(-(t - attack) / decay);
+ }
+ return x;
+ }
+
+ public static float[] Gain(this float[] x, float gain)
+ {
+ for (int i = 0; i < x.Length; i++) x[i] *= gain;
+ return x;
+ }
+
+ /// <summary>Adds <paramref name="y"/> into <paramref name="into"/> at a time offset (clipped to fit).</summary>
+ public static float[] Add(this float[] into, float[] y, float atSeconds = 0f, float gain = 1f)
+ {
+ int offset = (int)(atSeconds * Rate);
+ for (int i = 0; i < y.Length && offset + i < into.Length; i++) into[offset + i] += y[i] * gain;
+ return into;
+ }
+
+ public static float[] LowPass(this float[] x, float f0, float? f1 = null, float q = 0.7071f) => Filter(x, FilterKind.LowPass, f0, f1 ?? f0, q);
+ public static float[] HighPass(this float[] x, float f0, float? f1 = null, float q = 0.7071f) => Filter(x, FilterKind.HighPass, f0, f1 ?? f0, q);
+ public static float[] BandPass(this float[] x, float f0, float q, float? f1 = null) => Filter(x, FilterKind.BandPass, f0, f1 ?? f0, q);
+
+ /// <summary>Biquad with its cutoff swept exponentially f0 → f1, retuned every 32 samples. In place.</summary>
+ private static float[] Filter(float[] x, FilterKind kind, float f0, float f1, float q)
+ {
+ var filter = new BiquadFilter();
+ for (int i = 0; i < x.Length; i++)
+ {
+ if (i % FilterBlock == 0) filter.Set(kind, f0 * MathF.Pow(f1 / f0, i / (float)x.Length), q);
+ x[i] = filter.Process(x[i]);
+ }
+ return x;
+ }
+
+ /// <summary>Makes a loop seamless: the extra <paramref name="overlapSamples"/> at the end fade
+ /// into the start, so the result wraps like any adjacent pair of samples.</summary>
+ public static float[] Loop(float[] x, int overlapSamples)
+ {
+ int overlap = overlapSamples, n = x.Length - overlap;
+ var y = new float[n];
+ Array.Copy(x, y, n);
+ for (int i = 0; i < overlap; i++)
+ {
+ float t = i / (float)overlap;
+ y[i] = x[i] * t + x[n + i] * (1f - t);
+ }
+ return y;
+ }
+
+ /// <summary>Normalises the peak to <paramref name="level"/> (0–1 FS) and, unless it's a loop,
+ /// fades 2 ms at both ends so starts and stops never click.</summary>
+ public static short[] ToPcm(float[] x, float level, bool fadeEnds = true)
+ {
+ float peak = 0f;
+ foreach (float v in x) peak = MathF.Max(peak, MathF.Abs(v));
+ float scale = peak > 0f ? level / peak : 0f;
+ int fade = fadeEnds ? Math.Min(x.Length / 2, Samples(0.002f)) : 0;
+ var pcm = new short[x.Length];
+ for (int i = 0; i < x.Length; i++)
+ {
+ float env = fade == 0 ? 1f : Math.Min(1f, Math.Min(i, x.Length - 1 - i) / (float)fade);
+ pcm[i] = Pcm.ToShort(x[i] * scale * env);
+ }
+ return pcm;
+ }
+}
diff --git a/src/Godosa.Core/Audio/WavReader.cs b/src/Godosa.Core/Audio/WavReader.cs
new file mode 100644
index 0000000..f587109
--- /dev/null
+++ b/src/Godosa.Core/Audio/WavReader.cs
@@ -0,0 +1,157 @@
+using System;
+using System.Buffers.Binary;
+
+namespace Godosa.Core.Audio;
+
+public readonly record struct PcmClip(short[] Samples, int SampleRate);
+
+/// <summary>WAV files for sound overrides (audio spec §3): 8/16/24-bit PCM, 32-bit float or
+/// Microsoft ADPCM (4-bit, format 2), plain or extensible header, any channel count, downmixed
+/// to mono (OpenAL only spatialises mono). Anything else → null.</summary>
+public static class WavReader
+{
+ private const int FormatPcm = 1, FormatMsAdpcm = 2, FormatFloat = 3, FormatExtensible = 0xFFFE;
+
+ public static PcmClip? Read(ReadOnlySpan<byte> file)
+ {
+ if (file.Length < 12 || !file[..4].SequenceEqual("RIFF"u8) || !file[8..12].SequenceEqual("WAVE"u8)) return null;
+ int pos = 12, format = 0, channels = 0, rate = 0, bits = 0, blockAlign = 0, factFrames = -1;
+ var fmt = ReadOnlySpan<byte>.Empty;
+ while (pos + 8 <= file.Length)
+ {
+ var id = file.Slice(pos, 4);
+ int size = BinaryPrimitives.ReadInt32LittleEndian(file.Slice(pos + 4, 4));
+ int body = pos + 8;
+ if (size < 0 || body > file.Length) return null;
+ if (id.SequenceEqual("fmt "u8) && size >= 16 && body + 16 <= file.Length)
+ {
+ format = BinaryPrimitives.ReadUInt16LittleEndian(file.Slice(body, 2));
+ channels = BinaryPrimitives.ReadUInt16LittleEndian(file.Slice(body + 2, 2));
+ rate = BinaryPrimitives.ReadInt32LittleEndian(file.Slice(body + 4, 4));
+ blockAlign = BinaryPrimitives.ReadUInt16LittleEndian(file.Slice(body + 12, 2));
+ bits = BinaryPrimitives.ReadUInt16LittleEndian(file.Slice(body + 14, 2));
+ fmt = file.Slice(body, Math.Min(size, file.Length - body));
+ if (format == FormatExtensible && size >= 26 && body + 26 <= file.Length)
+ format = BinaryPrimitives.ReadUInt16LittleEndian(file.Slice(body + 24, 2));
+ }
+ else if (id.SequenceEqual("fact"u8) && size >= 4 && body + 4 <= file.Length)
+ factFrames = BinaryPrimitives.ReadInt32LittleEndian(file.Slice(body, 4));
+ else if (id.SequenceEqual("data"u8))
+ {
+ if (channels < 1 || rate < 1) return null;
+ var data = file.Slice(body, Math.Min(size, file.Length - body));
+ // fact (sample count) usually follows fmt, but may come after data: look ahead for it.
+ if (factFrames < 0 && FindFact(file, body + size + (size & 1)) is { } later) factFrames = later;
+ var pcm = format == FormatMsAdpcm ? MsAdpcm(data, fmt, channels, blockAlign) : Decode(data, format, channels, bits);
+ if (pcm != null && factFrames >= 0 && factFrames < pcm.Length) pcm = pcm[..factFrames];
+ return pcm is null ? null : new PcmClip(pcm, rate);
+ }
+ pos = body + size + (size & 1);
+ }
+ return null;
+ }
+
+ private static int? FindFact(ReadOnlySpan<byte> file, int pos)
+ {
+ while (pos + 8 <= file.Length)
+ {
+ int size = BinaryPrimitives.ReadInt32LittleEndian(file.Slice(pos + 4, 4));
+ if (size < 0) return null;
+ if (file.Slice(pos, 4).SequenceEqual("fact"u8) && size >= 4 && pos + 12 <= file.Length)
+ return BinaryPrimitives.ReadInt32LittleEndian(file.Slice(pos + 8, 4));
+ pos += 8 + size + (size & 1);
+ }
+ return null;
+ }
+
+ private static readonly int[] AdpcmAdaptation = [230, 230, 230, 230, 307, 409, 512, 614, 768, 614, 512, 409, 307, 230, 230, 230];
+
+ /// <summary>Microsoft ADPCM: per block and channel a predictor index, delta, and two history samples (output
+ /// oldest first), then 4-bit codes, high nibble first, channels interleaved per nibble. Coefficient pairs come from
+ /// the fmt extension (cbSize, samples per block, count, pairs). A trailing partial block decodes what it holds.</summary>
+ private static short[]? MsAdpcm(ReadOnlySpan<byte> data, ReadOnlySpan<byte> fmt, int channels, int blockAlign)
+ {
+ if (channels > 2 || fmt.Length < 22 || blockAlign < 7 * channels) return null;
+ int count = BinaryPrimitives.ReadUInt16LittleEndian(fmt.Slice(20, 2));
+ if (count < 1 || fmt.Length < 22 + 4 * count) return null;
+ var coef = new (int C1, int C2)[count];
+ for (int i = 0; i < count; i++)
+ coef[i] = (BinaryPrimitives.ReadInt16LittleEndian(fmt.Slice(22 + 4 * i, 2)), BinaryPrimitives.ReadInt16LittleEndian(fmt.Slice(24 + 4 * i, 2)));
+ var mono = new System.Collections.Generic.List<short>();
+ Span<int> c1 = stackalloc int[2], c2 = stackalloc int[2], delta = stackalloc int[2], s1 = stackalloc int[2], s2 = stackalloc int[2],
+ frame = stackalloc int[2];
+ for (int start = 0; start + 7 * channels <= data.Length; start += blockAlign)
+ {
+ var block = data.Slice(start, Math.Min(blockAlign, data.Length - start));
+ int p = 0;
+ for (int c = 0; c < channels; c++)
+ {
+ int index = block[p++];
+ if (index >= count) return null;
+ (c1[c], c2[c]) = coef[index];
+ }
+ for (int c = 0; c < channels; c++, p += 2) delta[c] = BinaryPrimitives.ReadInt16LittleEndian(block.Slice(p, 2));
+ for (int c = 0; c < channels; c++, p += 2) s1[c] = BinaryPrimitives.ReadInt16LittleEndian(block.Slice(p, 2));
+ for (int c = 0; c < channels; c++, p += 2) s2[c] = BinaryPrimitives.ReadInt16LittleEndian(block.Slice(p, 2));
+ mono.Add(Mix(s2, channels));
+ mono.Add(Mix(s1, channels));
+ int ch = 0;
+ for (; p < block.Length; p++)
+ for (int shift = 4; shift >= 0; shift -= 4)
+ {
+ int code = (block[p] >> shift) & 0xF;
+ int signed = code >= 8 ? code - 16 : code;
+ int predicted = ((s1[ch] * c1[ch]) + (s2[ch] * c2[ch])) >> 8;
+ int sample = Math.Clamp(predicted + signed * delta[ch], short.MinValue, short.MaxValue);
+ (s2[ch], s1[ch]) = (s1[ch], sample);
+ delta[ch] = Math.Max(16, (AdpcmAdaptation[code] * delta[ch]) >> 8);
+ frame[ch] = sample;
+ if (++ch == channels)
+ {
+ mono.Add(Mix(frame, channels));
+ ch = 0;
+ }
+ }
+ }
+ return [.. mono];
+ }
+
+ private static short Mix(ReadOnlySpan<int> frame, int channels) => (short)(channels == 1 ? frame[0] : (frame[0] + frame[1]) / 2);
+
+ private static short[]? Decode(ReadOnlySpan<byte> data, int format, int channels, int bits)
+ {
+ int bytes = bits / 8;
+ bool supported = (format == FormatPcm && bits is 8 or 16 or 24) || (format == FormatFloat && bits == 32);
+ if (!supported) return null;
+ int frames = data.Length / (bytes * channels);
+ var pcm = new short[frames];
+ for (int f = 0; f < frames; f++)
+ {
+ if (bits == 8)
+ {
+ int sum8 = 0;
+ for (int c = 0; c < channels; c++) sum8 += (data[f * channels + c] - 128) << 8; // unsigned, 128 = 0
+ pcm[f] = (short)(sum8 / channels);
+ continue;
+ }
+ if (bits == 16)
+ {
+ int sum = 0;
+ for (int c = 0; c < channels; c++)
+ sum += BinaryPrimitives.ReadInt16LittleEndian(data.Slice((f * channels + c) * 2, 2));
+ pcm[f] = (short)(sum / channels);
+ continue;
+ }
+ float mix = 0f;
+ for (int c = 0; c < channels; c++)
+ {
+ var s = data.Slice((f * channels + c) * bytes, bytes);
+ mix += bits == 24
+ ? ((s[0] | s[1] << 8 | (sbyte)s[2] << 16) / 8388608f)
+ : BinaryPrimitives.ReadSingleLittleEndian(s);
+ }
+ pcm[f] = Pcm.ToShort(mix / channels);
+ }
+ return pcm;
+ }
+}
diff --git a/src/Godosa.Core/Diagnostics/DebugFlags.cs b/src/Godosa.Core/Diagnostics/DebugFlags.cs
new file mode 100644
index 0000000..27b63bf
--- /dev/null
+++ b/src/Godosa.Core/Diagnostics/DebugFlags.cs
@@ -0,0 +1,20 @@
+namespace Godosa.Core.Diagnostics;
+
+/// <summary>Opt-in debug categories from one environment value: a comma list,
+/// case-insensitive; <c>all</c> turns on every category but not <see cref="Exact"/>
+/// switches (perf experiments like <c>nohud</c>).</summary>
+public sealed class DebugFlags
+{
+ private readonly string[] _names;
+
+ public DebugFlags(string? value) =>
+ _names = (value ?? "").Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
+
+ public bool On(string category) =>
+ Array.Exists(_names, n =>
+ n.Equals(category, StringComparison.OrdinalIgnoreCase) ||
+ n.Equals("all", StringComparison.OrdinalIgnoreCase));
+
+ public bool Exact(string name) =>
+ Array.Exists(_names, n => n.Equals(name, StringComparison.OrdinalIgnoreCase));
+}
diff --git a/src/Godosa.Core/Diagnostics/EngineDebug.cs b/src/Godosa.Core/Diagnostics/EngineDebug.cs
new file mode 100644
index 0000000..3cad819
--- /dev/null
+++ b/src/Godosa.Core/Diagnostics/EngineDebug.cs
@@ -0,0 +1,25 @@
+namespace Godosa.Core.Diagnostics;
+
+/// <summary>The lib's own debug categories (<c>input</c>, <c>script</c>, <c>audio</c>), read
+/// from the game's variable: call <see cref="Configure"/> first in <c>Main</c>, before any lib
+/// code runs. Gate each call on its flag so disabled logging costs one bool check and no
+/// string formatting.</summary>
+public static class EngineDebug
+{
+ public static bool Input { get; private set; }
+ public static bool Script { get; private set; }
+ public static bool Audio { get; private set; }
+
+ public static void Configure(string environmentVariable) =>
+ Apply(new DebugFlags(Environment.GetEnvironmentVariable(environmentVariable)));
+
+ public static void Apply(DebugFlags flags)
+ {
+ Input = flags.On("input");
+ Script = flags.On("script");
+ Audio = flags.On("audio");
+ }
+
+ public static void Write(string category, string message) =>
+ Console.Error.WriteLine($"[{category}] {message}");
+}
diff --git a/src/Godosa.Core/Diagnostics/FrameTimeStats.cs b/src/Godosa.Core/Diagnostics/FrameTimeStats.cs
new file mode 100644
index 0000000..2d1945c
--- /dev/null
+++ b/src/Godosa.Core/Diagnostics/FrameTimeStats.cs
@@ -0,0 +1,139 @@
+namespace Godosa.Core.Diagnostics;
+
+/// <summary>One snapshot of the rolling frame window (game-loop spec §5). Times in ms.</summary>
+public readonly record struct FrameStatsSummary(
+ int Frames,
+ double Fps,
+ double AvgMs,
+ double P50Ms,
+ double P95Ms,
+ double P99Ms,
+ double MaxMs,
+ double OnePercentLowFps,
+ int Hitches,
+ double AvgWorkMs,
+ double SimTickRate,
+ double AvgTickMs,
+ int GpuSamples = 0,
+ double AvgGpuMs = 0);
+
+/// <summary>
+/// Rolling window of render-frame timings (game-loop spec §5). FPS is frames ÷
+/// window time, percentiles are nearest-rank over frame times, 1% low is the
+/// mean of the slowest 1% of frames, and a hitch is a frame over 2× the median.
+/// Buffers are preallocated so recording and summarizing don't allocate.
+/// </summary>
+public sealed class FrameTimeStats
+{
+ private readonly double[] _intervals;
+ private readonly double[] _work;
+ private readonly int[] _ticks;
+ private readonly double[] _sim;
+ private readonly double[] _sorted;
+ private readonly double[] _gpu;
+ private int _next, _gpuNext, _gpuCount;
+
+ public FrameTimeStats(int capacity = 240)
+ {
+ _intervals = new double[capacity];
+ _work = new double[capacity];
+ _ticks = new int[capacity];
+ _sim = new double[capacity];
+ _sorted = new double[capacity];
+ _gpu = new double[capacity];
+ }
+
+ /// <summary>GPU time of one frame (timer queries report a few frames late, so these
+ /// roll separately from <see cref="Record"/>).</summary>
+ public void RecordGpu(double seconds)
+ {
+ _gpu[_gpuNext] = seconds;
+ _gpuNext = (_gpuNext + 1) % Capacity;
+ if (_gpuCount < Capacity) _gpuCount++;
+ }
+
+ public int Capacity => _intervals.Length;
+ public int Count { get; private set; }
+
+ /// <param name="intervalSeconds">Time since the previous frame started.</param>
+ /// <param name="workSeconds">CPU time spent inside this frame's render callback.</param>
+ /// <param name="simTicks">Simulation ticks run this frame.</param>
+ /// <param name="simSeconds">CPU time those ticks took.</param>
+ public void Record(double intervalSeconds, double workSeconds, int simTicks, double simSeconds)
+ {
+ _intervals[_next] = intervalSeconds;
+ _work[_next] = workSeconds;
+ _ticks[_next] = simTicks;
+ _sim[_next] = simSeconds;
+ _next = (_next + 1) % Capacity;
+ if (Count < Capacity) Count++;
+ }
+
+ /// <summary>Copies frame intervals in ms, oldest first; returns how many were written.</summary>
+ public int CopyIntervalsMs(Span<float> destination)
+ {
+ int n = Math.Min(Count, destination.Length);
+ int start = (_next - n + Capacity) % Capacity;
+ for (int i = 0; i < n; i++)
+ destination[i] = (float)(_intervals[(start + i) % Capacity] * 1000.0);
+ return n;
+ }
+
+ public FrameStatsSummary Summarize()
+ {
+ int n = Count;
+ if (n == 0) return default(FrameStatsSummary) with { GpuSamples = _gpuCount, AvgGpuMs = GpuAverageMs() };
+
+ double total = 0, work = 0, sim = 0;
+ int ticks = 0;
+ for (int i = 0; i < n; i++)
+ {
+ total += _intervals[i];
+ work += _work[i];
+ ticks += _ticks[i];
+ sim += _sim[i];
+ _sorted[i] = _intervals[i];
+ }
+ Array.Sort(_sorted, 0, n);
+
+ double median = NearestRank(50, n);
+ int slowest = Math.Max(1, (int)Math.Ceiling(n * 0.01));
+ double slowestSum = 0;
+ for (int i = n - slowest; i < n; i++) slowestSum += _sorted[i];
+
+ int hitches = 0;
+ for (int i = 0; i < n; i++)
+ if (_intervals[i] > 2 * median) hitches++;
+
+ return new FrameStatsSummary(
+ Frames: n,
+ Fps: total > 0 ? n / total : 0,
+ AvgMs: total / n * 1000,
+ P50Ms: median * 1000,
+ P95Ms: NearestRank(95, n) * 1000,
+ P99Ms: NearestRank(99, n) * 1000,
+ MaxMs: _sorted[n - 1] * 1000,
+ OnePercentLowFps: slowestSum > 0 ? slowest / slowestSum : 0,
+ Hitches: hitches,
+ AvgWorkMs: work / n * 1000,
+ SimTickRate: total > 0 ? ticks / total : 0,
+ AvgTickMs: ticks > 0 ? sim / ticks * 1000 : 0,
+ GpuSamples: _gpuCount,
+ AvgGpuMs: GpuAverageMs());
+ }
+
+ private double GpuAverageMs()
+ {
+ if (_gpuCount == 0) return 0;
+ double sum = 0;
+ for (int i = 0; i < _gpuCount; i++) sum += _gpu[i];
+ return sum / _gpuCount * 1000;
+ }
+
+ /// <summary>Nearest-rank percentile of the first <paramref name="n"/> sorted intervals.</summary>
+ private double NearestRank(int percent, int n)
+ {
+ int rank = Math.Max(1, (int)Math.Ceiling(percent / 100.0 * n));
+ return _sorted[rank - 1];
+ }
+}
diff --git a/src/Godosa.Core/Diagnostics/PerfCsv.cs b/src/Godosa.Core/Diagnostics/PerfCsv.cs
new file mode 100644
index 0000000..6c4ef0d
--- /dev/null
+++ b/src/Godosa.Core/Diagnostics/PerfCsv.cs
@@ -0,0 +1,27 @@
+using System.Globalization;
+
+namespace Godosa.Core.Diagnostics;
+
+/// <summary>Per-frame CSV rows for perf-log stall hunts. Fixed columns first, then the
+/// caller's own (<see cref="Header"/> names, <see cref="Row"/> values, same order).
+/// Invariant culture; times in ms with 3 decimals; blank = unknown (e.g. GPU before a
+/// timer query reports).</summary>
+public static class PerfCsv
+{
+ public const string BaseHeader = "t_s,frame_ms,work_ms,sim_ticks,sim_ms,gpu_ms";
+
+ public static string Header(params string[] extraColumns) =>
+ extraColumns.Length == 0 ? BaseHeader : BaseHeader + "," + string.Join(',', extraColumns);
+
+ public static string Row(double timeSeconds, double frameSeconds, double workSeconds, int simTicks,
+ double simSeconds, double? gpuSeconds, params string[] extraValues)
+ {
+ string row = string.Create(CultureInfo.InvariantCulture,
+ $"{timeSeconds:F3},{frameSeconds * 1000:F3},{workSeconds * 1000:F3},{simTicks},{simSeconds * 1000:F3},{Ms(gpuSeconds)}");
+ return extraValues.Length == 0 ? row : row + "," + string.Join(',', extraValues);
+ }
+
+ /// <summary>Seconds → ms with 3 decimals (invariant); blank for null.</summary>
+ public static string Ms(double? seconds) =>
+ seconds is { } s ? (s * 1000).ToString("F3", CultureInfo.InvariantCulture) : "";
+}
diff --git a/src/Godosa.Core/Godosa.Core.csproj b/src/Godosa.Core/Godosa.Core.csproj
new file mode 100644
index 0000000..41ac853
--- /dev/null
+++ b/src/Godosa.Core/Godosa.Core.csproj
@@ -0,0 +1,5 @@
+<Project Sdk="Microsoft.NET.Sdk">
+ <ItemGroup>
+ <PackageReference Include="NLayer" />
+ </ItemGroup>
+</Project>
diff --git a/src/Godosa.Core/Input/GamepadState.cs b/src/Godosa.Core/Input/GamepadState.cs
new file mode 100644
index 0000000..5f1d5a5
--- /dev/null
+++ b/src/Godosa.Core/Input/GamepadState.cs
@@ -0,0 +1,58 @@
+using System.Numerics;
+
+namespace Godosa.Core.Input;
+
+[Flags]
+public enum PadButtons
+{
+ None = 0,
+ A = 1 << 0,
+ B = 1 << 1,
+ X = 1 << 2,
+ Y = 1 << 3,
+ Back = 1 << 4,
+ Start = 1 << 5,
+ LeftShoulder = 1 << 6,
+ RightShoulder = 1 << 7,
+ DpadUp = 1 << 8,
+ DpadDown = 1 << 9,
+ DpadLeft = 1 << 10,
+ DpadRight = 1 << 11,
+}
+
+/// <summary>Raw held pad state, no game meaning: sticks −1…1 (SDL convention, stick up =
+/// −Y), triggers 0…1. Each game maps it to its own input frame.</summary>
+public readonly record struct GamepadState(Vector2 LeftStick, Vector2 RightStick, float LeftTrigger, float RightTrigger, PadButtons Buttons)
+{
+ public bool Held(PadButtons button) => (Buttons & button) != 0;
+}
+
+/// <summary>Input-script pad events (<c>PadA PadB PadX PadY PadBack PadStart PadUp PadDown
+/// PadLeft PadRight PadLB PadRB PadLT PadRT</c>, case-insensitive): triggers go full on/off.</summary>
+public static class GamepadScript
+{
+ public static GamepadState? Apply(GamepadState state, string name, bool down)
+ {
+ string key = name.ToLowerInvariant();
+ if (key == "padlt") return state with { LeftTrigger = down ? 1f : 0f };
+ if (key == "padrt") return state with { RightTrigger = down ? 1f : 0f };
+ PadButtons button = key switch
+ {
+ "pada" => PadButtons.A,
+ "padb" => PadButtons.B,
+ "padx" => PadButtons.X,
+ "pady" => PadButtons.Y,
+ "padback" => PadButtons.Back,
+ "padstart" => PadButtons.Start,
+ "padup" => PadButtons.DpadUp,
+ "paddown" => PadButtons.DpadDown,
+ "padleft" => PadButtons.DpadLeft,
+ "padright" => PadButtons.DpadRight,
+ "padlb" => PadButtons.LeftShoulder,
+ "padrb" => PadButtons.RightShoulder,
+ _ => PadButtons.None,
+ };
+ if (button == PadButtons.None) return null;
+ return state with { Buttons = down ? state.Buttons | button : state.Buttons & ~button };
+ }
+}
diff --git a/src/Godosa.Core/Input/InputScript.cs b/src/Godosa.Core/Input/InputScript.cs
new file mode 100644
index 0000000..481b78f
--- /dev/null
+++ b/src/Godosa.Core/Input/InputScript.cs
@@ -0,0 +1,82 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Linq;
+
+namespace Godosa.Core.Input;
+
+public enum ScriptAction { Down, Up, Click, Type }
+
+/// <param name="Arg">Physical key name (Down/Up), button label (Click) or text (Type).</param>
+public readonly record struct ScriptEvent(double Time, ScriptAction Action, string Arg);
+
+/// <summary>Timed input for smoke runs (the host replays it as real key events and button
+/// clicks). One per line: <c>TIME ACTION ARGS</c>, TIME absolute seconds or <c>+seconds</c>
+/// after the previous line; actions <c>down K</c>, <c>up K</c>, <c>press K</c>,
+/// <c>hold K SECONDS</c>, <c>click LABEL</c>, <c>type TEXT</c> (into the focused text box); <c>#</c> comments.</summary>
+public sealed class InputScript
+{
+ public const double PressSeconds = 0.1;
+
+ private int _next;
+
+ public IReadOnlyList<ScriptEvent> Events { get; }
+
+ public bool Finished => _next >= Events.Count;
+
+ private InputScript(List<ScriptEvent> events) => Events = events;
+
+ public static InputScript Parse(string text)
+ {
+ var events = new List<ScriptEvent>();
+ double? previous = null;
+ var lines = text.Split('\n');
+ for (int n = 0; n < lines.Length; n++)
+ {
+ string line = lines[n].Trim();
+ if (line.Length == 0 || line.StartsWith('#')) continue;
+ string[] parts = line.Split(' ', 3, StringSplitOptions.RemoveEmptyEntries);
+ FormatException Bad(string why) => new($"input script line {n + 1}: {why}: '{line}'");
+ if (parts.Length < 3) throw Bad("expected TIME ACTION ARG");
+
+ bool relative = parts[0].StartsWith('+');
+ if (!double.TryParse(parts[0].TrimStart('+'), NumberStyles.Float, CultureInfo.InvariantCulture, out double t))
+ throw Bad("bad time");
+ if (relative)
+ {
+ if (previous is null) throw Bad("relative time needs a line before it");
+ t += previous.Value;
+ }
+ previous = t;
+
+ string arg = parts[2];
+ switch (parts[1])
+ {
+ case "down": events.Add(new(t, ScriptAction.Down, arg)); break;
+ case "up": events.Add(new(t, ScriptAction.Up, arg)); break;
+ case "press":
+ events.Add(new(t, ScriptAction.Down, arg));
+ events.Add(new(t + PressSeconds, ScriptAction.Up, arg));
+ break;
+ case "hold":
+ string[] hold = arg.Split(' ', StringSplitOptions.RemoveEmptyEntries);
+ if (hold.Length != 2 || !double.TryParse(hold[1], NumberStyles.Float, CultureInfo.InvariantCulture, out double seconds))
+ throw Bad("hold needs KEY SECONDS");
+ events.Add(new(t, ScriptAction.Down, hold[0]));
+ events.Add(new(t + seconds, ScriptAction.Up, hold[0]));
+ break;
+ case "click": events.Add(new(t, ScriptAction.Click, arg)); break;
+ case "type": events.Add(new(t, ScriptAction.Type, arg)); break;
+ default: throw Bad($"unknown action '{parts[1]}'");
+ }
+ }
+ return new InputScript(events.OrderBy(e => e.Time).ToList()); // stable: same-time lines keep order
+ }
+
+ /// <summary>Appends every not-yet-returned event with Time ≤ <paramref name="now"/>.</summary>
+ public void Due(double now, List<ScriptEvent> into)
+ {
+ while (_next < Events.Count && Events[_next].Time <= now)
+ into.Add(Events[_next++]);
+ }
+}
diff --git a/src/Godosa.Core/Input/MenuCursor.cs b/src/Godosa.Core/Input/MenuCursor.cs
new file mode 100644
index 0000000..be6cab3
--- /dev/null
+++ b/src/Godosa.Core/Input/MenuCursor.cs
@@ -0,0 +1,21 @@
+namespace Godosa.Core.Input;
+
+/// <summary>Which action a gamepad highlights in an open window (hud spec §4): wraps at the
+/// ends, stays inside a list that shrinks, −1 when there's nothing to pick.</summary>
+public sealed class MenuCursor
+{
+ public int Index { get; private set; } = -1;
+
+ public void Clamp(int count)
+ {
+ if (count <= 0) Index = -1;
+ else if (Index < 0) Index = 0;
+ else if (Index >= count) Index = count - 1;
+ }
+
+ public void Move(int delta, int count)
+ {
+ if (count <= 0) return;
+ Index = ((Index + delta) % count + count) % count;
+ }
+}
diff --git a/src/Godosa.Core/Runs/DigestLine.cs b/src/Godosa.Core/Runs/DigestLine.cs
new file mode 100644
index 0000000..bc4519b
--- /dev/null
+++ b/src/Godosa.Core/Runs/DigestLine.cs
@@ -0,0 +1,13 @@
+using System.Globalization;
+
+namespace Godosa.Core.Runs;
+
+/// <summary>The shared digest stdout line: <c>digest &lt;frame&gt; &lt;game seconds 0.###&gt; &lt;16 hex&gt;</c>.</summary>
+public static class DigestLine
+{
+ public static string Format(long frame, double gameSeconds, ulong hash) =>
+ string.Create(CultureInfo.InvariantCulture, $"digest {frame} {gameSeconds:0.###} {hash:x16}");
+
+ /// <summary>Every <paramref name="every"/> frames (frame 0 and every ≤ 0 never).</summary>
+ public static bool Due(long frame, int every) => every > 0 && frame > 0 && frame % every == 0;
+}
diff --git a/src/Godosa.Core/Runs/FrameClock.cs b/src/Godosa.Core/Runs/FrameClock.cs
new file mode 100644
index 0000000..eedb78d
--- /dev/null
+++ b/src/Godosa.Core/Runs/FrameClock.cs
@@ -0,0 +1,23 @@
+namespace Godosa.Core.Runs;
+
+/// <summary>Game time per frame: deterministic → a fixed <see cref="Step"/> whatever the wall clock; otherwise the wall dt
+/// clamped to [0, maxDt]. <see cref="Held"/> (e.g. a map still loading) → no game time and no frame counted.</summary>
+public sealed class FrameClock(bool deterministic, double step = 1.0 / 60, double maxDt = 0.1)
+{
+ public bool Deterministic { get; } = deterministic;
+ public double Step { get; } = step;
+ public long Frame { get; private set; }
+ public double Seconds { get; private set; }
+ public bool Held { get; set; }
+
+ /// <summary>The game dt of this frame (0 when held); counts the frame unless held.</summary>
+ public double Next(double wallDt)
+ {
+ if (Held)
+ return 0;
+ var dt = Deterministic ? Step : Math.Clamp(wallDt, 0, maxDt);
+ Frame++;
+ Seconds += dt;
+ return dt;
+ }
+}
diff --git a/src/Godosa.Core/Runs/HeadlessLoop.cs b/src/Godosa.Core/Runs/HeadlessLoop.cs
new file mode 100644
index 0000000..fc21b41
--- /dev/null
+++ b/src/Godosa.Core/Runs/HeadlessLoop.cs
@@ -0,0 +1,20 @@
+namespace Godosa.Core.Runs;
+
+/// <summary>Runs frames as fast as possible: <c>frame(n)</c> for n = 1, 2, … until it returns false, <paramref name="frames"/>
+/// is reached, or <paramref name="cap"/> (Capped = true).</summary>
+public static class HeadlessLoop
+{
+ public const long DefaultCap = 100_000;
+
+ public static (long Frames, bool Capped) Run(Func<long, bool> frame, long? frames, long cap = DefaultCap)
+ {
+ for (long n = 1; ; n++)
+ {
+ if (n > cap)
+ return (cap, true);
+ var more = frame(n);
+ if (!more || n == frames)
+ return (n, false);
+ }
+ }
+}
diff --git a/src/Godosa.Core/Runs/RouteLegs.cs b/src/Godosa.Core/Runs/RouteLegs.cs
new file mode 100644
index 0000000..e02b7c3
--- /dev/null
+++ b/src/Godosa.Core/Runs/RouteLegs.cs
@@ -0,0 +1,117 @@
+namespace Godosa.Core.Runs;
+
+/// <summary>Route planning for run scripts (docs/notes/run-scripts-routing.md): a way over a grid (8 neighbours) cut into
+/// the legs the game's own router accepts, the script seals that close a way, and portal hops between walkable areas. Pure;
+/// the game passes its grid, its router's acceptance test and its portals.</summary>
+public static class RouteLegs
+{
+ /// <summary>Breadth-first over <c>[0,width)×[0,height)</c>, 8 neighbours, until <paramref name="goal"/>: each reached
+ /// cell → the one it was reached from (start → itself).</summary>
+ public static Dictionary<(long X, long Y), (long X, long Y)> Reach((long X, long Y) start, (long X, long Y) goal, long width, long height,
+ Func<long, long, bool> blocked)
+ {
+ var from = new Dictionary<(long X, long Y), (long X, long Y)> { [start] = start };
+ var queue = new Queue<(long X, long Y)>([start]);
+ while (queue.Count > 0 && !from.ContainsKey(goal))
+ {
+ var at = queue.Dequeue();
+ for (var dx = -1; dx <= 1; dx++)
+ for (var dy = -1; dy <= 1; dy++)
+ {
+ var next = (at.X + dx, at.Y + dy);
+ if (from.ContainsKey(next) || next.Item1 < 0 || next.Item2 < 0 || next.Item1 >= width || next.Item2 >= height
+ || blocked(next.Item1, next.Item2))
+ continue;
+ from[next] = at;
+ queue.Enqueue(next);
+ }
+ }
+ return from;
+ }
+
+ /// <summary>The cells from <paramref name="start"/> (excluded) to <paramref name="goal"/> (included); null when walled
+ /// off.</summary>
+ public static List<(long X, long Y)>? Path((long X, long Y) start, (long X, long Y) goal, long width, long height, Func<long, long, bool> blocked)
+ {
+ var from = Reach(start, goal, width, height, blocked);
+ return from.ContainsKey(goal) ? Trace(from, start, goal) : null;
+ }
+
+ /// <summary>Legs along <paramref name="path"/>: from each stop the farthest path cell <paramref name="accepts"/> (the
+ /// game's router takes the trip), until the goal; null when the router takes no further cell from a stop.</summary>
+ public static List<(long X, long Y)>? Legs((long X, long Y) start, IReadOnlyList<(long X, long Y)> path,
+ Func<(long X, long Y), (long X, long Y), bool> accepts) => Legs(start, path, accepts, out _);
+
+ /// <inheritdoc cref="Legs(ValueTuple{long, long}, IReadOnlyList{ValueTuple{long, long}}, Func{ValueTuple{long, long}, ValueTuple{long, long}, bool})"/>
+ /// <param name="stuck">The stop no leg leaves (refusals name it).</param>
+ public static List<(long X, long Y)>? Legs((long X, long Y) start, IReadOnlyList<(long X, long Y)> path,
+ Func<(long X, long Y), (long X, long Y), bool> accepts, out (long X, long Y) stuck)
+ {
+ var legs = new List<(long X, long Y)>();
+ var (stop, index) = (start, 0);
+ while (index < path.Count)
+ {
+ var best = path.Count - 1;
+ while (best >= index && !accepts(stop, path[best]))
+ best--;
+ if (best < index)
+ {
+ stuck = stop;
+ return null;
+ }
+ (stop, index) = (path[best], best + 1);
+ legs.Add(stop);
+ }
+ stuck = default;
+ return legs;
+ }
+
+ /// <summary>The <paramref name="soft"/> cells (blocked by the game's scripts, opened later) the way would cross once
+ /// open, start to goal, at most <paramref name="max"/>; empty when walled off anyway or open already.</summary>
+ public static List<(long X, long Y)> Seals((long X, long Y) start, (long X, long Y) goal, long width, long height,
+ Func<long, long, bool> blocked, Func<long, long, bool> soft, int max = 8)
+ {
+ if (Path(start, goal, width, height, blocked) != null)
+ return [];
+ var way = Path(start, goal, width, height, (x, y) => blocked(x, y) && !soft(x, y));
+ return way == null ? [] : way.Where(c => soft(c.X, c.Y)).Take(max).ToList();
+ }
+
+ /// <summary>Portal hops: breadth-first over portal exits from <paramref name="start"/>; the portals to take, in order, so
+ /// that <paramref name="reaches"/> (a walk inside one area) gets to <paramref name="goal"/>; empty when it walks there;
+ /// null when no chain does.</summary>
+ public static List<TPortal>? Hops<TPos, TPortal>(TPos start, TPos goal, IReadOnlyList<TPortal> portals, Func<TPortal, TPos> at,
+ Func<TPortal, TPos> exit, Func<TPos, TPos, bool> reaches) where TPos : notnull
+ {
+ var via = new Dictionary<TPos, (TPos From, TPortal? Portal)> { [start] = (start, default) };
+ var queue = new Queue<TPos>([start]);
+ while (queue.Count > 0)
+ {
+ var here = queue.Dequeue();
+ if (reaches(here, goal))
+ {
+ var hops = new List<TPortal>();
+ for (var p = here; !EqualityComparer<TPos>.Default.Equals(p, start); p = via[p].From)
+ hops.Add(via[p].Portal!);
+ hops.Reverse();
+ return hops;
+ }
+ foreach (var portal in portals)
+ if (!via.ContainsKey(exit(portal)) && reaches(here, at(portal)))
+ {
+ via[exit(portal)] = (here, portal);
+ queue.Enqueue(exit(portal));
+ }
+ }
+ return null;
+ }
+
+ private static List<(long X, long Y)> Trace(Dictionary<(long X, long Y), (long X, long Y)> from, (long X, long Y) start, (long X, long Y) goal)
+ {
+ var path = new List<(long X, long Y)>();
+ for (var c = goal; c != start; c = from[c])
+ path.Add(c);
+ path.Reverse();
+ return path;
+ }
+}
diff --git a/src/Godosa.Core/Runs/RunFlags.cs b/src/Godosa.Core/Runs/RunFlags.cs
new file mode 100644
index 0000000..1eca433
--- /dev/null
+++ b/src/Godosa.Core/Runs/RunFlags.cs
@@ -0,0 +1,39 @@
+using System.Globalization;
+
+namespace Godosa.Core.Runs;
+
+/// <summary>Shared run flags: <c>--deterministic [seed]</c> (fixed step, seeded streams), <c>--digest [every=N]</c>
+/// (state hash lines), <c>--headless</c> (no window; implies deterministic). <see cref="Seed"/> null = the game's default.</summary>
+public sealed record RunFlags(bool Deterministic = false, ulong? Seed = null, bool Digest = false, int DigestEvery = 0, bool Headless = false)
+{
+ public ulong SeedOr(ulong fallback) => Seed ?? fallback;
+
+ /// <summary>Consumes the run flag at <paramref name="i"/> (and its optional value, advancing <paramref name="i"/>);
+ /// false when <c>args[i]</c> is no run flag.</summary>
+ public static bool TryTake(IReadOnlyList<string> args, ref int i, ref RunFlags flags)
+ {
+ string? next = i + 1 < args.Count ? args[i + 1] : null;
+ switch (args[i])
+ {
+ case "--deterministic":
+ flags = flags with { Deterministic = true };
+ if (next != null && ulong.TryParse(next, NumberStyles.None, CultureInfo.InvariantCulture, out var seed))
+ (flags, i) = (flags with { Seed = seed }, i + 1);
+ return true;
+ case "--digest":
+ flags = flags with { Digest = true };
+ if (next != null && next.StartsWith("every=", StringComparison.Ordinal))
+ {
+ if (!int.TryParse(next.AsSpan(6), NumberStyles.None, CultureInfo.InvariantCulture, out var every) || every < 1)
+ throw new ArgumentException($"--digest {next}: every=N needs N ≥ 1");
+ (flags, i) = (flags with { DigestEvery = every }, i + 1);
+ }
+ return true;
+ case "--headless":
+ flags = flags with { Headless = true, Deterministic = true };
+ return true;
+ default:
+ return false;
+ }
+ }
+}
diff --git a/src/Godosa.Core/Runs/Scripts/CheckpointCache.cs b/src/Godosa.Core/Runs/Scripts/CheckpointCache.cs
new file mode 100644
index 0000000..1e1dd3a
--- /dev/null
+++ b/src/Godosa.Core/Runs/Scripts/CheckpointCache.cs
@@ -0,0 +1,112 @@
+// Provenance: own design — shared run scripts (spec §5.4)
+using System.Buffers.Binary;
+using System.Text;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace Godosa.Core.Runs.Scripts;
+
+public sealed record LabelState(string Name, long Start, string File, int Line);
+
+public sealed record RunnerState(long Frame, ulong Digest, List<ScopeState> Scopes, List<LabelState> Labels);
+
+public sealed record CachedCheckpoint(RunnerState State, byte[] Game);
+
+public static class CheckpointKeys
+{
+ public const string RunnerVersion = "run-scripts 1";
+
+ public static IReadOnlyDictionary<int, string> Compute(ScriptProgram p, string contentVersion, ulong seed)
+ {
+ var d = new StateDigest();
+ d.Add(RunnerVersion);
+ d.Add(contentVersion);
+ d.Add(seed);
+ d.Add((int)p.Mode);
+ foreach (var (k, v) in p.Vars.OrderBy(kv => kv.Key, StringComparer.Ordinal))
+ d.Add($"{k}={v}");
+ var keys = new Dictionary<int, string>();
+ for (var i = 0; i < p.Code.Count; i++)
+ {
+ var instr = p.Code[i];
+ d.Add((int)instr.Op);
+ d.Add(instr.Format);
+ d.Add(instr.At.File);
+ d.Add(instr.Text);
+ if (instr.Op == Op.Checkpoint)
+ keys[i] = d.Hex;
+ }
+ return keys;
+ }
+}
+
+public sealed class CheckpointCache(string dir, long capBytes)
+{
+ private static readonly byte[] Magic = "GRS1"u8.ToArray();
+ private static readonly JsonSerializerOptions Json = new() { Converters = { new JsonStringEnumConverter() } };
+
+ public CachedCheckpoint? Load(string key)
+ {
+ var path = Path.Combine(dir, key + ".snap");
+ if (!File.Exists(path))
+ return null;
+ byte[] bytes;
+ try
+ {
+ bytes = File.ReadAllBytes(path);
+ }
+ catch (Exception e) when (e is IOException or UnauthorizedAccessException)
+ {
+ return null;
+ }
+ if (bytes.Length < 8 || !bytes.AsSpan(0, 4).SequenceEqual(Magic))
+ return null;
+ var length = BinaryPrimitives.ReadInt32LittleEndian(bytes.AsSpan(4));
+ if (length < 0 || 8 + length > bytes.Length)
+ return null;
+ RunnerState? state;
+ try
+ {
+ state = JsonSerializer.Deserialize<RunnerState>(Encoding.UTF8.GetString(bytes, 8, length), Json);
+ }
+ catch (JsonException)
+ {
+ return null; // a corrupt entry is a miss
+ }
+ return state == null ? null : new CachedCheckpoint(state, bytes[(8 + length)..]);
+ }
+
+ public void Save(string key, CachedCheckpoint cp)
+ {
+ Directory.CreateDirectory(dir);
+ var json = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(cp.State, Json));
+ var path = Path.Combine(dir, key + ".snap");
+ var tmp = path + ".tmp";
+ using (var f = File.Create(tmp))
+ {
+ f.Write(Magic);
+ Span<byte> len = stackalloc byte[4];
+ BinaryPrimitives.WriteInt32LittleEndian(len, json.Length);
+ f.Write(len);
+ f.Write(json);
+ f.Write(cp.Game);
+ }
+ File.Move(tmp, path, overwrite: true);
+ Evict(path);
+ }
+
+ private void Evict(string keep)
+ {
+ var files = new DirectoryInfo(dir).GetFiles("*.snap").OrderBy(f => f.LastWriteTimeUtc).ToList();
+ var total = files.Sum(f => f.Length);
+ foreach (var f in files)
+ {
+ if (total <= capBytes)
+ break;
+ if (f.FullName == Path.GetFullPath(keep))
+ continue;
+ total -= f.Length;
+ f.Delete();
+ }
+ }
+}
diff --git a/src/Godosa.Core/Runs/Scripts/Condition.cs b/src/Godosa.Core/Runs/Scripts/Condition.cs
new file mode 100644
index 0000000..346ee3b
--- /dev/null
+++ b/src/Godosa.Core/Runs/Scripts/Condition.cs
@@ -0,0 +1,234 @@
+// Provenance: own design — shared run scripts (spec §3.6)
+using System.Globalization;
+using System.Text;
+
+namespace Godosa.Core.Runs.Scripts;
+
+public interface IConditionContext
+{
+ bool HasProbe(string name);
+ RunValue Probe(string name, IReadOnlyList<string> args);
+ long? Constant(string name);
+}
+
+/// <summary>Condition AST: or / and / not / comparison / atom. A bare identifier is a probe without arguments when the
+/// game has one by that name, else a game constant.</summary>
+public abstract record Cond
+{
+ public abstract RunValue Eval(IConditionContext c);
+
+ public bool Test(IConditionContext c) => Truthy(Eval(c));
+
+ public abstract IEnumerable<(string Name, IReadOnlyList<string> Args)> Probes();
+
+ public abstract IEnumerable<string> Bare();
+
+ public static string Describe(Cond cond, IConditionContext c) =>
+ string.Join(" ", cond.Probes().Where(p => c.HasProbe(p.Name)).Distinct().Select(p =>
+ {
+ var label = p.Args.Count == 0 ? p.Name : $"{p.Name}({string.Join(",", p.Args)})";
+ return $"{label}={c.Probe(p.Name, p.Args)}";
+ }));
+
+ internal static bool Truthy(RunValue v) => v.IsNumber ? v.Number != 0 : !string.IsNullOrEmpty(v.Text);
+}
+
+public sealed record OrCond(Cond A, Cond B) : Cond
+{
+ public override RunValue Eval(IConditionContext c) => RunValue.Of(A.Test(c) || B.Test(c));
+ public override IEnumerable<(string, IReadOnlyList<string>)> Probes() => A.Probes().Concat(B.Probes());
+ public override IEnumerable<string> Bare() => A.Bare().Concat(B.Bare());
+}
+
+public sealed record AndCond(Cond A, Cond B) : Cond
+{
+ public override RunValue Eval(IConditionContext c) => RunValue.Of(A.Test(c) && B.Test(c));
+ public override IEnumerable<(string, IReadOnlyList<string>)> Probes() => A.Probes().Concat(B.Probes());
+ public override IEnumerable<string> Bare() => A.Bare().Concat(B.Bare());
+}
+
+public sealed record NotCond(Cond A) : Cond
+{
+ public override RunValue Eval(IConditionContext c) => RunValue.Of(!A.Test(c));
+ public override IEnumerable<(string, IReadOnlyList<string>)> Probes() => A.Probes();
+ public override IEnumerable<string> Bare() => A.Bare();
+}
+
+public sealed record CompareCond(Cond A, string Op, Cond B) : Cond
+{
+ public override RunValue Eval(IConditionContext c)
+ {
+ var (a, b) = (A.Eval(c), B.Eval(c));
+ if (a.IsNumber != b.IsNumber)
+ throw new ScriptFault($"cannot compare {a} with {b}");
+ var order = a.IsNumber ? a.Number!.Value.CompareTo(b.Number!.Value) : string.CompareOrdinal(a.Text, b.Text);
+ if (!a.IsNumber && Op is "<" or "<=" or ">" or ">=")
+ throw new ScriptFault($"cannot order strings {a} and {b}");
+ return RunValue.Of(Op switch
+ {
+ "=" => order == 0, "!=" => order != 0, "<" => order < 0, "<=" => order <= 0, ">" => order > 0, _ => order >= 0,
+ });
+ }
+
+ public override IEnumerable<(string, IReadOnlyList<string>)> Probes() => A.Probes().Concat(B.Probes());
+ public override IEnumerable<string> Bare() => A.Bare().Concat(B.Bare());
+}
+
+public sealed record LiteralCond(RunValue Value) : Cond
+{
+ public override RunValue Eval(IConditionContext c) => Value;
+ public override IEnumerable<(string, IReadOnlyList<string>)> Probes() => [];
+ public override IEnumerable<string> Bare() => [];
+}
+
+/// <summary>A probe call <c>name(args)</c>, or a bare <c>name</c> (probe if the game has it, else a constant).</summary>
+public sealed record NameCond(string Name, IReadOnlyList<string> Args, bool HasParens) : Cond
+{
+ public override RunValue Eval(IConditionContext c)
+ {
+ if (HasParens || c.HasProbe(Name))
+ return c.Probe(Name, Args);
+ return c.Constant(Name) is { } n ? RunValue.Of(n) : throw new ScriptFault($"unknown probe or constant {Name}");
+ }
+
+ public override IEnumerable<(string, IReadOnlyList<string>)> Probes() => [(Name, Args)];
+ public override IEnumerable<string> Bare() => HasParens ? [] : [Name];
+}
+
+public static class ConditionParser
+{
+ public static Cond Parse(string text)
+ {
+ var tokens = Lex(text);
+ var pos = 0;
+ var cond = Or(tokens, ref pos);
+ if (pos != tokens.Count)
+ throw new FormatException($"unexpected '{tokens[pos]}' in condition '{text}'");
+ return cond;
+ }
+
+ private static Cond Or(List<string> t, ref int pos)
+ {
+ var a = And(t, ref pos);
+ while (pos < t.Count && t[pos] == "or")
+ {
+ pos++;
+ a = new OrCond(a, And(t, ref pos));
+ }
+ return a;
+ }
+
+ private static Cond And(List<string> t, ref int pos)
+ {
+ var a = Not(t, ref pos);
+ while (pos < t.Count && t[pos] == "and")
+ {
+ pos++;
+ a = new AndCond(a, Not(t, ref pos));
+ }
+ return a;
+ }
+
+ private static Cond Not(List<string> t, ref int pos)
+ {
+ if (pos < t.Count && t[pos] == "not")
+ {
+ pos++;
+ return new NotCond(Not(t, ref pos));
+ }
+ return Compare(t, ref pos);
+ }
+
+ private static Cond Compare(List<string> t, ref int pos)
+ {
+ var a = Atom(t, ref pos);
+ if (pos < t.Count && t[pos] is "=" or "!=" or "<" or "<=" or ">" or ">=")
+ {
+ var op = t[pos++];
+ return new CompareCond(a, op, Atom(t, ref pos));
+ }
+ return a;
+ }
+
+ private static Cond Atom(List<string> t, ref int pos)
+ {
+ if (pos >= t.Count)
+ throw new FormatException("condition ends early");
+ var tok = t[pos++];
+ if (tok == "(")
+ {
+ var inner = Or(t, ref pos);
+ if (pos >= t.Count || t[pos] != ")")
+ throw new FormatException("missing ')'");
+ pos++;
+ return inner;
+ }
+ if (tok.StartsWith('"'))
+ return new LiteralCond(RunValue.Of(tok[1..^1]));
+ if (long.TryParse(tok, NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out var n))
+ return new LiteralCond(RunValue.Of(n));
+ if (!char.IsLetter(tok[0]) && tok[0] != '_')
+ throw new FormatException($"unexpected '{tok}'");
+ var paren = tok.IndexOf('(');
+ if (paren < 0)
+ return new NameCond(tok, [], false);
+ var args = tok[(paren + 1)..^1].Split(',', StringSplitOptions.TrimEntries).Where(a => a.Length > 0).ToArray();
+ return new NameCond(tok[..paren], args, true);
+ }
+
+ private static List<string> Lex(string text)
+ {
+ var tokens = new List<string>();
+ var i = 0;
+ while (i < text.Length)
+ {
+ var c = text[i];
+ if (c == ' ')
+ {
+ i++;
+ continue;
+ }
+ if (c is '(' or ')')
+ {
+ tokens.Add(c.ToString());
+ i++;
+ continue;
+ }
+ if (c == '"')
+ {
+ var end = text.IndexOf('"', i + 1);
+ if (end < 0)
+ throw new FormatException("unterminated quote");
+ tokens.Add(text[i..(end + 1)]);
+ i = end + 1;
+ continue;
+ }
+ if (c is '=' or '!' or '<' or '>')
+ {
+ var two = i + 1 < text.Length && text[i + 1] == '=';
+ var op = text.Substring(i, two ? 2 : 1);
+ if (op == "!")
+ throw new FormatException("use != or not");
+ tokens.Add(op);
+ i += op.Length;
+ continue;
+ }
+ var sb = new StringBuilder();
+ var depth = 0;
+ while (i < text.Length && (depth > 0 || (text[i] != ' ' && text[i] != ')' && !(text[i] is '=' or '!' or '<' or '>'))))
+ {
+ if (text[i] == '(' && sb.Length == 0)
+ break;
+ if (text[i] == '(')
+ depth++;
+ else if (text[i] == ')')
+ depth--;
+ sb.Append(text[i++]);
+ }
+ if (sb.Length == 0)
+ throw new FormatException($"unexpected '{text[i]}'");
+ tokens.Add(sb.ToString());
+ }
+ return tokens;
+ }
+}
diff --git a/src/Godosa.Core/Runs/Scripts/Input.cs b/src/Godosa.Core/Runs/Scripts/Input.cs
new file mode 100644
index 0000000..d549bb4
--- /dev/null
+++ b/src/Godosa.Core/Runs/Scripts/Input.cs
@@ -0,0 +1,24 @@
+// Provenance: own design — shared run scripts (spec §5.5)
+namespace Godosa.Core.Runs.Scripts;
+
+public enum InputKind { Move, Down, Up, Wheel, KeyDown, KeyUp, Text }
+
+public enum MouseButton { Left, Right }
+
+/// <summary>One simulated user input event at virtual-screen coordinates.</summary>
+public sealed record InputEvent(InputKind Kind, int X = 0, int Y = 0, MouseButton Button = MouseButton.Left, string Key = "",
+ string Text = "", int Delta = 0);
+
+/// <summary>An event due <see cref="Frame"/> frames after its step began.</summary>
+public sealed record PlannedInput(int Frame, InputEvent Event);
+
+public sealed record InputPlan(IReadOnlyList<PlannedInput> Events)
+{
+ public int LastFrame => Events.Count == 0 ? 0 : Events.Max(e => e.Frame);
+}
+
+/// <summary>The game's input entry: the real UI in a window, its layout + hit-testing headless.</summary>
+public interface IInputRouter
+{
+ void Send(InputEvent e);
+}
diff --git a/src/Godosa.Core/Runs/Scripts/InputPlans.cs b/src/Godosa.Core/Runs/Scripts/InputPlans.cs
new file mode 100644
index 0000000..9c150b3
--- /dev/null
+++ b/src/Godosa.Core/Runs/Scripts/InputPlans.cs
@@ -0,0 +1,111 @@
+// Provenance: own design — shared run scripts (spec §4.1)
+using System.Globalization;
+
+namespace Godosa.Core.Runs.Scripts;
+
+public static class Positions
+{
+ /// <summary><c>x,y</c> or a locator <c>@name(args)</c> (args comma separated).</summary>
+ public static (int X, int Y) Resolve(string token, IRunGame game)
+ {
+ if (token.StartsWith('@'))
+ {
+ var paren = token.IndexOf('(');
+ var name = paren > 0 ? token[1..paren] : token[1..];
+ var args = paren > 0 && token.EndsWith(')')
+ ? token[(paren + 1)..^1].Split(',', StringSplitOptions.TrimEntries).Where(a => a.Length > 0).ToArray()
+ : [];
+ if (!game.Locators.TryGetValue(name, out var locator))
+ throw new ScriptFault($"unknown locator @{name}");
+ var r = locator.Locate(args);
+ return r.IsOk ? (r.X, r.Y) : throw new ScriptFault($"locator {token}: {r.Error}");
+ }
+ var parts = token.Split(',');
+ if (parts.Length == 2 && int.TryParse(parts[0], NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out var x)
+ && int.TryParse(parts[1], NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out var y))
+ return (x, y);
+ throw new ScriptFault($"bad position '{token}'");
+ }
+}
+
+public static class InputPlans
+{
+ public static bool IsInputVerb(string verb) => verb is "click" or "rclick" or "dclick" or "drag" or "move" or "wheel"
+ or "key" or "hold" or "chord" or "type";
+
+ public static InputPlan For(string verb, IReadOnlyList<Token> args, Func<string, (int X, int Y)> position)
+ {
+ List<PlannedInput> e = [];
+ void Add(int frame, InputEvent ev) => e.Add(new PlannedInput(frame, ev));
+ switch (verb)
+ {
+ case "click" or "rclick" or "dclick":
+ {
+ var (x, y) = position(args[0].Text);
+ var button = verb == "rclick" ? MouseButton.Right : MouseButton.Left;
+ Add(0, new InputEvent(InputKind.Move, x, y));
+ Add(0, new InputEvent(InputKind.Down, x, y, button));
+ Add(1, new InputEvent(InputKind.Up, x, y, button));
+ if (verb == "dclick")
+ {
+ Add(2, new InputEvent(InputKind.Down, x, y, button));
+ Add(3, new InputEvent(InputKind.Up, x, y, button));
+ }
+ break;
+ }
+ case "drag":
+ {
+ var (ax, ay) = position(args[0].Text);
+ var (bx, by) = position(args[1].Text);
+ Add(0, new InputEvent(InputKind.Move, ax, ay));
+ Add(0, new InputEvent(InputKind.Down, ax, ay));
+ for (var step = 1; step <= 3; step++)
+ Add(step, new InputEvent(InputKind.Move, ax + (bx - ax) * step / 3, ay + (by - ay) * step / 3));
+ Add(4, new InputEvent(InputKind.Up, bx, by));
+ break;
+ }
+ case "move":
+ {
+ var (x, y) = position(args[0].Text);
+ Add(0, new InputEvent(InputKind.Move, x, y));
+ break;
+ }
+ case "wheel":
+ {
+ var delta = int.TryParse(args[0].Text, NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out var d)
+ ? d : throw new ScriptFault($"bad wheel delta '{args[0].Text}'");
+ var (x, y) = args.Count > 1 ? position(args[1].Text) : (0, 0);
+ if (args.Count > 1)
+ Add(0, new InputEvent(InputKind.Move, x, y));
+ Add(0, new InputEvent(InputKind.Wheel, x, y, Delta: delta));
+ break;
+ }
+ case "key":
+ Add(0, new InputEvent(InputKind.KeyDown, Key: args[0].Text));
+ Add(1, new InputEvent(InputKind.KeyUp, Key: args[0].Text));
+ break;
+ case "hold":
+ {
+ var frames = Durations.Frames(args[1].Text) is { } f and > 0 ? f : throw new ScriptFault($"bad hold frames '{args[1].Text}'");
+ Add(0, new InputEvent(InputKind.KeyDown, Key: args[0].Text));
+ Add(frames, new InputEvent(InputKind.KeyUp, Key: args[0].Text));
+ break;
+ }
+ case "chord":
+ {
+ var keys = args[0].Text.Split('+', StringSplitOptions.RemoveEmptyEntries);
+ foreach (var k in keys)
+ Add(0, new InputEvent(InputKind.KeyDown, Key: k));
+ foreach (var k in keys.Reverse())
+ Add(1, new InputEvent(InputKind.KeyUp, Key: k));
+ break;
+ }
+ case "type":
+ Add(0, new InputEvent(InputKind.Text, Text: args[0].Text));
+ break;
+ default:
+ throw new ScriptFault($"not an input verb: {verb}");
+ }
+ return new InputPlan(e);
+ }
+}
diff --git a/src/Godosa.Core/Runs/Scripts/LibVerbs.cs b/src/Godosa.Core/Runs/Scripts/LibVerbs.cs
new file mode 100644
index 0000000..5659b0c
--- /dev/null
+++ b/src/Godosa.Core/Runs/Scripts/LibVerbs.cs
@@ -0,0 +1,32 @@
+// Provenance: own design — shared run scripts (spec §4.1, §4.2)
+namespace Godosa.Core.Runs.Scripts;
+
+/// <summary>A verb the lib defines. <see cref="GameHook"/> = implemented by the game's <see cref="IRunGame.Actions"/>
+/// entry of the same name; otherwise the runner implements it.</summary>
+public sealed record VerbSpec(string Name, VerbKind Kind, int MinArgs, int MaxArgs, bool GameHook);
+
+public static class LibVerbs
+{
+ private const VerbKind P = VerbKind.Player, D = VerbKind.Debug;
+
+ public static IReadOnlyList<VerbSpec> All { get; } =
+ [
+ new("click", P, 1, 1, false), new("rclick", P, 1, 1, false), new("dclick", P, 1, 1, false),
+ new("drag", P, 2, 2, false), new("move", P, 1, 1, false), new("wheel", P, 1, 2, false),
+ new("key", P, 1, 1, false), new("hold", P, 2, 2, false), new("chord", P, 1, 1, false), new("type", P, 1, 1, false),
+ new("wait-frames", P, 1, 1, false), new("rng", P, 2, 3, false), new("log", P, 1, 1, false), new("output", P, 1, 1, false),
+ new("screenshot", P, 1, 1, true), new("save", P, 1, 1, true), new("load", P, 1, 1, true),
+ new("walk-to", P, 1, 1, true), new("talk", P, 1, 1, true), new("answer", P, 1, 1, true), new("attack", P, 1, 2, true),
+ new("use", P, 1, 3, true), new("equip", P, 1, 1, true), new("drop", P, 1, 1, true), new("take", P, 1, 1, true),
+ new("open-panel", P, 1, 1, true), new("close-panel", P, 0, 0, true), new("travel-to", P, 1, 1, true),
+ new("rest", P, 1, 1, true), new("buy", P, 3, 3, true), new("sell", P, 3, 3, true),
+ new("cheat", D, 1, 20, true), new("option", D, 2, 2, true), new("teleport", D, 2, 2, true),
+ new("talk-direct", D, 1, 1, true), new("kill", D, 1, 1, true), new("give", D, 1, 2, true), new("set-state", D, 2, 2, true),
+ ];
+
+ private static readonly Dictionary<string, VerbSpec> ByName = All.ToDictionary(v => v.Name, StringComparer.Ordinal);
+
+ public static VerbSpec? Find(string name) => ByName.GetValueOrDefault(name);
+
+ public static readonly string[] OutputLevels = ["quiet", "checkpoints", "labels", "steps", "trace"];
+}
diff --git a/src/Godosa.Core/Runs/Scripts/RunGame.cs b/src/Godosa.Core/Runs/Scripts/RunGame.cs
new file mode 100644
index 0000000..a6222a6
--- /dev/null
+++ b/src/Godosa.Core/Runs/Scripts/RunGame.cs
@@ -0,0 +1,68 @@
+// Provenance: own design — shared run scripts (spec §6; one hook shape for §4.2 capabilities, see Actions)
+namespace Godosa.Core.Runs.Scripts;
+
+/// <summary>A game's run-script adapter. <see cref="Actions"/> holds the lib's game-hooked verbs by their lib name
+/// (<c>talk</c>, <c>walk-to</c>, <c>cheat</c>, …) and the game's own verbs. Player-kind actions return input plans only;
+/// the lib injects them.</summary>
+public interface IRunGame
+{
+ string Name { get; }
+ string ContentVersion { get; }
+ IInputRouter Input { get; }
+ IReadOnlyDictionary<string, IProbe> Probes { get; }
+ IReadOnlyDictionary<string, long> Constants { get; }
+ IReadOnlyDictionary<string, ILocator> Locators { get; }
+ IReadOnlyDictionary<string, IRunAction> Actions { get; }
+ IRunRng? Rng { get; }
+ bool SupportsSnapshot { get; }
+ byte[] SnapshotSave();
+ /// <summary>Why the current state can't be checkpointed faithfully (e.g. state the snapshot doesn't hold), or null.
+ /// A refused checkpoint is not cached (warning); failure snapshots are still taken.</summary>
+ string? SnapshotRefusal() => null;
+ void SnapshotRestore(byte[] data);
+ /// <summary>The restore completes after this frame (a window rebuilds its game view): the runner pauses one frame
+ /// (<see cref="ScriptRunner.Restoring"/>), in which the host advances no game frame.</summary>
+ bool RestoreDeferred => false;
+ ulong Digest();
+ string Dump();
+ void Advance();
+}
+
+public interface IProbe
+{
+ int MinArgs { get; }
+ int MaxArgs { get; }
+ RunValue Eval(IReadOnlyList<string> args);
+}
+
+public readonly record struct LocatorResult(int X, int Y, string? Error)
+{
+ public static LocatorResult Ok(int x, int y) => new(x, y, null);
+ public static LocatorResult Fail(string message) => new(0, 0, message);
+ public bool IsOk => Error == null;
+}
+
+/// <summary>Finds where to aim (virtual-screen position) from game state, as a player reads the screen.</summary>
+public interface ILocator
+{
+ LocatorResult Locate(IReadOnlyList<string> args);
+}
+
+/// <summary>Begin returns an input plan to inject (player) and/or an immediate status (debug: usually Done); after the
+/// plan is sent, Poll runs each frame until Done or Fault.</summary>
+public interface IRunAction
+{
+ VerbKind Kind { get; }
+ int MinArgs { get; }
+ int MaxArgs { get; }
+ ActionStart Begin(IReadOnlyList<string> args);
+ StepStatus Poll();
+}
+
+public sealed record ActionStart(InputPlan? Plan = null, StepStatus? Status = null);
+
+public interface IRunRng
+{
+ void Seed(long seed);
+ void ForceNext(string kind, long value);
+}
diff --git a/src/Godosa.Core/Runs/Scripts/RunOutput.cs b/src/Godosa.Core/Runs/Scripts/RunOutput.cs
new file mode 100644
index 0000000..4c75a6a
--- /dev/null
+++ b/src/Godosa.Core/Runs/Scripts/RunOutput.cs
@@ -0,0 +1,21 @@
+// Provenance: own design — shared run scripts (spec §7.1)
+namespace Godosa.Core.Runs.Scripts;
+
+/// <summary>Auto-output filter: a line of <c>level</c> prints when <c>level ≤ max(current, floor)</c>; <see cref="Always"/>
+/// = log lines and failures.</summary>
+public sealed class RunOutput(Action<string> write, OutputLevel floor)
+{
+ public void Emit(OutputLevel level, OutputLevel current, string line)
+ {
+ if (level <= (current > floor ? current : floor))
+ write(line);
+ }
+
+ public void Always(string line) => write(line);
+
+ public static OutputLevel Parse(string name) => name switch
+ {
+ "quiet" => OutputLevel.Quiet, "checkpoints" => OutputLevel.Checkpoints, "labels" => OutputLevel.Labels,
+ "steps" => OutputLevel.Steps, "trace" => OutputLevel.Trace, _ => throw new ScriptFault($"bad output level '{name}'"),
+ };
+}
diff --git a/src/Godosa.Core/Runs/Scripts/RunReport.cs b/src/Godosa.Core/Runs/Scripts/RunReport.cs
new file mode 100644
index 0000000..f0ecf0d
--- /dev/null
+++ b/src/Godosa.Core/Runs/Scripts/RunReport.cs
@@ -0,0 +1,58 @@
+// Provenance: own design — shared run scripts (spec §7.2, §7.3)
+using System.Text;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace Godosa.Core.Runs.Scripts;
+
+public sealed record CheckpointRecord(string Name, long Frame, string Digest, string Source);
+
+public sealed record FailureInfo(string At, IReadOnlyList<string> Chain, string Step, string? Label, string Reason,
+ IReadOnlyList<string> Recent, string Dump, IReadOnlyDictionary<string, string> Vars, string? Snapshot);
+
+public sealed record RunReport(string Script, RunMode Mode, ulong Seed, bool Deterministic, RunOutcome Outcome, long Frames,
+ int Steps, double WallSeconds, int CacheHits, int CacheMisses, IReadOnlyList<CheckpointRecord> Checkpoints,
+ IReadOnlyList<string> Warnings, FailureInfo? Failure);
+
+public static class ReportWriter
+{
+ private static readonly JsonSerializerOptions Json = new() { WriteIndented = true, Converters = { new JsonStringEnumConverter() } };
+
+ public static string Text(RunReport r)
+ {
+ var sb = new StringBuilder();
+ sb.AppendLine($"script: {r.Script} mode: {r.Mode.ToString().ToLowerInvariant()} seed: {r.Seed} deterministic: {(r.Deterministic ? "yes" : "no")}");
+ sb.AppendLine($"outcome: {r.Outcome} frames: {r.Frames} steps: {r.Steps} wall: {r.WallSeconds:0.0} s cache: {r.CacheHits} hit / {r.CacheMisses} miss");
+ foreach (var c in r.Checkpoints)
+ sb.AppendLine($"checkpoint {c.Name} f={c.Frame} {c.Digest} ({c.Source})");
+ foreach (var w in r.Warnings)
+ sb.AppendLine($"warning: {w}");
+ if (r.Failure is { } f)
+ {
+ sb.AppendLine();
+ sb.AppendLine($"FAILED at {string.Join(" → ", f.Chain.Append(f.At))}{(f.Label == null ? "" : $" [{f.Label}]")}");
+ sb.AppendLine($" step: {f.Step}");
+ sb.AppendLine($" reason: {f.Reason}");
+ sb.AppendLine("recent steps:");
+ foreach (var s in f.Recent)
+ sb.AppendLine($" {s}");
+ sb.AppendLine("variables:");
+ foreach (var (k, v) in f.Vars.OrderBy(kv => kv.Key, StringComparer.Ordinal))
+ sb.AppendLine($" {k} = {v}");
+ sb.AppendLine("game state:");
+ sb.AppendLine(f.Dump);
+ if (f.Snapshot != null)
+ sb.AppendLine($"failure save: {f.Snapshot}");
+ }
+ return sb.ToString();
+ }
+
+ public static void Write(string dir, RunReport report, byte[]? failSnapshot)
+ {
+ Directory.CreateDirectory(dir);
+ File.WriteAllText(Path.Combine(dir, "report.txt"), Text(report));
+ File.WriteAllText(Path.Combine(dir, "report.json"), JsonSerializer.Serialize(report, Json));
+ if (failSnapshot != null)
+ File.WriteAllBytes(Path.Combine(dir, "fail.snap"), failSnapshot);
+ }
+}
diff --git a/src/Godosa.Core/Runs/Scripts/ScriptAst.cs b/src/Godosa.Core/Runs/Scripts/ScriptAst.cs
new file mode 100644
index 0000000..9fa2654
--- /dev/null
+++ b/src/Godosa.Core/Runs/Scripts/ScriptAst.cs
@@ -0,0 +1,25 @@
+// Provenance: own design — shared run scripts
+namespace Godosa.Core.Runs.Scripts;
+
+public enum BlockKind { Setup, Step, Try }
+
+/// <summary>One parsed line (or block header) of a <c>.steps</c> file; <see cref="Text"/> = the trimmed source line.</summary>
+public abstract record Node(SourceRef At, string Text);
+public sealed record VerbNode(SourceRef At, string Text, string Verb, string Args) : Node(At, Text);
+public sealed record WaitNode(SourceRef At, string Text, string Condition, int Timeout) : Node(At, Text);
+public sealed record ExpectNode(SourceRef At, string Text, string Condition) : Node(At, Text);
+public sealed record SetNode(SourceRef At, string Text, string Name, string Value) : Node(At, Text);
+public sealed record IncludeNode(SourceRef At, string Text, string Path) : Node(At, Text);
+public sealed record CallNode(SourceRef At, string Text, string Name, IReadOnlyList<string> Args) : Node(At, Text);
+public sealed record CheckpointNode(SourceRef At, string Text, string Name) : Node(At, Text);
+public sealed record BlockNode(SourceRef At, string Text, BlockKind Kind, string? Label, IReadOnlyList<Node> Body) : Node(At, Text);
+public sealed record DefNode(SourceRef At, string Text, string Name, IReadOnlyList<string> Params, IReadOnlyList<Node> Body) : Node(At, Text);
+
+public sealed record ScriptFile(string Path, int Format, RunMode? Mode, ulong? Seed, IReadOnlyList<string> VarFiles,
+ IReadOnlyList<Node> Body, IReadOnlyList<ScriptError> Errors);
+
+public static class ScriptFormat
+{
+ /// <summary>Newest format version this runner knows; every older one stays supported.</summary>
+ public const int Latest = 1;
+}
diff --git a/src/Godosa.Core/Runs/Scripts/ScriptLoader.cs b/src/Godosa.Core/Runs/Scripts/ScriptLoader.cs
new file mode 100644
index 0000000..d123539
--- /dev/null
+++ b/src/Godosa.Core/Runs/Scripts/ScriptLoader.cs
@@ -0,0 +1,218 @@
+// Provenance: own design — shared run scripts (spec §3.4, §3.7 structural checks)
+namespace Godosa.Core.Runs.Scripts;
+
+public static class ScriptLoader
+{
+ private sealed class State(IScriptSource source)
+ {
+ public IScriptSource Source { get; } = source;
+ public List<Instr> Code { get; } = [];
+ public List<ScriptError> Errors { get; } = [];
+ public Dictionary<string, (DefNode Def, string File, int Format)> Defs { get; } = new(StringComparer.Ordinal);
+ public List<string> Includes { get; } = [];
+ public List<string> Calls { get; } = [];
+ public List<SourceRef> Chain { get; } = [];
+ public HashSet<string> Checkpoints { get; } = new(StringComparer.Ordinal);
+ public List<HashSet<string>> Known { get; } = [];
+ public Dictionary<string, string> Vars { get; } = new(StringComparer.Ordinal);
+ public HashSet<string> Locked { get; } = new(StringComparer.Ordinal);
+ public int TryDepth { get; set; }
+ }
+
+ public static ScriptProgram Load(string entry, IScriptSource source, IReadOnlyDictionary<string, string> cmdVars)
+ {
+ var s = new State(source);
+ foreach (var (k, v) in cmdVars)
+ {
+ s.Vars[k] = v;
+ s.Locked.Add(k);
+ }
+ s.Known.Add([.. cmdVars.Keys]);
+ var text = source.Read(entry);
+ if (text == null)
+ {
+ s.Errors.Add(new ScriptError(new SourceRef(entry, 1), $"script not found: {entry}"));
+ return new ScriptProgram(entry, RunMode.Debug, null, s.Vars, s.Locked, s.Code, s.Errors);
+ }
+ var file = ScriptParser.Parse(entry, text);
+ s.Errors.AddRange(file.Errors);
+ if (file.Mode == null)
+ s.Errors.Add(new ScriptError(new SourceRef(entry, 1), "top-level file needs 'mode'"));
+ LoadVarFiles(s, file);
+ s.Includes.Add(entry);
+ for (var i = 0; i < file.Body.Count; i++)
+ {
+ if (file.Body[i] is BlockNode { Kind: BlockKind.Setup } setup && i > 0)
+ s.Errors.Add(new ScriptError(setup.At, "setup must be the first block"));
+ }
+ Emit(s, file.Body, entry, file.Format, top: true);
+ return new ScriptProgram(entry, file.Mode ?? RunMode.Debug, file.Seed, s.Vars, s.Locked, s.Code, s.Errors);
+ }
+
+ private static void LoadVarFiles(State s, ScriptFile file)
+ {
+ foreach (var raw in file.VarFiles)
+ {
+ string path;
+ try
+ {
+ path = ScriptPaths.Resolve(file.Path, VarExpander.Expand(raw, n => s.Vars.GetValueOrDefault(n), null));
+ }
+ catch (ScriptFault e)
+ {
+ s.Errors.Add(new ScriptError(new SourceRef(file.Path, 1), e.Message));
+ continue;
+ }
+ if (s.Source.Read(path) is not { } text)
+ {
+ s.Errors.Add(new ScriptError(new SourceRef(file.Path, 1), $"vars file not found: {path}"));
+ continue;
+ }
+ var (values, errors) = VarsFile.Parse(path, text);
+ s.Errors.AddRange(errors);
+ foreach (var (k, v) in values)
+ {
+ if (!s.Locked.Contains(k))
+ s.Vars[k] = v;
+ s.Known[0].Add(k);
+ }
+ }
+ }
+
+ private static void Emit(State s, IReadOnlyList<Node> nodes, string file, int format, bool top)
+ {
+ foreach (var node in nodes)
+ {
+ Instr I(Op op) => new(op, node.At, node.Text, [.. s.Chain], format);
+ switch (node)
+ {
+ case VerbNode v:
+ CheckVars(s, v.At, v.Args);
+ s.Code.Add(I(Op.Verb) with { A = v.Verb, B = v.Args });
+ break;
+ case WaitNode w:
+ CheckVars(s, w.At, w.Condition);
+ s.Code.Add(I(Op.Wait) with { A = w.Condition, Timeout = w.Timeout });
+ break;
+ case ExpectNode e:
+ CheckVars(s, e.At, e.Condition);
+ s.Code.Add(I(Op.Expect) with { A = e.Condition });
+ break;
+ case SetNode set:
+ CheckVars(s, set.At, set.Value);
+ if (s.Locked.Contains(set.Name))
+ s.Errors.Add(new ScriptError(set.At, $"variable {set.Name} is set on the command line"));
+ s.Known[^1].Add(set.Name);
+ if (s.Calls.Count == 0)
+ s.Known[0].Add(set.Name);
+ s.Code.Add(I(Op.Set) with { A = set.Name, B = set.Value });
+ break;
+ case CheckpointNode cp:
+ if (s.TryDepth > 0)
+ s.Errors.Add(new ScriptError(cp.At, "checkpoint inside try"));
+ if (!s.Checkpoints.Add(cp.Name))
+ s.Errors.Add(new ScriptError(cp.At, $"duplicate checkpoint {cp.Name}"));
+ s.Code.Add(I(Op.Checkpoint) with { A = cp.Name });
+ break;
+ case BlockNode { Kind: BlockKind.Setup } b:
+ if (!top)
+ s.Errors.Add(new ScriptError(b.At, "setup only in the top-level file"));
+ s.Code.Add(I(Op.SetupBegin));
+ Emit(s, b.Body, file, format, false);
+ s.Code.Add(I(Op.SetupEnd));
+ break;
+ case BlockNode { Kind: BlockKind.Step } b:
+ s.Code.Add(I(Op.LabelBegin) with { A = b.Label! });
+ Emit(s, b.Body, file, format, false);
+ s.Code.Add(I(Op.LabelEnd) with { A = b.Label! });
+ break;
+ case BlockNode { Kind: BlockKind.Try } b:
+ var begin = s.Code.Count;
+ s.Code.Add(I(Op.TryBegin));
+ s.TryDepth++;
+ Emit(s, b.Body, file, format, false);
+ s.TryDepth--;
+ s.Code[begin] = s.Code[begin] with { Jump = s.Code.Count };
+ s.Code.Add(I(Op.TryEnd));
+ break;
+ case DefNode d:
+ if (!s.Defs.TryAdd(d.Name, (d, file, format)))
+ s.Errors.Add(new ScriptError(d.At, $"def {d.Name} already defined"));
+ break;
+ case CallNode c:
+ Call(s, c, format);
+ break;
+ case IncludeNode inc:
+ Include(s, inc);
+ break;
+ }
+ }
+ }
+
+ private static void Call(State s, CallNode c, int format)
+ {
+ foreach (var arg in c.Args)
+ CheckVars(s, c.At, arg);
+ if (!s.Defs.TryGetValue(c.Name, out var def))
+ {
+ s.Errors.Add(new ScriptError(c.At, $"unknown def {c.Name}"));
+ return;
+ }
+ if (def.Def.Params.Count != c.Args.Count)
+ {
+ s.Errors.Add(new ScriptError(c.At, $"def {c.Name} takes {def.Def.Params.Count} arguments, got {c.Args.Count}"));
+ return;
+ }
+ if (s.Calls.Contains(c.Name))
+ {
+ s.Errors.Add(new ScriptError(c.At, $"recursive call {c.Name}"));
+ return;
+ }
+ var bindings = def.Def.Params.Zip(c.Args, (p, a) => new KeyValuePair<string, string>(p, a)).ToList();
+ s.Code.Add(new Instr(Op.ScopeBegin, c.At, c.Text, [.. s.Chain], format) { Bindings = bindings, LocalVars = true });
+ s.Calls.Add(c.Name);
+ s.Chain.Add(c.At);
+ s.Known.Add([.. def.Def.Params]);
+ Emit(s, def.Def.Body, def.File, def.Format, false);
+ s.Known.RemoveAt(s.Known.Count - 1);
+ s.Chain.RemoveAt(s.Chain.Count - 1);
+ s.Calls.RemoveAt(s.Calls.Count - 1);
+ s.Code.Add(new Instr(Op.ScopeEnd, c.At, c.Text, [.. s.Chain], format));
+ }
+
+ private static void Include(State s, IncludeNode inc)
+ {
+ var path = ScriptPaths.Resolve(inc.At.File, inc.Path);
+ if (s.Includes.Contains(path))
+ {
+ s.Errors.Add(new ScriptError(inc.At, $"include cycle: {string.Join(" → ", s.Includes)} → {path}"));
+ return;
+ }
+ if (s.Source.Read(path) is not { } text)
+ {
+ s.Errors.Add(new ScriptError(inc.At, $"include not found: {path}"));
+ return;
+ }
+ var file = ScriptParser.Parse(path, text);
+ s.Errors.AddRange(file.Errors);
+ if (file.Mode != null)
+ s.Errors.Add(new ScriptError(new SourceRef(path, 2), "mode only in the top-level file"));
+ LoadVarFiles(s, file);
+ s.Code.Add(new Instr(Op.ScopeBegin, inc.At, inc.Text, [.. s.Chain], file.Format));
+ s.Includes.Add(path);
+ s.Chain.Add(inc.At);
+ Emit(s, file.Body, path, file.Format, false);
+ s.Chain.RemoveAt(s.Chain.Count - 1);
+ s.Includes.RemoveAt(s.Includes.Count - 1);
+ s.Code.Add(new Instr(Op.ScopeEnd, inc.At, inc.Text, [.. s.Chain], file.Format));
+ }
+
+ private static void CheckVars(State s, SourceRef at, string text)
+ {
+ foreach (var name in VarExpander.Names(text))
+ {
+ if (!s.Known.Any(k => k.Contains(name)))
+ s.Errors.Add(new ScriptError(at, $"unresolved variable ${{{name}}}"));
+ }
+ }
+}
diff --git a/src/Godosa.Core/Runs/Scripts/ScriptParser.cs b/src/Godosa.Core/Runs/Scripts/ScriptParser.cs
new file mode 100644
index 0000000..f9f964f
--- /dev/null
+++ b/src/Godosa.Core/Runs/Scripts/ScriptParser.cs
@@ -0,0 +1,231 @@
+// Provenance: own design — shared run scripts (spec §3)
+using System.Globalization;
+using System.Text.RegularExpressions;
+
+namespace Godosa.Core.Runs.Scripts;
+
+/// <summary>Format v1 parser: one file → <see cref="ScriptFile"/>; every error collected (never throws).</summary>
+public static partial class ScriptParser
+{
+ private sealed record Line(int Number, int Indent, string Text);
+
+ [GeneratedRegex(@"^set\s+([A-Za-z_][\w.\-]*)\s*=\s*(.*)$")]
+ private static partial Regex SetLine();
+
+ [GeneratedRegex(@"^def\s+([A-Za-z_][\w\-]*)\s*\(([^)]*)\)\s*:$")]
+ private static partial Regex DefLine();
+
+ [GeneratedRegex(@"^([A-Za-z_][\w\-]*)\s*(?:\(([^)]*)\))?$")]
+ private static partial Regex CallTarget();
+
+ public static ScriptFile Parse(string path, string text)
+ {
+ var errors = new List<ScriptError>();
+ var lines = new List<Line>();
+ var raw = text.TrimStart('\uFEFF').Replace("\r\n", "\n").Split('\n');
+ for (var i = 0; i < raw.Length; i++)
+ {
+ var at = new SourceRef(path, i + 1);
+ var stripped = StepLexer.StripComment(raw[i]).TrimEnd();
+ if (stripped.Trim().Length == 0)
+ continue;
+ var indent = stripped.Length - stripped.TrimStart(' ', '\t').Length;
+ if (stripped[..indent].Contains('\t'))
+ {
+ errors.Add(new ScriptError(at, "tab in indentation"));
+ continue;
+ }
+ if (indent % 2 != 0)
+ {
+ errors.Add(new ScriptError(at, "indent not a multiple of 2"));
+ continue;
+ }
+ lines.Add(new Line(i + 1, indent / 2, stripped.Trim()));
+ }
+
+ var format = 0;
+ RunMode? mode = null;
+ ulong? seed = null;
+ var varFiles = new List<string>();
+ var pos = 0;
+ if (lines.Count == 0 || !lines[0].Text.StartsWith("format ", StringComparison.Ordinal))
+ errors.Add(new ScriptError(new SourceRef(path, lines.Count > 0 ? lines[0].Number : 1), "first line must be 'format N'"));
+ else
+ {
+ var value = lines[0].Text["format ".Length..].Trim();
+ if (!int.TryParse(value, CultureInfo.InvariantCulture, out format) || format < 1 || format > ScriptFormat.Latest)
+ {
+ errors.Add(new ScriptError(new SourceRef(path, lines[0].Number), $"unknown format {value} (latest {ScriptFormat.Latest})"));
+ format = 0;
+ }
+ pos = 1;
+ }
+ for (; pos < lines.Count && lines[pos].Indent == 0 && IsHeader(lines[pos].Text); pos++)
+ Header(lines[pos], path, errors, ref mode, ref seed, varFiles);
+ var body = Block(lines, ref pos, 0, path, errors);
+ return new ScriptFile(path, format, mode, seed, varFiles, body, errors);
+ }
+
+ private static bool IsHeader(string text) =>
+ text.StartsWith("mode ", StringComparison.Ordinal) || text.StartsWith("seed ", StringComparison.Ordinal)
+ || text.StartsWith("vars ", StringComparison.Ordinal) || text.StartsWith("format ", StringComparison.Ordinal);
+
+ private static void Header(Line line, string path, List<ScriptError> errors, ref RunMode? mode, ref ulong? seed, List<string> varFiles)
+ {
+ var at = new SourceRef(path, line.Number);
+ var value = line.Text[(line.Text.IndexOf(' ') + 1)..].Trim();
+ switch (line.Text[..line.Text.IndexOf(' ')])
+ {
+ case "mode":
+ mode = value switch { "player" => RunMode.Player, "debug" => RunMode.Debug, _ => null };
+ if (mode == null)
+ errors.Add(new ScriptError(at, "mode must be player or debug"));
+ break;
+ case "seed":
+ if (ulong.TryParse(value, CultureInfo.InvariantCulture, out var s))
+ seed = s;
+ else
+ errors.Add(new ScriptError(at, $"bad seed '{value}'"));
+ break;
+ case "vars":
+ varFiles.Add(value);
+ break;
+ default:
+ errors.Add(new ScriptError(at, "format must be the first line only"));
+ break;
+ }
+ }
+
+ private static List<Node> Block(List<Line> lines, ref int pos, int indent, string path, List<ScriptError> errors)
+ {
+ var nodes = new List<Node>();
+ while (pos < lines.Count)
+ {
+ var line = lines[pos];
+ if (line.Indent < indent)
+ break;
+ var at = new SourceRef(path, line.Number);
+ if (line.Indent > indent)
+ {
+ errors.Add(new ScriptError(at, "unexpected indent"));
+ pos++;
+ continue;
+ }
+ pos++;
+ if (indent == 0 && IsHeader(line.Text))
+ {
+ errors.Add(new ScriptError(at, "header line after steps"));
+ continue;
+ }
+ var hasBody = pos < lines.Count && lines[pos].Indent == indent + 1;
+ if (line.Text.EndsWith(':') && (hasBody || IsBlockKeyword(line.Text)))
+ {
+ var body = hasBody ? Block(lines, ref pos, indent + 1, path, errors) : [];
+ if (!hasBody)
+ errors.Add(new ScriptError(at, "empty block"));
+ if (BlockHeader(line.Text, at, body, errors) is { } node)
+ nodes.Add(node);
+ continue;
+ }
+ if (Statement(line.Text, at, errors) is { } statement)
+ nodes.Add(statement);
+ }
+ return nodes;
+ }
+
+ /// <summary>A line ending in ':' is a block header when it has an indented body or starts with a block keyword;
+ /// otherwise a statement (<c>log Done:</c>).</summary>
+ private static bool IsBlockKeyword(string text) =>
+ text is "setup:" or "try:" || text.StartsWith("step ", StringComparison.Ordinal) || text.StartsWith("def ", StringComparison.Ordinal);
+
+ private static Node? BlockHeader(string text, SourceRef at, List<Node> body, List<ScriptError> errors)
+ {
+ if (text == "setup:")
+ return new BlockNode(at, text, BlockKind.Setup, null, body);
+ if (text == "try:")
+ return new BlockNode(at, text, BlockKind.Try, null, body);
+ if (text.StartsWith("step ", StringComparison.Ordinal) && text.Length > "step :".Length)
+ return new BlockNode(at, text, BlockKind.Step, text["step ".Length..^1].Trim(), body);
+ if (DefLine().Match(text) is { Success: true } def)
+ {
+ var parameters = def.Groups[2].Value.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
+ return new DefNode(at, text, def.Groups[1].Value, parameters, body);
+ }
+ errors.Add(new ScriptError(at, $"unknown block '{text}'"));
+ return null;
+ }
+
+ private static Node? Statement(string text, SourceRef at, List<ScriptError> errors)
+ {
+ var space = text.IndexOf(' ');
+ var head = space < 0 ? text : text[..space];
+ var rest = space < 0 ? "" : text[(space + 1)..].Trim();
+ try
+ {
+ // Validate quoting once for every line (log "abc → unterminated quote).
+ StepLexer.Tokens(rest);
+ }
+ catch (FormatException e)
+ {
+ errors.Add(new ScriptError(at, e.Message));
+ return null;
+ }
+ switch (head)
+ {
+ case "set":
+ if (SetLine().Match(text) is not { Success: true } set)
+ {
+ errors.Add(new ScriptError(at, "bad set (set name = value)"));
+ return null;
+ }
+ var value = set.Groups[2].Value.Trim();
+ if (value.Length >= 2 && value[0] == '"' && value[^1] == '"')
+ value = StepLexer.Tokens(value)[0].Text;
+ return new SetNode(at, text, set.Groups[1].Value, value);
+ case "wait":
+ var last = rest.LastIndexOf(' ');
+ if (last < 0)
+ {
+ errors.Add(new ScriptError(at, "wait needs a condition and a timeout"));
+ return null;
+ }
+ var timeout = rest[(last + 1)..];
+ if (Durations.Frames(timeout) is not { } frames)
+ {
+ errors.Add(new ScriptError(at, $"bad timeout '{timeout}'"));
+ return null;
+ }
+ return new WaitNode(at, text, rest[..last].Trim(), frames);
+ case "expect":
+ if (rest.Length == 0)
+ {
+ errors.Add(new ScriptError(at, "expect needs a condition"));
+ return null;
+ }
+ return new ExpectNode(at, text, rest);
+ case "include":
+ return rest.Length == 0 || rest.Contains(' ')
+ ? Error(errors, at, "include takes one path")
+ : new IncludeNode(at, text, rest);
+ case "checkpoint":
+ return rest.Length == 0 || rest.Contains(' ')
+ ? Error(errors, at, "checkpoint takes one name")
+ : new CheckpointNode(at, text, rest);
+ case "call":
+ if (CallTarget().Match(rest) is not { Success: true } call)
+ return Error(errors, at, "bad call (call name(args))");
+ var args = call.Groups[2].Success
+ ? call.Groups[2].Value.Split(',', StringSplitOptions.TrimEntries).Where(a => a.Length > 0).ToArray()
+ : [];
+ return new CallNode(at, text, call.Groups[1].Value, args);
+ default:
+ return new VerbNode(at, text, head, rest);
+ }
+ }
+
+ private static Node? Error(List<ScriptError> errors, SourceRef at, string message)
+ {
+ errors.Add(new ScriptError(at, message));
+ return null;
+ }
+}
diff --git a/src/Godosa.Core/Runs/Scripts/ScriptProgram.cs b/src/Godosa.Core/Runs/Scripts/ScriptProgram.cs
new file mode 100644
index 0000000..502e24c
--- /dev/null
+++ b/src/Godosa.Core/Runs/Scripts/ScriptProgram.cs
@@ -0,0 +1,57 @@
+// Provenance: own design — shared run scripts
+namespace Godosa.Core.Runs.Scripts;
+
+public enum Op { Verb, Wait, Expect, Set, Checkpoint, LabelBegin, LabelEnd, ScopeBegin, ScopeEnd, TryBegin, TryEnd, SetupBegin, SetupEnd }
+
+/// <summary>One flattened instruction. <see cref="Chain"/> = include / call sites, outermost first.</summary>
+public sealed record Instr(Op Op, SourceRef At, string Text, IReadOnlyList<SourceRef> Chain, int Format)
+{
+ public string A { get; init; } = "";
+ public string B { get; init; } = "";
+ public int Timeout { get; init; }
+ public IReadOnlyList<KeyValuePair<string, string>> Bindings { get; init; } = [];
+ public bool LocalVars { get; init; }
+ public int Jump { get; init; } = -1;
+}
+
+public sealed record ScriptProgram(string Entry, RunMode Mode, ulong? Seed, IReadOnlyDictionary<string, string> Vars,
+ IReadOnlySet<string> Locked, IReadOnlyList<Instr> Code, IReadOnlyList<ScriptError> Errors);
+
+public interface IScriptSource
+{
+ string? Read(string path);
+}
+
+public sealed class MemoryScriptSource(IReadOnlyDictionary<string, string> files) : IScriptSource
+{
+ public string? Read(string path) => files.GetValueOrDefault(path);
+}
+
+/// <summary>Files under <paramref name="root"/>; paths are '/'-separated and relative to it.</summary>
+public sealed class FileScriptSource(string root) : IScriptSource
+{
+ public string? Read(string path)
+ {
+ var full = Path.GetFullPath(Path.Combine(root, path));
+ return File.Exists(full) ? File.ReadAllText(full) : null;
+ }
+}
+
+public static class ScriptPaths
+{
+ public static string Resolve(string fromFile, string relative)
+ {
+ var dir = fromFile.Contains('/') ? fromFile[..fromFile.LastIndexOf('/')] : "";
+ var parts = new List<string>(dir.Length == 0 ? [] : dir.Split('/'));
+ foreach (var part in relative.Replace('\\', '/').Split('/'))
+ {
+ if (part is "" or ".")
+ continue;
+ if (part == ".." && parts.Count > 0)
+ parts.RemoveAt(parts.Count - 1);
+ else if (part != "..")
+ parts.Add(part);
+ }
+ return string.Join('/', parts);
+ }
+}
diff --git a/src/Godosa.Core/Runs/Scripts/ScriptRunFlags.cs b/src/Godosa.Core/Runs/Scripts/ScriptRunFlags.cs
new file mode 100644
index 0000000..63a335a
--- /dev/null
+++ b/src/Godosa.Core/Runs/Scripts/ScriptRunFlags.cs
@@ -0,0 +1,100 @@
+// Provenance: own design — shared run scripts (CLI)
+using System.Globalization;
+
+namespace Godosa.Core.Runs.Scripts;
+
+/// <summary>Run-script CLI options (<c>--script</c>, <c>--var k=v</c>, <c>--output</c>, <c>--no-cache</c>, <c>--no-snapshots</c>, <c>--from</c>,
+/// <c>--until</c>, <c>--nondeterministic</c>, <c>--step-cap</c>, <c>--report-dir</c>); determinism and seed come from
+/// <see cref="RunFlags"/>.</summary>
+public sealed class ScriptRunFlags
+{
+ public string? Script { get; set; }
+ public Dictionary<string, string> Vars { get; } = new(StringComparer.Ordinal);
+ public OutputLevel? Output { get; set; }
+ public bool NoCache { get; set; }
+ public bool NoSnapshots { get; set; }
+ public string? From { get; set; }
+ public string? Until { get; set; }
+ public bool NonDeterministic { get; set; }
+ public int? StepCap { get; set; }
+ public string? ReportDir { get; set; }
+
+ public static bool TryTake(string[] args, ref int i, ScriptRunFlags flags)
+ {
+ string Value(ref int j)
+ {
+ if (j + 1 >= args.Length)
+ throw new ArgumentException($"{args[j]} needs a value");
+ return args[++j];
+ }
+ switch (args[i])
+ {
+ case "--script":
+ flags.Script = Value(ref i);
+ return true;
+ case "--var":
+ {
+ var v = Value(ref i);
+ var eq = v.IndexOf('=');
+ if (eq <= 0)
+ throw new ArgumentException($"--var needs name=value, got '{v}'");
+ flags.Vars[v[..eq]] = v[(eq + 1)..];
+ return true;
+ }
+ case "--output":
+ {
+ var v = Value(ref i);
+ try
+ {
+ flags.Output = RunOutput.Parse(v);
+ }
+ catch (ScriptFault e)
+ {
+ throw new ArgumentException(e.Message);
+ }
+ return true;
+ }
+ case "--no-cache":
+ flags.NoCache = true;
+ return true;
+ case "--no-snapshots":
+ flags.NoSnapshots = true;
+ return true;
+ case "--from":
+ flags.From = Value(ref i);
+ return true;
+ case "--until":
+ flags.Until = Value(ref i);
+ return true;
+ case "--nondeterministic":
+ flags.NonDeterministic = true;
+ return true;
+ case "--step-cap":
+ {
+ var v = Value(ref i);
+ flags.StepCap = int.TryParse(v, CultureInfo.InvariantCulture, out var n) && n > 0 ? n : throw new ArgumentException($"bad --step-cap '{v}'");
+ return true;
+ }
+ case "--report-dir":
+ flags.ReportDir = Value(ref i);
+ return true;
+ default:
+ return false;
+ }
+ }
+
+ public ScriptRunOptions ToOptions(RunFlags run, string? cacheDir) => new()
+ {
+ Deterministic = !NonDeterministic,
+ Seed = run.Seed,
+ Output = Output ?? OutputLevel.Labels,
+ Floor = Output ?? OutputLevel.Quiet,
+ StepCap = StepCap ?? 36_000,
+ CacheDir = cacheDir,
+ UseCache = !NoCache,
+ Snapshots = !NoSnapshots,
+ From = From,
+ Until = Until,
+ ReportDir = ReportDir,
+ };
+}
diff --git a/src/Godosa.Core/Runs/Scripts/ScriptRunner.cs b/src/Godosa.Core/Runs/Scripts/ScriptRunner.cs
new file mode 100644
index 0000000..edc7b97
--- /dev/null
+++ b/src/Godosa.Core/Runs/Scripts/ScriptRunner.cs
@@ -0,0 +1,568 @@
+// Provenance: own design — shared run scripts (spec §5)
+using System.Globalization;
+
+namespace Godosa.Core.Runs.Scripts;
+
+public sealed record ScriptRunOptions
+{
+ public bool Deterministic { get; init; } = true;
+ /// <summary>Overrides the script's <c>seed</c> header; neither → 0 and the game's rng is left alone.</summary>
+ public ulong? Seed { get; init; }
+ public OutputLevel Output { get; init; } = OutputLevel.Labels;
+ public OutputLevel Floor { get; init; } = OutputLevel.Quiet;
+ public int StepCap { get; init; } = 36_000;
+ public string? CacheDir { get; init; }
+ public bool UseCache { get; init; } = true;
+ /// <summary>false: checkpoints are markers — no digest (a whole-state serialization, slow in a big game), nothing
+ /// cached (<c>--no-snapshots</c>).</summary>
+ public bool Snapshots { get; init; } = true;
+ public string? From { get; init; }
+ public string? Until { get; init; }
+ public string? ReportDir { get; init; }
+ public long CacheCapBytes { get; init; } = 2L << 30;
+}
+
+public enum RunOutcome { Passed, Failed, Invalid, Stopped }
+
+public sealed record RunResult(RunOutcome Outcome, int ExitCode, long Frames, string? Failure);
+
+public sealed class ScriptRunner : IConditionContext
+{
+ private sealed class Active(Instr instr, long startFrame)
+ {
+ public Instr Instr { get; } = instr;
+ public long StartFrame { get; } = startFrame;
+ public int Frames { get; set; }
+ public InputPlan? Plan { get; set; }
+ /// <summary>The <see cref="Frames"/> value a plan's frame 0 lands on (0 for the step's own plan).</summary>
+ public int PlanBase { get; set; }
+ public int Sent { get; set; }
+ public IRunAction? Action { get; init; }
+ public Cond? Wait { get; init; }
+ public int? WaitFrames { get; init; }
+ }
+
+ private sealed record Label(string Name, long Start, SourceRef At);
+
+ private readonly ScriptProgram _program;
+ private readonly IRunGame _game;
+ private readonly ScriptRunOptions _options;
+ private readonly RunOutput _out;
+ private readonly VarScopes _vars;
+ private readonly List<Label> _labels = [];
+ private Active? _active;
+ private sealed record TryFrame(int End, int ScopeDepth, int LabelCount);
+ private readonly List<TryFrame> _tries = [];
+ private int _pc;
+ private bool _started;
+
+ public ScriptRunner(ScriptProgram program, IRunGame game, ScriptRunOptions options, Action<string> output)
+ {
+ (_program, _game, _options) = (program, game, options);
+ _out = new RunOutput(output, options.Floor);
+ _vars = new VarScopes(options.Output);
+ foreach (var (k, v) in program.Vars)
+ _vars.Set(k, v);
+ }
+
+ public long FrameCount { get; private set; }
+
+ /// <summary>Steps completed so far (a window host draws before the next one resolves its positions).</summary>
+ public int StepsDone => _steps;
+
+ /// <summary>Effective seed: options, else the script's <c>seed</c> header, else 0.</summary>
+ public ulong Seed => _options.Seed ?? _program.Seed ?? 0;
+ public RunResult? Result { get; private set; }
+ public RunReport? Report { get; private set; }
+
+ private readonly Queue<string> _recent = new();
+ private readonly List<CheckpointRecord> _checkpoints = [];
+ private readonly List<string> _warnings = [];
+ private readonly System.Diagnostics.Stopwatch _wall = new();
+ private int _steps, _cacheHits, _cacheMisses;
+ private CheckpointCache? _cache;
+ private IReadOnlyDictionary<int, string> _keys = new Dictionary<int, string>();
+
+ public RunResult Run(long cap = HeadlessLoop.DefaultCap)
+ {
+ var (_, capped) = HeadlessLoop.Run(_ => Frame(), null, cap);
+ if (capped && Result == null)
+ Fail(_active?.Instr, $"frame cap {cap}");
+ return Result ?? new RunResult(RunOutcome.Passed, 0, FrameCount, null);
+ }
+
+ /// <summary>A deferred checkpoint restore (<see cref="IRunGame.RestoreDeferred"/>) is under way: the host advances no
+ /// game frame and calls <see cref="AfterFrame"/> not at all this frame; the next <see cref="BeforeFrame"/> goes on.</summary>
+ public bool Restoring { get; private set; }
+
+ public bool Frame()
+ {
+ BeforeFrame();
+ if (Result != null)
+ return false;
+ if (Restoring)
+ return true;
+ Guard(_active?.Instr, _game.Advance);
+ if (Result == null)
+ AfterFrame();
+ return Result == null;
+ }
+
+ public void BeforeFrame()
+ {
+ if (Result != null)
+ return;
+ if (!_started)
+ {
+ var ok = false;
+ Guard(null, () => ok = Start());
+ if (!ok || Result != null)
+ return;
+ if (Restoring = _cacheHits > 0 && _game.RestoreDeferred)
+ return;
+ }
+ Restoring = false;
+ Guard(_active?.Instr, () =>
+ {
+ while (_active == null && Result == null)
+ {
+ if (_pc >= _program.Code.Count)
+ {
+ Finish(new RunResult(RunOutcome.Passed, 0, FrameCount, null), null);
+ return;
+ }
+ Exec(_program.Code[_pc++]);
+ }
+ });
+ if (_active is { Plan: { } plan } a && Result == null)
+ {
+ Guard(a.Instr, () =>
+ {
+ foreach (var e in plan.Events.Where(e => e.Frame + a.PlanBase == a.Frames))
+ {
+ _game.Input.Send(e.Event);
+ a.Sent++;
+ _out.Emit(OutputLevel.Trace, _vars.Level, $" input f={FrameCount} {e.Event}");
+ }
+ });
+ }
+ }
+
+ /// <param name="poll">false: the frame counts but the step is not polled (a window host whose next draw shows what this
+ /// frame changed: a click resolved now would aim on the draw from before); the next frame polls it.</param>
+ public void AfterFrame(bool poll = true)
+ {
+ if (Result != null || _active is not { } a)
+ return;
+ FrameCount++;
+ a.Frames++;
+ if (!poll)
+ return;
+ Guard(a.Instr, () =>
+ {
+ var status = Poll(a);
+ if (status.Plan is { } more)
+ (a.Plan, a.PlanBase, a.Sent) = (more, a.Frames, 0);
+ if (status.State == StepState.Running && a.Frames >= _options.StepCap)
+ status = StepStatus.Fault($"step exceeded {_options.StepCap} frames");
+ if (status.State == StepState.Fault)
+ throw new ScriptFault(status.Message ?? "fault");
+ if (status.State == StepState.Done)
+ Complete(a.Instr, a.Frames);
+ });
+ }
+
+ private bool Start()
+ {
+ _started = true;
+ _wall.Start();
+ var errors = ScriptValidator.Validate(_program, _game, _options.Deterministic);
+ if (errors.Count > 0)
+ {
+ foreach (var e in errors)
+ _out.Always(e.ToString());
+ Finish(new RunResult(RunOutcome.Invalid, 2, 0, errors[0].ToString()), null);
+ return false;
+ }
+ var until = int.MaxValue;
+ if (_options.Until is { } stop)
+ {
+ until = _program.Code.Select((c, n) => (c, n)).FirstOrDefault(x => x.c.A == stop && x.c.Op is Op.Checkpoint or Op.LabelEnd, (null!, -1)).Item2;
+ if (until < 0)
+ return Invalid($"unknown label or checkpoint {stop}");
+ }
+ if ((_options.Seed ?? _program.Seed) is { } seed && _game.Rng is { } rng)
+ rng.Seed(unchecked((long)seed));
+ if (_options.Deterministic && _options.CacheDir != null && _game.SupportsSnapshot)
+ {
+ _cache = new CheckpointCache(_options.CacheDir, _options.CacheCapBytes);
+ _keys = CheckpointKeys.Compute(_program, _game.ContentVersion, Seed);
+ }
+ if (_options.From is { } from)
+ {
+ var index = _keys.Keys.Concat(_program.Code.Select((c, n) => (c, n)).Where(x => x.c.Op == Op.Checkpoint).Select(x => x.n))
+ .Distinct().FirstOrDefault(n => _program.Code[n].A == from, -1);
+ if (index < 0)
+ return Invalid($"unknown checkpoint {from}");
+ if (_cache?.Load(_keys[index]) is not { } entry)
+ return Invalid($"checkpoint {from} not cached");
+ Resume(index, entry);
+ }
+ else if (_cache != null && _options.UseCache)
+ {
+ foreach (var index in _keys.Keys.Where(k => k < until).OrderDescending())
+ {
+ if (_cache.Load(_keys[index]) is { } entry)
+ {
+ Resume(index, entry);
+ break;
+ }
+ }
+ }
+ return true;
+ }
+
+ private bool Invalid(string message)
+ {
+ _out.Always(message);
+ Finish(new RunResult(RunOutcome.Invalid, 2, 0, message), null);
+ return false;
+ }
+
+ private void Resume(int index, CachedCheckpoint entry)
+ {
+ _game.SnapshotRestore(entry.Game);
+ FrameCount = entry.State.Frame;
+ _vars.Restore(entry.State.Scopes);
+ _labels.Clear();
+ _labels.AddRange(entry.State.Labels.Select(l => new Label(l.Name, l.Start, new SourceRef(l.File, l.Line))));
+ _pc = index + 1;
+ var name = _program.Code[index].A;
+ _checkpoints.Add(new CheckpointRecord(name, FrameCount, entry.State.Digest.ToString("x16"), "resumed"));
+ _cacheHits++;
+ _out.Always($"resumed at {name} (cache) f={FrameCount}");
+ }
+
+ private StepStatus Poll(Active a)
+ {
+ if (a.Plan is { } plan && a.Sent < plan.Events.Count)
+ return StepStatus.Running;
+ if (a.Action != null)
+ return a.Action.Poll();
+ if (a.WaitFrames is { } n)
+ return a.Frames >= n ? StepStatus.Done : StepStatus.Running;
+ if (a.Wait is { } cond)
+ {
+ if (cond.Test(this))
+ return StepStatus.Done;
+ _out.Emit(OutputLevel.Trace, _vars.Level, $" wait f={FrameCount} {a.Instr.A}: {Cond.Describe(cond, this)}");
+ return a.Frames >= a.Instr.Timeout
+ ? StepStatus.Fault($"timeout after {a.Instr.Timeout} frames: {a.Instr.A} ({Cond.Describe(cond, this)})")
+ : StepStatus.Running;
+ }
+ return StepStatus.Done;
+ }
+
+ private void Exec(Instr i)
+ {
+ switch (i.Op)
+ {
+ case Op.Set:
+ _vars.Set(i.A, Expand(i.B));
+ break;
+ case Op.Expect:
+ {
+ var cond = ConditionParser.Parse(Expand(i.A));
+ if (!cond.Test(this))
+ throw new ScriptFault($"expect failed: {i.A} ({Cond.Describe(cond, this)})");
+ Complete(i, 0);
+ break;
+ }
+ case Op.Wait:
+ {
+ var cond = ConditionParser.Parse(Expand(i.A));
+ if (cond.Test(this))
+ Complete(i, 0);
+ else
+ _active = new Active(i, FrameCount) { Wait = cond };
+ break;
+ }
+ case Op.Verb:
+ Verb(i);
+ break;
+ case Op.ScopeBegin:
+ _vars.Push(i.LocalVars, _vars.Level, i.Bindings.Select(b => new KeyValuePair<string, string>(b.Key, Expand(b.Value))).ToList());
+ break;
+ case Op.ScopeEnd:
+ _vars.Pop();
+ break;
+ case Op.LabelBegin:
+ _labels.Add(new Label(i.A, FrameCount, i.At));
+ break;
+ case Op.LabelEnd:
+ {
+ var label = _labels[^1];
+ _labels.RemoveAt(_labels.Count - 1);
+ _out.Emit(OutputLevel.Labels, _vars.Level, $"[{label.At} {label.Name}] done f={FrameCount} (+{FrameCount - label.Start})");
+ if (_options.Deterministic)
+ _out.Emit(OutputLevel.Labels, _vars.Level, DigestLine.Format(FrameCount, FrameCount / 60.0, _game.Digest()));
+ if (_options.Until == label.Name)
+ Finish(new RunResult(RunOutcome.Stopped, 0, FrameCount, null), null);
+ break;
+ }
+ case Op.TryBegin:
+ _tries.Add(new TryFrame(i.Jump, _vars.Depth, _labels.Count));
+ break;
+ case Op.TryEnd:
+ if (_tries.Count > 0)
+ _tries.RemoveAt(_tries.Count - 1);
+ break;
+ case Op.Checkpoint:
+ {
+ if (!_options.Snapshots)
+ {
+ _checkpoints.Add(new CheckpointRecord(i.A, FrameCount, "", "marker"));
+ _out.Emit(OutputLevel.Checkpoints, _vars.Level, $"checkpoint {i.A} f={FrameCount}");
+ if (_options.Until == i.A)
+ Finish(new RunResult(RunOutcome.Stopped, 0, FrameCount, null), null);
+ break;
+ }
+ var digest = _game.Digest();
+ var source = "replayed";
+ if (_cache != null && _game.SnapshotRefusal() is { } refusal)
+ _warnings.Add($"checkpoint {i.A} not cached: {refusal}");
+ else if (_cache != null && _keys.TryGetValue(_pc - 1, out var key))
+ {
+ var old = _cache.Load(key);
+ if (old != null && old.State.Digest == digest)
+ source = "cached";
+ else
+ {
+ if (old != null)
+ _warnings.Add($"checkpoint {i.A} digest differs from cache ({old.State.Digest:x16} → {digest:x16}); cache replaced");
+ try
+ {
+ _cache.Save(key, new CachedCheckpoint(new RunnerState(FrameCount, digest, _vars.Snapshot(),
+ [.. _labels.Select(l => new LabelState(l.Name, l.Start, l.At.File, l.At.Line))]), _game.SnapshotSave()));
+ }
+ catch (Exception e) when (e is IOException or UnauthorizedAccessException)
+ {
+ _warnings.Add($"checkpoint cache not written: {e.Message}");
+ }
+ _cacheMisses++;
+ }
+ }
+ _checkpoints.Add(new CheckpointRecord(i.A, FrameCount, digest.ToString("x16"), source));
+ _out.Emit(OutputLevel.Checkpoints, _vars.Level, $"checkpoint {i.A} f={FrameCount}");
+ if (_options.Deterministic)
+ _out.Emit(OutputLevel.Checkpoints, _vars.Level, DigestLine.Format(FrameCount, FrameCount / 60.0, digest));
+ if (_options.Until == i.A)
+ Finish(new RunResult(RunOutcome.Stopped, 0, FrameCount, null), null);
+ break;
+ }
+ }
+ }
+
+ private void Verb(Instr i)
+ {
+ var tokens = ExpandTokens(i.B);
+ switch (i.A)
+ {
+ case "log":
+ _out.Always(tokens[0].Text);
+ return;
+ case "output":
+ _vars.Level = RunOutput.Parse(tokens[0].Text);
+ return;
+ case "rng":
+ Rng(tokens);
+ return;
+ case "wait-frames":
+ {
+ var n = Durations.Frames(tokens[0].Text) ?? throw new ScriptFault($"bad frame count '{tokens[0].Text}'");
+ if (n == 0)
+ Complete(i, 0);
+ else
+ _active = new Active(i, FrameCount) { WaitFrames = n };
+ return;
+ }
+ }
+ if (InputPlans.IsInputVerb(i.A))
+ {
+ _active = new Active(i, FrameCount) { Plan = InputPlans.For(i.A, tokens, t => Positions.Resolve(t, _game)) };
+ return;
+ }
+ var action = _game.Actions[i.A];
+ var start = action.Begin(tokens.Select(t => t.Text).ToList());
+ if (start.Status is { State: StepState.Fault } fault)
+ throw new ScriptFault(fault.Message ?? "fault");
+ if (start.Plan == null && start.Status is { State: StepState.Done })
+ {
+ Complete(i, 0);
+ return;
+ }
+ _active = new Active(i, FrameCount) { Plan = start.Plan, Action = action };
+ }
+
+ private void Rng(List<Token> t)
+ {
+ var rng = _game.Rng ?? throw new ScriptFault("game has no rng hook");
+ long N(string s) => long.TryParse(s, NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out var v) ? v : throw new ScriptFault($"bad number '{s}'");
+ switch (t[0].Text)
+ {
+ case "seed" when t.Count == 2:
+ rng.Seed(N(t[1].Text));
+ break;
+ case "next" when t.Count == 3:
+ rng.ForceNext(t[1].Text, N(t[2].Text));
+ break;
+ default:
+ throw new ScriptFault("rng seed <n> | rng next <kind> <value>");
+ }
+ }
+
+ private void Complete(Instr i, int frames)
+ {
+ _active = null;
+ var label = _labels.Count > 0 ? $" {_labels[^1].Name}" : "";
+ _out.Emit(OutputLevel.Steps, _vars.Level, $"[{i.At}{label}] {i.Text} done f={FrameCount} (+{frames})");
+ _steps++;
+ _recent.Enqueue($"{i.At} {i.Text} f={FrameCount}");
+ while (_recent.Count > 20)
+ _recent.Dequeue();
+ }
+
+ /// <summary>Quoted arguments expand inside their token (a value with quotes or spaces stays one argument); unquoted ones
+ /// expand, then split (a variable may hold several arguments).</summary>
+ private List<Token> ExpandTokens(string text) =>
+ [.. StepLexer.Tokens(text).SelectMany(t => t.Quoted ? [new Token(Expand(t.Text), true)] : StepLexer.Tokens(Expand(t.Text)))];
+
+ private string Expand(string text) => VarExpander.Expand(text, _vars.Get, p =>
+ {
+ var cond = ConditionParser.Parse(p);
+ return cond.Eval(this);
+ });
+
+ private void Guard(Instr? at, Action step)
+ {
+ try
+ {
+ step();
+ }
+ catch (ScriptFault e)
+ {
+ Fail(at ?? CurrentOrLast(), e.Message);
+ }
+ catch (FormatException e)
+ {
+ Fail(at ?? CurrentOrLast(), e.Message);
+ }
+ catch (Exception e) when (e is not OutOfMemoryException)
+ {
+ Fail(at ?? CurrentOrLast(), $"{e.GetType().Name}: {e.Message}");
+ }
+ }
+
+ private Instr? CurrentOrLast() => _pc > 0 && _pc <= _program.Code.Count ? _program.Code[_pc - 1] : null;
+
+ private void Fail(Instr? at, string reason)
+ {
+ ReleaseHeld();
+ _active = null;
+ if (_tries.Count > 0)
+ {
+ var t = _tries[^1];
+ _tries.RemoveAt(_tries.Count - 1);
+ _vars.PopTo(t.ScopeDepth);
+ _labels.RemoveRange(t.LabelCount, _labels.Count - t.LabelCount);
+ _out.Always($"try failed at {at?.At}: {reason}");
+ _pc = t.End + 1;
+ return;
+ }
+ var where = at == null ? "" : $" {at.At} {at.Text}";
+ byte[]? snap = null;
+ if (_game.SupportsSnapshot)
+ {
+ try
+ {
+ snap = _game.SnapshotSave();
+ }
+ catch (Exception e) when (e is not OutOfMemoryException)
+ {
+ _warnings.Add($"failure save not written: {e.Message}");
+ }
+ }
+ string dump;
+ try
+ {
+ dump = _game.Dump();
+ }
+ catch (Exception e) when (e is not OutOfMemoryException)
+ {
+ dump = $"(dump failed: {e.Message})";
+ }
+ var failure = new FailureInfo(at?.At.ToString() ?? "", at?.Chain.Select(c => c.ToString()).ToList() ?? [], at?.Text ?? "",
+ _labels.Count > 0 ? _labels[^1].Name : null, reason, [.. _recent], dump, _vars.Visible(),
+ snap != null && _options.ReportDir != null ? Path.Combine(_options.ReportDir, "fail.snap") : null);
+ _out.Always($"FAIL{where}: {reason}");
+ Finish(new RunResult(RunOutcome.Failed, 1, FrameCount, reason), failure, snap);
+ }
+
+ private void Finish(RunResult result, FailureInfo? failure, byte[]? failSnapshot = null)
+ {
+ Result = result;
+ _wall.Stop();
+ Report = new RunReport(_program.Entry, _program.Mode, Seed, _options.Deterministic, result.Outcome, FrameCount,
+ _steps, _wall.Elapsed.TotalSeconds, _cacheHits, _cacheMisses, [.. _checkpoints], [.. _warnings], failure);
+ if (_options.ReportDir is not { } dir)
+ return;
+ try
+ {
+ ReportWriter.Write(dir, Report, failSnapshot);
+ }
+ catch (Exception e) when (e is IOException or UnauthorizedAccessException)
+ {
+ _warnings.Add($"report not written: {e.Message}");
+ Report = Report with { Warnings = [.. _warnings] };
+ _out.Always($"warning: report not written: {e.Message}");
+ }
+ }
+
+ /// <summary>Review focus 1: a half-sent plan leaves no button or key held — pending ups are sent now.</summary>
+ private void ReleaseHeld()
+ {
+ if (_active is not { Plan: { } plan } a)
+ return;
+ var sent = plan.Events.Take(a.Sent).Select(e => e.Event).ToList();
+ foreach (var pending in plan.Events.Skip(a.Sent).Select(e => e.Event))
+ {
+ var held = pending.Kind switch
+ {
+ InputKind.Up => sent.Count(e => e.Kind == InputKind.Down && e.Button == pending.Button)
+ > sent.Count(e => e.Kind == InputKind.Up && e.Button == pending.Button),
+ InputKind.KeyUp => sent.Count(e => e.Kind == InputKind.KeyDown && e.Key == pending.Key)
+ > sent.Count(e => e.Kind == InputKind.KeyUp && e.Key == pending.Key),
+ _ => false,
+ };
+ if (held)
+ {
+ try
+ {
+ _game.Input.Send(pending);
+ }
+ catch (Exception e) when (e is not OutOfMemoryException)
+ {
+ // The game is failing anyway; the report carries the first error.
+ }
+ sent.Add(pending);
+ }
+ }
+ }
+
+ bool IConditionContext.HasProbe(string name) => _game.Probes.ContainsKey(name);
+
+ RunValue IConditionContext.Probe(string name, IReadOnlyList<string> args) =>
+ _game.Probes.TryGetValue(name, out var p) ? p.Eval(args) : throw new ScriptFault($"unknown probe {name}");
+
+ long? IConditionContext.Constant(string name) => _game.Constants.TryGetValue(name, out var v) ? v : null;
+}
diff --git a/src/Godosa.Core/Runs/Scripts/ScriptTypes.cs b/src/Godosa.Core/Runs/Scripts/ScriptTypes.cs
new file mode 100644
index 0000000..909c26f
--- /dev/null
+++ b/src/Godosa.Core/Runs/Scripts/ScriptTypes.cs
@@ -0,0 +1,46 @@
+// Provenance: own design — shared run scripts (design notes kept privately by the author)
+using System.Globalization;
+
+namespace Godosa.Core.Runs.Scripts;
+
+public enum RunMode { Player, Debug }
+
+/// <summary>Player = only injects user input (may read state to aim); Debug = calls the game directly.</summary>
+public enum VerbKind { Player, Debug }
+
+public enum OutputLevel { Quiet, Checkpoints, Labels, Steps, Trace }
+
+public enum StepState { Running, Done, Fault }
+
+public sealed record SourceRef(string File, int Line)
+{
+ public override string ToString() => $"{File}:{Line}";
+}
+
+public sealed record ScriptError(SourceRef At, string Message)
+{
+ public override string ToString() => $"{At}: {Message}";
+}
+
+/// <param name="Plan">Running with more input (multi-click verbs): the runner plays it from the next frame and polls
+/// again once it is sent.</param>
+public readonly record struct StepStatus(StepState State, string? Message = null, InputPlan? Plan = null)
+{
+ public static StepStatus Running => new(StepState.Running);
+ public static StepStatus Input(InputPlan plan) => new(StepState.Running, null, plan);
+ public static StepStatus Done => new(StepState.Done);
+ public static StepStatus Fault(string message) => new(StepState.Fault, message);
+}
+
+/// <summary>A step failure inside the runner (turned into a failed run, never escapes <c>ScriptRunner</c>).</summary>
+public sealed class ScriptFault(string message) : Exception(message);
+
+/// <summary>A probe / condition value: a number or a string.</summary>
+public readonly record struct RunValue(long? Number, string? Text)
+{
+ public static RunValue Of(long n) => new(n, null);
+ public static RunValue Of(string s) => new(null, s);
+ public static RunValue Of(bool b) => new(b ? 1 : 0, null);
+ public bool IsNumber => Number.HasValue;
+ public override string ToString() => Number?.ToString(CultureInfo.InvariantCulture) ?? Text ?? "";
+}
diff --git a/src/Godosa.Core/Runs/Scripts/ScriptValidator.cs b/src/Godosa.Core/Runs/Scripts/ScriptValidator.cs
new file mode 100644
index 0000000..e67c48a
--- /dev/null
+++ b/src/Godosa.Core/Runs/Scripts/ScriptValidator.cs
@@ -0,0 +1,109 @@
+// Provenance: own design — shared run scripts (spec §3.7)
+namespace Godosa.Core.Runs.Scripts;
+
+public static class ScriptValidator
+{
+ public static List<ScriptError> Validate(ScriptProgram p, IRunGame game, bool deterministic)
+ {
+ var errors = new List<ScriptError>(p.Errors);
+ foreach (var (name, action) in game.Actions)
+ {
+ if (LibVerbs.Find(name) is { } lib && lib.Kind != action.Kind)
+ errors.Add(new ScriptError(new SourceRef(p.Entry, 1), $"game verb {name} shadows a lib verb with another kind"));
+ else if (LibVerbs.Find(name) is { GameHook: false })
+ errors.Add(new ScriptError(new SourceRef(p.Entry, 1), $"game verb {name} shadows a lib verb"));
+ }
+ var inSetup = false;
+ foreach (var i in p.Code)
+ {
+ switch (i.Op)
+ {
+ case Op.SetupBegin:
+ inSetup = true;
+ break;
+ case Op.SetupEnd:
+ inSetup = false;
+ break;
+ case Op.TryBegin when deterministic:
+ errors.Add(new ScriptError(i.At, "try needs a non-deterministic run"));
+ break;
+ case Op.Verb:
+ Verb(errors, i, game, p.Mode, inSetup);
+ break;
+ case Op.Wait or Op.Expect:
+ Condition(errors, i, game);
+ break;
+ }
+ }
+ return errors;
+ }
+
+ private static void Verb(List<ScriptError> errors, Instr i, IRunGame game, RunMode mode, bool inSetup)
+ {
+ var lib = LibVerbs.Find(i.A);
+ var gameVerb = lib == null ? game.Actions.GetValueOrDefault(i.A) : null;
+ if (lib == null && gameVerb == null)
+ {
+ errors.Add(new ScriptError(i.At, $"unknown verb {i.A}"));
+ return;
+ }
+ if ((lib is { GameHook: true } && !game.Actions.ContainsKey(i.A)) || (lib?.Name == "rng" && game.Rng == null))
+ {
+ errors.Add(new ScriptError(i.At, $"verb {i.A} not supported by game {game.Name}"));
+ return;
+ }
+ var (kind, min, max) = lib != null ? (lib.Kind, lib.MinArgs, lib.MaxArgs) : (gameVerb!.Kind, gameVerb.MinArgs, gameVerb.MaxArgs);
+ List<Token> tokens;
+ try
+ {
+ tokens = StepLexer.Tokens(i.B);
+ }
+ catch (FormatException e)
+ {
+ errors.Add(new ScriptError(i.At, e.Message));
+ return;
+ }
+ if (tokens.Count < min || tokens.Count > max)
+ errors.Add(new ScriptError(i.At, $"verb {i.A} takes {min}..{max} arguments, got {tokens.Count}"));
+ if (kind == VerbKind.Debug && mode == RunMode.Player && !inSetup)
+ errors.Add(new ScriptError(i.At, $"debug verb {i.A} outside setup in player mode"));
+ if (i.A == "output" && tokens.Count == 1 && !LibVerbs.OutputLevels.Contains(tokens[0].Text))
+ errors.Add(new ScriptError(i.At, "output level must be quiet|checkpoints|labels|steps|trace"));
+ foreach (var t in tokens.Where(t => !t.Quoted && t.Text.StartsWith('@')))
+ {
+ var name = t.Text[1..(t.Text.IndexOf('(') is var p and > 0 ? p : t.Text.Length)];
+ if (!game.Locators.ContainsKey(name))
+ errors.Add(new ScriptError(i.At, $"unknown locator @{name}"));
+ }
+ }
+
+ private static void Condition(List<ScriptError> errors, Instr i, IRunGame game)
+ {
+ if (i.A.Contains("${", StringComparison.Ordinal))
+ return; // expanded at run time; checked then
+ Cond cond;
+ try
+ {
+ cond = ConditionParser.Parse(i.A);
+ }
+ catch (FormatException e)
+ {
+ errors.Add(new ScriptError(i.At, e.Message));
+ return;
+ }
+ foreach (var bare in cond.Bare())
+ {
+ if (!game.Probes.ContainsKey(bare) && !game.Constants.ContainsKey(bare))
+ errors.Add(new ScriptError(i.At, $"unknown probe or constant {bare}"));
+ }
+ foreach (var (name, args) in cond.Probes())
+ {
+ if (cond.Bare().Contains(name) && args.Count == 0)
+ continue;
+ if (!game.Probes.TryGetValue(name, out var probe))
+ errors.Add(new ScriptError(i.At, $"unknown probe {name}"));
+ else if (args.Count < probe.MinArgs || args.Count > probe.MaxArgs)
+ errors.Add(new ScriptError(i.At, $"probe {name} takes {probe.MinArgs}..{probe.MaxArgs} arguments, got {args.Count}"));
+ }
+ }
+}
diff --git a/src/Godosa.Core/Runs/Scripts/StepLexer.cs b/src/Godosa.Core/Runs/Scripts/StepLexer.cs
new file mode 100644
index 0000000..4c54544
--- /dev/null
+++ b/src/Godosa.Core/Runs/Scripts/StepLexer.cs
@@ -0,0 +1,104 @@
+// Provenance: own design — shared run scripts
+using System.Globalization;
+using System.Text;
+
+namespace Godosa.Core.Runs.Scripts;
+
+public sealed record Token(string Text, bool Quoted);
+
+/// <summary>Line lexing for <c>.steps</c>: <c>#</c> comments outside quotes; tokens split on spaces, <c>"…"</c> (escapes
+/// <c>\"</c>, <c>\\</c>) and <c>(…)</c> groups stay one token.</summary>
+public static class StepLexer
+{
+ public static string StripComment(string line)
+ {
+ var quoted = false;
+ for (var i = 0; i < line.Length; i++)
+ {
+ var c = line[i];
+ if (quoted && c == '\\' && i + 1 < line.Length)
+ {
+ i++;
+ continue;
+ }
+ if (c == '"')
+ quoted = !quoted;
+ else if (c == '#' && !quoted)
+ return line[..i];
+ }
+ return line;
+ }
+
+ public static List<Token> Tokens(string text)
+ {
+ var tokens = new List<Token>();
+ var i = 0;
+ while (i < text.Length)
+ {
+ if (text[i] == ' ')
+ {
+ i++;
+ continue;
+ }
+ if (text[i] == '"')
+ {
+ var sb = new StringBuilder();
+ i++;
+ while (true)
+ {
+ if (i >= text.Length)
+ throw new FormatException("unterminated quote");
+ var c = text[i];
+ if (c == '\\' && i + 1 < text.Length)
+ {
+ sb.Append(text[i + 1]);
+ i += 2;
+ continue;
+ }
+ if (c == '"')
+ {
+ i++;
+ break;
+ }
+ sb.Append(c);
+ i++;
+ }
+ tokens.Add(new Token(sb.ToString(), true));
+ continue;
+ }
+ var start = i;
+ var depth = 0;
+ while (i < text.Length && (depth > 0 || text[i] != ' '))
+ {
+ if (text[i] == '(')
+ depth++;
+ else if (text[i] == ')' && depth > 0)
+ depth--;
+ i++;
+ }
+ tokens.Add(new Token(text[start..i], false));
+ }
+ return tokens;
+ }
+}
+
+/// <summary>Timeouts and waits in frames (1 frame = 1/60 s): <c>120</c> = 120 frames, <c>2s</c> = 120, <c>0.5s</c> = 30;
+/// negative, non-whole frame counts and other units → null.</summary>
+public static class Durations
+{
+ public const int FramesPerSecond = 60;
+
+ public static int? Frames(string text)
+ {
+ if (text.EndsWith('s'))
+ {
+ var number = text[..^1];
+ if (number.Length == 0 || !number.All(c => char.IsAsciiDigit(c) || c == '.')
+ || !decimal.TryParse(number, NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture, out var seconds))
+ return null;
+ var frames = seconds * FramesPerSecond;
+ return frames == decimal.Truncate(frames) ? (int)frames : null;
+ }
+ return text.Length > 0 && text.All(char.IsAsciiDigit) && int.TryParse(text, CultureInfo.InvariantCulture, out var n) ? n : null;
+ }
+}
diff --git a/src/Godosa.Core/Runs/Scripts/Vars.cs b/src/Godosa.Core/Runs/Scripts/Vars.cs
new file mode 100644
index 0000000..a01da62
--- /dev/null
+++ b/src/Godosa.Core/Runs/Scripts/Vars.cs
@@ -0,0 +1,174 @@
+// Provenance: own design — shared run scripts (spec §3.3)
+using System.Text;
+
+namespace Godosa.Core.Runs.Scripts;
+
+public static class VarsFile
+{
+ public static (Dictionary<string, string> Values, List<ScriptError> Errors) Parse(string path, string text)
+ {
+ var values = new Dictionary<string, string>(StringComparer.Ordinal);
+ var errors = new List<ScriptError>();
+ var lines = text.TrimStart('\uFEFF').Replace("\r\n", "\n").Split('\n');
+ for (var i = 0; i < lines.Length; i++)
+ {
+ var line = StepLexer.StripComment(lines[i]).Trim();
+ if (line.Length == 0)
+ continue;
+ var eq = line.IndexOf('=');
+ var name = eq > 0 ? line[..eq].Trim() : "";
+ if (name.Length == 0 || name.Contains(' '))
+ {
+ errors.Add(new ScriptError(new SourceRef(path, i + 1), "bad vars line (name = value)"));
+ continue;
+ }
+ values[name] = line[(eq + 1)..].Trim();
+ }
+ return (values, errors);
+ }
+}
+
+/// <summary><c>${name}</c> expansion, innermost first (<c>${ui.rest.${hours}}</c>); <c>${probe:name(args)}</c> = a probe
+/// value; a <c>$</c> not followed by <c>{</c> is literal.</summary>
+public static class VarExpander
+{
+ public static string Expand(string text, Func<string, string?> lookup, Func<string, RunValue>? probe)
+ {
+ var sb = new StringBuilder();
+ var i = 0;
+ while (i < text.Length)
+ {
+ if (text[i] == '$' && i + 1 < text.Length && text[i + 1] == '{')
+ {
+ i += 2;
+ sb.Append(Reference(text, ref i, lookup, probe));
+ continue;
+ }
+ sb.Append(text[i++]);
+ }
+ return sb.ToString();
+ }
+
+ /// <summary>Resolves the reference opened before <paramref name="i"/> (nested references in its name first); the
+ /// value is used as is, never scanned again.</summary>
+ private static string Reference(string text, ref int i, Func<string, string?> lookup, Func<string, RunValue>? probe)
+ {
+ var name = new StringBuilder();
+ while (true)
+ {
+ if (i >= text.Length)
+ throw new ScriptFault($"unclosed ${{ in '{text}'");
+ if (text[i] == '}')
+ {
+ i++;
+ break;
+ }
+ if (text[i] == '$' && i + 1 < text.Length && text[i + 1] == '{')
+ {
+ i += 2;
+ name.Append(Reference(text, ref i, lookup, probe));
+ continue;
+ }
+ name.Append(text[i++]);
+ }
+ var n = name.ToString();
+ if (n.Length == 0)
+ throw new ScriptFault("empty ${}");
+ if (n.StartsWith("probe:", StringComparison.Ordinal))
+ return (probe ?? throw new ScriptFault($"no probes here: ${{{n}}}"))(n["probe:".Length..]).ToString();
+ return lookup(n) ?? throw new ScriptFault($"unresolved variable ${{{n}}}");
+ }
+
+ /// <summary>Plain names inside the innermost <c>${…}</c> (no nested <c>${</c>, no <c>probe:</c>).</summary>
+ public static IEnumerable<string> Names(string text)
+ {
+ for (var i = 0; i < text.Length - 1; i++)
+ {
+ if (text[i] != '$' || text[i + 1] != '{')
+ continue;
+ var close = text.IndexOf('}', i + 2);
+ if (close < 0)
+ yield break;
+ var inner = text[(i + 2)..close];
+ if (inner.Length > 0 && !inner.Contains("${", StringComparison.Ordinal) && !inner.StartsWith("probe:", StringComparison.Ordinal))
+ yield return inner;
+ }
+ }
+}
+
+public sealed record ScopeState(Dictionary<string, string> Vars, bool Local, OutputLevel Level);
+
+/// <summary>Variable scopes: root (globals), include scopes (vars go to root, output level scoped), def-call scopes
+/// (local vars). Lookup walks from the top down.</summary>
+public sealed class VarScopes
+{
+ private readonly List<ScopeState> _stack;
+
+ public VarScopes(OutputLevel level) => _stack = [new ScopeState(new Dictionary<string, string>(StringComparer.Ordinal), false, level)];
+
+ public int Depth => _stack.Count;
+
+ public OutputLevel Level
+ {
+ get => _stack[^1].Level;
+ set => _stack[^1] = _stack[^1] with { Level = value };
+ }
+
+ public void Push(bool local, OutputLevel level, IEnumerable<KeyValuePair<string, string>> bindings) =>
+ _stack.Add(new ScopeState(new Dictionary<string, string>(bindings, StringComparer.Ordinal), local, level));
+
+ public void Pop()
+ {
+ if (_stack.Count > 1)
+ _stack.RemoveAt(_stack.Count - 1);
+ }
+
+ public void PopTo(int depth)
+ {
+ while (_stack.Count > Math.Max(1, depth))
+ _stack.RemoveAt(_stack.Count - 1);
+ }
+
+ public string? Get(string name)
+ {
+ for (var i = _stack.Count - 1; i >= 0; i--)
+ {
+ if (_stack[i].Vars.TryGetValue(name, out var v))
+ return v;
+ }
+ return null;
+ }
+
+ public void Set(string name, string value)
+ {
+ for (var i = _stack.Count - 1; i >= 1; i--)
+ {
+ if (_stack[i].Local)
+ {
+ _stack[i].Vars[name] = value;
+ return;
+ }
+ }
+ _stack[0].Vars[name] = value;
+ }
+
+ public Dictionary<string, string> Visible()
+ {
+ var all = new Dictionary<string, string>(StringComparer.Ordinal);
+ foreach (var scope in _stack)
+ foreach (var (k, v) in scope.Vars)
+ all[k] = v;
+ return all;
+ }
+
+ public List<ScopeState> Snapshot() =>
+ [.. _stack.Select(s => s with { Vars = new Dictionary<string, string>(s.Vars, StringComparer.Ordinal) })];
+
+ public void Restore(IReadOnlyList<ScopeState> scopes)
+ {
+ _stack.Clear();
+ _stack.AddRange(scopes.Select(s => s with { Vars = new Dictionary<string, string>(s.Vars, StringComparer.Ordinal) }));
+ if (_stack.Count == 0)
+ _stack.Add(new ScopeState(new Dictionary<string, string>(StringComparer.Ordinal), false, OutputLevel.Labels));
+ }
+}
diff --git a/src/Godosa.Core/Runs/SeedStreams.cs b/src/Godosa.Core/Runs/SeedStreams.cs
new file mode 100644
index 0000000..385d732
--- /dev/null
+++ b/src/Godosa.Core/Runs/SeedStreams.cs
@@ -0,0 +1,14 @@
+namespace Godosa.Core.Runs;
+
+/// <summary>Independent per-purpose seeds from one run seed: SplitMix64 output number <c>stream + 1</c> of
+/// <paramref name="seed"/> (audio draws then never shift the game's stream).</summary>
+public static class SeedStreams
+{
+ public static ulong Of(ulong seed, int stream)
+ {
+ var z = unchecked(seed + (ulong)(stream + 1) * 0x9E3779B97F4A7C15UL);
+ z = unchecked((z ^ (z >> 30)) * 0xBF58476D1CE4E5B9UL);
+ z = unchecked((z ^ (z >> 27)) * 0x94D049BB133111EBUL);
+ return z ^ (z >> 31);
+ }
+}
diff --git a/src/Godosa.Core/Runs/StateDigest.cs b/src/Godosa.Core/Runs/StateDigest.cs
new file mode 100644
index 0000000..a7adaf2
--- /dev/null
+++ b/src/Godosa.Core/Runs/StateDigest.cs
@@ -0,0 +1,51 @@
+using System.Buffers.Binary;
+using System.Text;
+
+namespace Godosa.Core.Runs;
+
+/// <summary>FNV-1a 64 over state bytes (numbers little-endian, strings UTF-8) for comparing runs.</summary>
+public sealed class StateDigest
+{
+ private const ulong Offset = 14695981039346656037UL, Prime = 1099511628211UL;
+
+ public ulong Value { get; private set; } = Offset;
+ public string Hex => Value.ToString("x16");
+
+ public static ulong Of(ReadOnlySpan<byte> bytes)
+ {
+ var d = new StateDigest();
+ d.Add(bytes);
+ return d.Value;
+ }
+
+ public void Add(ReadOnlySpan<byte> bytes)
+ {
+ var h = Value;
+ foreach (var b in bytes)
+ h = (h ^ b) * Prime;
+ Value = h;
+ }
+
+ public void Add(byte b) => Value = (Value ^ b) * Prime;
+
+ public void Add(uint v)
+ {
+ Span<byte> b = stackalloc byte[4];
+ BinaryPrimitives.WriteUInt32LittleEndian(b, v);
+ Add(b);
+ }
+
+ public void Add(int v) => Add(unchecked((uint)v));
+
+ public void Add(ulong v)
+ {
+ Span<byte> b = stackalloc byte[8];
+ BinaryPrimitives.WriteUInt64LittleEndian(b, v);
+ Add(b);
+ }
+
+ public void Add(long v) => Add(unchecked((ulong)v));
+ public void Add(float v) => Add(BitConverter.SingleToUInt32Bits(v));
+ public void Add(double v) => Add(BitConverter.DoubleToUInt64Bits(v));
+ public void Add(string s) => Add(Encoding.UTF8.GetBytes(s));
+}
diff --git a/src/Godosa.Core/SettingsFile.cs b/src/Godosa.Core/SettingsFile.cs
new file mode 100644
index 0000000..6aca17e
--- /dev/null
+++ b/src/Godosa.Core/SettingsFile.cs
@@ -0,0 +1,49 @@
+namespace Godosa.Core;
+
+/// <summary>A settings file on disk. Failures never stop the game: any load failure
+/// (missing file excepted: plain defaults) → defaults + a stderr line; save failure →
+/// stderr. Saves write a temp file then rename, so a crash never leaves half a file.
+/// <paramref name="report"/> receives those lines (default: stderr).</summary>
+public sealed class SettingsFile<T>(string path, Func<string, T> parse, Func<T, string> serialize, Func<T> defaults,
+ Action<string>? report = null)
+{
+ private readonly Action<string> _report = report ?? Console.Error.WriteLine;
+
+ public string Path => path;
+
+ /// <summary>The override variable's value when set, else
+ /// <c>ApplicationData/&lt;appFolder&gt;/settings.json</c>.</summary>
+ public static string DefaultPath(string environmentOverride, string appFolder) =>
+ Environment.GetEnvironmentVariable(environmentOverride) is { Length: > 0 } overridden
+ ? overridden
+ : System.IO.Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), appFolder, "settings.json");
+
+ public T Load()
+ {
+ try
+ {
+ return File.Exists(path) ? parse(File.ReadAllText(path)) : defaults();
+ }
+ catch (Exception e) when (e is not OutOfMemoryException)
+ {
+ _report($"settings: load failed ({e.Message}); using defaults");
+ return defaults();
+ }
+ }
+
+ public void Save(T value)
+ {
+ string temp = path + ".tmp";
+ try
+ {
+ Directory.CreateDirectory(System.IO.Path.GetDirectoryName(System.IO.Path.GetFullPath(path))!); // a bare name: the working folder
+ File.WriteAllText(temp, serialize(value));
+ File.Move(temp, path, overwrite: true);
+ }
+ catch (Exception e) when (e is IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException)
+ {
+ _report($"settings: save failed ({e.Message})");
+ try { File.Delete(temp); } catch (Exception) { } // best effort: don't leave the half-step behind
+ }
+ }
+}
diff --git a/src/Godosa.Desktop/Buttons.cs b/src/Godosa.Desktop/Buttons.cs
new file mode 100644
index 0000000..34a89e0
--- /dev/null
+++ b/src/Godosa.Desktop/Buttons.cs
@@ -0,0 +1,15 @@
+using Avalonia.Controls;
+using Avalonia.Controls.Primitives;
+using Avalonia.Interactivity;
+
+namespace Godosa.Desktop;
+
+public static class Buttons
+{
+ /// <summary>Presses a button as a click would: toggles a ToggleButton, else raises Click.</summary>
+ public static void Press(Button button)
+ {
+ if (button is ToggleButton toggle) toggle.IsChecked = toggle.IsChecked != true;
+ else button.RaiseEvent(new RoutedEventArgs(Button.ClickEvent));
+ }
+}
diff --git a/src/Godosa.Desktop/CursorWarp.cs b/src/Godosa.Desktop/CursorWarp.cs
new file mode 100644
index 0000000..c44342d
--- /dev/null
+++ b/src/Godosa.Desktop/CursorWarp.cs
@@ -0,0 +1,66 @@
+using System;
+using System.Runtime.InteropServices;
+using Avalonia;
+using Avalonia.Controls;
+using Godosa.Core.Diagnostics;
+
+namespace Godosa.Desktop;
+
+/// <summary>
+/// Moves the OS pointer to a point in a visual (free look recentering, camera
+/// spec §3: Avalonia 12.1.2 has no pointer lock). X11/XWayland via libX11
+/// (XWayland turns a warp under a hidden cursor into a pointer lock), Windows
+/// via user32. Returns false when unsupported or on any failure.
+/// </summary>
+public static partial class CursorWarp
+{
+ private static IntPtr _display;
+
+ public static bool TryWarp(Visual visual, Point viewPoint)
+ {
+ try
+ {
+ var topLevel = TopLevel.GetTopLevel(visual);
+ var handle = topLevel?.TryGetPlatformHandle();
+ if (topLevel is null || handle is null) return false;
+
+ if (OperatingSystem.IsWindows())
+ {
+ var screen = visual.PointToScreen(viewPoint);
+ return SetCursorPos(screen.X, screen.Y);
+ }
+ if (OperatingSystem.IsLinux() && handle.HandleDescriptor == "XID")
+ {
+ if (visual.TranslatePoint(viewPoint, topLevel) is not { } p) return false;
+ if (_display == IntPtr.Zero) _display = XOpenDisplay(IntPtr.Zero);
+ if (_display == IntPtr.Zero) return false;
+ double scaling = topLevel.RenderScaling;
+ XWarpPointer(_display, 0, (nuint)handle.Handle, 0, 0, 0, 0,
+ (int)Math.Round(p.X * scaling), (int)Math.Round(p.Y * scaling));
+ XFlush(_display);
+ return true;
+ }
+ if (EngineDebug.Input) EngineDebug.Write("input", $"cursor warp unsupported (handle {handle.HandleDescriptor})");
+ return false;
+ }
+ catch (Exception ex) when (ex is DllNotFoundException or EntryPointNotFoundException)
+ {
+ if (EngineDebug.Input) EngineDebug.Write("input", $"cursor warp failed: {ex.Message}");
+ return false;
+ }
+ }
+
+ [LibraryImport("libX11.so.6")]
+ private static partial IntPtr XOpenDisplay(IntPtr displayName);
+
+ [LibraryImport("libX11.so.6")]
+ private static partial int XWarpPointer(IntPtr display, nuint srcWindow, nuint destWindow,
+ int srcX, int srcY, uint srcWidth, uint srcHeight, int destX, int destY);
+
+ [LibraryImport("libX11.so.6")]
+ private static partial int XFlush(IntPtr display);
+
+ [LibraryImport("user32.dll")]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ private static partial bool SetCursorPos(int x, int y);
+}
diff --git a/src/Godosa.Desktop/DisplayMode.cs b/src/Godosa.Desktop/DisplayMode.cs
new file mode 100644
index 0000000..527fa43
--- /dev/null
+++ b/src/Godosa.Desktop/DisplayMode.cs
@@ -0,0 +1,19 @@
+using Avalonia.Controls;
+using Avalonia.Media;
+
+namespace Godosa.Desktop;
+
+/// <summary>Window fullscreen and a UI layer's scale (the 3D view stays outside that layer).</summary>
+public static class DisplayMode
+{
+ public static void SetFullscreen(Window? window, bool fullscreen)
+ {
+ if (window is null) return;
+ bool full = window.WindowState == WindowState.FullScreen;
+ if (fullscreen && !full) window.WindowState = WindowState.FullScreen;
+ else if (!fullscreen && full) window.WindowState = WindowState.Normal;
+ }
+
+ public static void SetUiScale(LayoutTransformControl layer, double factor) =>
+ layer.LayoutTransform = factor == 1.0 ? null : new ScaleTransform(factor, factor);
+}
diff --git a/src/Godosa.Desktop/FrameTimeGraph.cs b/src/Godosa.Desktop/FrameTimeGraph.cs
new file mode 100644
index 0000000..7e59a17
--- /dev/null
+++ b/src/Godosa.Desktop/FrameTimeGraph.cs
@@ -0,0 +1,65 @@
+using System;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Media;
+using Godosa.Core.Diagnostics;
+
+namespace Godosa.Desktop;
+
+/// <summary>
+/// Frame-time bar graph (game-loop spec §5): one 1-px bar per frame in the
+/// stats window, newest at the right, 0–50 ms scale, dashed guides at 16.7 ms
+/// (60 fps) and 33.3 ms (30 fps). Bars are colored relative to the window
+/// median (green ≤ 1.5×, yellow ≤ 2×, red = hitch) so ordinary vsync jitter
+/// stays green whatever the refresh rate. Drawn by Avalonia, not GL, so it
+/// doesn't perturb the GL frames it measures. Presentation only.
+/// </summary>
+public sealed class FrameTimeGraph : Control
+{
+ private const float ScaleMs = 50f;
+ private const float Ms60 = 1000f / 60f;
+ private const float Ms30 = 1000f / 30f;
+
+ private static readonly IBrush Backdrop = new SolidColorBrush(Color.FromArgb(90, 255, 255, 255));
+ private static readonly IBrush Good = new SolidColorBrush(Color.FromRgb(80, 200, 120));
+ private static readonly IBrush Slow = new SolidColorBrush(Color.FromRgb(230, 200, 60));
+ private static readonly IBrush Bad = new SolidColorBrush(Color.FromRgb(230, 70, 60));
+ private static readonly IPen Guide = new Pen(new SolidColorBrush(Color.FromArgb(170, 255, 255, 255)), 1, DashStyle.Dash);
+
+ private readonly FrameTimeStats _stats;
+ private readonly float[] _buffer;
+
+ public FrameTimeGraph(FrameTimeStats stats)
+ {
+ _stats = stats;
+ _buffer = new float[stats.Capacity];
+ Width = stats.Capacity;
+ Height = 60;
+ }
+
+ public override void Render(DrawingContext context)
+ {
+ double w = Bounds.Width, h = Bounds.Height;
+ context.FillRectangle(Backdrop, new Rect(0, 0, w, h));
+
+ int n = _stats.CopyIntervalsMs(_buffer);
+ float median = (float)_stats.Summarize().P50Ms;
+ double x0 = w - n;
+ for (int i = 0; i < n; i++)
+ {
+ float ms = _buffer[i];
+ double barHeight = Math.Min(ms / ScaleMs, 1f) * h;
+ var brush = ms > 2f * median ? Bad : ms > 1.5f * median ? Slow : Good;
+ context.FillRectangle(brush, new Rect(x0 + i, h - barHeight, 1, barHeight));
+ }
+
+ DrawGuide(context, Ms60, w, h);
+ DrawGuide(context, Ms30, w, h);
+ }
+
+ private static void DrawGuide(DrawingContext context, float ms, double w, double h)
+ {
+ double y = h - ms / ScaleMs * h;
+ context.DrawLine(Guide, new Point(0, y), new Point(w, y));
+ }
+}
diff --git a/src/Godosa.Desktop/GlProgram.cs b/src/Godosa.Desktop/GlProgram.cs
new file mode 100644
index 0000000..25ebe5c
--- /dev/null
+++ b/src/Godosa.Desktop/GlProgram.cs
@@ -0,0 +1,35 @@
+using Silk.NET.OpenGL;
+
+namespace Godosa.Desktop;
+
+/// <summary>Shader program helpers: compile + link with the driver's info log in the
+/// exception, so a bad shader fails loudly at startup instead of drawing nothing.</summary>
+public static class GlProgram
+{
+ public static uint Link(GL gl, string vertexSource, string fragmentSource)
+ {
+ uint vs = Compile(gl, ShaderType.VertexShader, vertexSource);
+ uint fs = Compile(gl, ShaderType.FragmentShader, fragmentSource);
+ uint program = gl.CreateProgram();
+ gl.AttachShader(program, vs);
+ gl.AttachShader(program, fs);
+ gl.LinkProgram(program);
+ gl.GetProgram(program, ProgramPropertyARB.LinkStatus, out int linkStatus);
+ if (linkStatus == 0)
+ throw new InvalidOperationException($"Shader link failed: {gl.GetProgramInfoLog(program)}");
+ gl.DeleteShader(vs);
+ gl.DeleteShader(fs);
+ return program;
+ }
+
+ public static uint Compile(GL gl, ShaderType type, string source)
+ {
+ uint shader = gl.CreateShader(type);
+ gl.ShaderSource(shader, source);
+ gl.CompileShader(shader);
+ gl.GetShader(shader, ShaderParameterName.CompileStatus, out int status);
+ if (status == 0)
+ throw new InvalidOperationException($"Shader compile failed: {gl.GetShaderInfoLog(shader)}");
+ return shader;
+ }
+}
diff --git a/src/Godosa.Desktop/Godosa.Desktop.csproj b/src/Godosa.Desktop/Godosa.Desktop.csproj
new file mode 100644
index 0000000..0be77f4
--- /dev/null
+++ b/src/Godosa.Desktop/Godosa.Desktop.csproj
@@ -0,0 +1,12 @@
+<Project Sdk="Microsoft.NET.Sdk">
+ <PropertyGroup>
+ <AllowUnsafeBlocks>true</AllowUnsafeBlocks>
+ </PropertyGroup>
+ <ItemGroup>
+ <PackageReference Include="Avalonia" />
+ <PackageReference Include="Silk.NET.OpenGL" />
+ </ItemGroup>
+ <ItemGroup>
+ <ProjectReference Include="../Godosa.Core/Godosa.Core.csproj" />
+ </ItemGroup>
+</Project>
diff --git a/src/Godosa.Desktop/GpuTimer.cs b/src/Godosa.Desktop/GpuTimer.cs
new file mode 100644
index 0000000..11076e8
--- /dev/null
+++ b/src/Godosa.Desktop/GpuTimer.cs
@@ -0,0 +1,71 @@
+using System;
+using Silk.NET.OpenGL;
+
+namespace Godosa.Desktop;
+
+/// <summary>GPU time per frame from GL_TIME_ELAPSED queries (game-loop spec §6). A small
+/// ring so reading a result never stalls on the frame still in flight; disables itself
+/// when the context has no timer queries (e.g. GLES without the extension).</summary>
+public sealed class GpuTimer
+{
+ private const int Ring = 4;
+ private readonly uint[] _queries = new uint[Ring];
+ private readonly bool[] _pending = new bool[Ring];
+ private int _current;
+ private bool _open;
+
+ public bool Supported { get; private set; }
+
+ public void Init(GL gl)
+ {
+ try
+ {
+ string version = gl.GetStringS(StringName.Version) ?? "";
+ if (version.Contains("OpenGL ES", StringComparison.Ordinal)) return;
+ for (int i = 0; i < Ring; i++) _queries[i] = gl.GenQuery();
+ Supported = gl.GetError() == GLEnum.NoError;
+ }
+ catch (Exception)
+ {
+ Supported = false;
+ }
+ }
+
+ public void Begin(GL gl)
+ {
+ if (!Supported || _pending[_current]) return; // slot still in flight: skip this frame
+ gl.BeginQuery(QueryTarget.TimeElapsed, _queries[_current]);
+ _open = true;
+ }
+
+ public void End(GL gl)
+ {
+ if (!_open) return;
+ gl.EndQuery(QueryTarget.TimeElapsed);
+ _pending[_current] = true;
+ _open = false;
+ _current = (_current + 1) % Ring;
+ }
+
+ /// <summary>Seconds for each finished query since the last call.</summary>
+ public void Collect(GL gl, Action<double> onResult)
+ {
+ if (!Supported) return;
+ for (int i = 0; i < Ring; i++)
+ {
+ if (!_pending[i]) continue;
+ gl.GetQueryObject(_queries[i], QueryObjectParameterName.ResultAvailable, out int available);
+ if (available == 0) continue;
+ gl.GetQueryObject(_queries[i], QueryObjectParameterName.Result, out ulong nanoseconds);
+ _pending[i] = false;
+ onResult(nanoseconds / 1e9);
+ }
+ }
+
+ public void Delete(GL gl)
+ {
+ if (!Supported) return;
+ for (int i = 0; i < Ring; i++) gl.DeleteQuery(_queries[i]);
+ Supported = false;
+ }
+}
diff --git a/src/Godosa.Desktop/InputScriptPlayer.cs b/src/Godosa.Desktop/InputScriptPlayer.cs
new file mode 100644
index 0000000..2aad679
--- /dev/null
+++ b/src/Godosa.Desktop/InputScriptPlayer.cs
@@ -0,0 +1,124 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.IO;
+using System.Linq;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Input;
+using Avalonia.Interactivity;
+using Avalonia.Threading;
+using Avalonia.VisualTree;
+using Godosa.Core.Diagnostics;
+using Godosa.Core.Input;
+
+namespace Godosa.Desktop;
+
+/// <summary>Replays the game's input-script file (Core <see cref="InputScript"/>, path in the
+/// variable given to <see cref="Configure"/>) as real
+/// routed key events to the focused control and clicks on visible buttons, so smoke runs
+/// can drive the game without X key injection (which never reaches a focused window on
+/// KDE Wayland). Log category <c>script</c> (<see cref="EngineDebug"/>).</summary>
+public sealed class InputScriptPlayer
+{
+ private readonly Window _window;
+ private readonly InputScript _script;
+ private readonly Stopwatch _clock = Stopwatch.StartNew();
+ private readonly List<ScriptEvent> _due = new();
+ private readonly DispatcherTimer _timer = new() { Interval = TimeSpan.FromMilliseconds(10) };
+
+ private static string? _variable;
+ private readonly Func<Window, Interactive?>? _keyTarget;
+
+ /// <summary>True while a script drives the game: window focus no longer pauses or
+ /// releases input.</summary>
+ public static bool Active { get; private set; }
+
+ /// <summary>Scripted gamepad (<c>PadA … PadRT</c>, see <see cref="GamepadScript"/>).</summary>
+ public static GamepadState Pad { get; private set; }
+
+ /// <summary>Call first in <c>Main</c>: names the game's script variable.</summary>
+ public static void Configure(string environmentVariable)
+ {
+ _variable = environmentVariable;
+ Active = Environment.GetEnvironmentVariable(environmentVariable) is { Length: > 0 };
+ }
+
+ private InputScriptPlayer(Window window, InputScript script, Func<Window, Interactive?>? keyTarget)
+ {
+ _window = window;
+ _script = script;
+ _keyTarget = keyTarget;
+ _timer.Tick += (_, _) => Step();
+ _timer.Start();
+ }
+
+ /// <param name="keyTarget">Where keys go when nothing has focus (e.g. the game view); the window otherwise.</param>
+ public static InputScriptPlayer? Start(Window window, Func<Window, Interactive?>? keyTarget = null)
+ {
+ if (!Active) return null;
+ string path = Environment.GetEnvironmentVariable(_variable!)!;
+ var script = InputScript.Parse(File.ReadAllText(path));
+ if (EngineDebug.Script) EngineDebug.Write("script", $"{script.Events.Count} events from {path}");
+ return new InputScriptPlayer(window, script, keyTarget);
+ }
+
+ private void Step()
+ {
+ _script.Due(_clock.Elapsed.TotalSeconds, _due);
+ foreach (var e in _due)
+ {
+ bool ok = e.Action switch
+ {
+ ScriptAction.Click => Click(e.Arg),
+ ScriptAction.Type => Type(e.Arg),
+ _ => Key(e.Action == ScriptAction.Down, e.Arg),
+ };
+ if (EngineDebug.Script) EngineDebug.Write("script", $"t={_clock.Elapsed.TotalSeconds:F2} {e.Action} {e.Arg}{(ok ? "" : " (FAILED)")}");
+ }
+ _due.Clear();
+ if (_script.Finished) _timer.Stop();
+ }
+
+ private bool Key(bool down, string name)
+ {
+ if (name.StartsWith("Pad", StringComparison.OrdinalIgnoreCase))
+ {
+ if (GamepadScript.Apply(Pad, name, down) is not { } next) return false;
+ Pad = next;
+ return true;
+ }
+ if (!Enum.TryParse(name, ignoreCase: true, out PhysicalKey physical)) return false;
+ Enum.TryParse(name, ignoreCase: true, out Key key);
+ var target = _window.FocusManager?.GetFocusedElement() as Interactive
+ ?? _keyTarget?.Invoke(_window)
+ ?? _window;
+ target.RaiseEvent(new KeyEventArgs
+ {
+ RoutedEvent = down ? InputElement.KeyDownEvent : InputElement.KeyUpEvent,
+ PhysicalKey = physical,
+ Key = key,
+ Source = target,
+ });
+ return true;
+ }
+
+ private bool Type(string text)
+ {
+ if (_window.FocusManager?.GetFocusedElement() is not TextBox box) return false;
+ box.Text = text;
+ return true;
+ }
+
+ private bool Click(string label)
+ {
+ var buttons = _window.GetVisualDescendants().OfType<Button>()
+ .Where(b => b.IsEffectivelyVisible && b.IsEffectivelyEnabled).ToList();
+ string Text(Button b) => b.Content?.ToString()?.Trim() ?? "";
+ var button = buttons.FirstOrDefault(b => Text(b).Equals(label, StringComparison.OrdinalIgnoreCase)) // exact label first
+ ?? buttons.FirstOrDefault(b => b.Name == label) // steppers: fov+ / fov-
+ ?? buttons.FirstOrDefault(b => Text(b).Contains(label, StringComparison.OrdinalIgnoreCase));
+ if (button is not null) Buttons.Press(button);
+ return button is not null;
+ }
+}
diff --git a/src/Godosa.Desktop/PadNavigator.cs b/src/Godosa.Desktop/PadNavigator.cs
new file mode 100644
index 0000000..b4b59d5
--- /dev/null
+++ b/src/Godosa.Desktop/PadNavigator.cs
@@ -0,0 +1,85 @@
+using System.Collections.Generic;
+using System.Linq;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Interactivity;
+using Avalonia.VisualTree;
+using Avalonia.Media;
+using Godosa.Core.Input;
+
+namespace Godosa.Desktop;
+
+/// <summary>Gamepad control of an open window: D-pad moves a highlight through its enabled
+/// buttons (wrapping), A presses (the game decides B/Start).</summary>
+public sealed class PadNavigator
+{
+ private static readonly Thickness HighlightThickness = new(2);
+ private readonly MenuCursor _cursor = new();
+ private readonly List<Button> _buttons = new();
+ private Control? _container;
+ private Button? _highlighted;
+ private readonly IBrush _highlight;
+
+ public PadNavigator(IBrush highlight) => _highlight = highlight;
+
+ /// <summary>The window pad input should drive now, or null (pad drives the character).</summary>
+ public Control? Active { get; private set; }
+
+ /// <summary>Highlight only while the gamepad is the device in use (keyboard/mouse users
+ /// get no stray gold border).</summary>
+ public bool ShowHighlight { get; set; }
+
+ public void Refresh(Control? active)
+ {
+ Active = active;
+ if (!ReferenceEquals(active, _container))
+ {
+ _container = active;
+ _cursor.Clamp(0);
+ }
+ Collect();
+ _cursor.Clamp(_buttons.Count);
+ Highlight();
+ }
+
+ public void Move(int delta)
+ {
+ Collect();
+ _cursor.Clamp(_buttons.Count);
+ _cursor.Move(delta, _buttons.Count);
+ Highlight();
+ }
+
+ public void Press()
+ {
+ Collect();
+ _cursor.Clamp(_buttons.Count);
+ if (_cursor.Index >= 0) Buttons.Press(_buttons[_cursor.Index]);
+ }
+
+ private void Collect()
+ {
+ _buttons.Clear();
+ if (_container is null) return;
+ _buttons.AddRange(_container.GetVisualDescendants().OfType<Button>()
+ .Where(b => b.IsEffectivelyVisible && b.IsEffectivelyEnabled));
+ }
+
+ private void Highlight()
+ {
+ var target = ShowHighlight && _cursor.Index >= 0 && _cursor.Index < _buttons.Count ? _buttons[_cursor.Index] : null;
+ if (ReferenceEquals(target, _highlighted)) return;
+ if (_highlighted is not null)
+ {
+ _highlighted.ClearValue(Button.BorderBrushProperty);
+ _highlighted.ClearValue(Button.BorderThicknessProperty);
+ }
+ _highlighted = target;
+ if (target is not null)
+ {
+ target.BorderBrush = _highlight;
+ target.BorderThickness = HighlightThickness;
+ target.BringIntoView(); // long option pages scroll
+ }
+ }
+}
diff --git a/src/Godosa.Gamepad/Godosa.Gamepad.csproj b/src/Godosa.Gamepad/Godosa.Gamepad.csproj
new file mode 100644
index 0000000..3fb6b13
--- /dev/null
+++ b/src/Godosa.Gamepad/Godosa.Gamepad.csproj
@@ -0,0 +1,11 @@
+<Project Sdk="Microsoft.NET.Sdk">
+ <PropertyGroup>
+ <AllowUnsafeBlocks>true</AllowUnsafeBlocks>
+ </PropertyGroup>
+ <ItemGroup>
+ <PackageReference Include="Silk.NET.SDL" />
+ </ItemGroup>
+ <ItemGroup>
+ <ProjectReference Include="../Godosa.Core/Godosa.Core.csproj" />
+ </ItemGroup>
+</Project>
diff --git a/src/Godosa.Gamepad/SdlGamepadSource.cs b/src/Godosa.Gamepad/SdlGamepadSource.cs
new file mode 100644
index 0000000..ee63161
--- /dev/null
+++ b/src/Godosa.Gamepad/SdlGamepadSource.cs
@@ -0,0 +1,107 @@
+using System;
+using System.Numerics;
+using Godosa.Core.Diagnostics;
+using Godosa.Core.Input;
+using Silk.NET.SDL;
+
+namespace Godosa.Gamepad;
+
+/// <summary>
+/// Reads the first attached game controller through windowless SDL. Returns raw
+/// held state; each game maps it (and applies deadzones). Any SDL failure disables
+/// the pad for the session and the game stays keyboard-only.
+/// </summary>
+public sealed unsafe class SdlGamepadSource : IDisposable
+{
+ private Sdl? _sdl;
+ private GameController* _pad;
+ private bool _failed;
+
+ public GamepadState? Poll()
+ {
+ if (_failed) return null;
+ try
+ {
+ if (_sdl is null && !Init()) return null;
+ var sdl = _sdl!;
+ sdl.GameControllerUpdate(); // also detects hot-plug (~1.4 µs/call)
+
+ if (_pad != null && sdl.GameControllerGetAttached(_pad) != SdlBool.True)
+ {
+ sdl.GameControllerClose(_pad);
+ _pad = null;
+ if (EngineDebug.Input) EngineDebug.Write("input", "gamepad detached");
+ }
+ if (_pad == null && !Open(sdl)) return null;
+
+ var buttons = PadButtons.None;
+ foreach (var (sdlButton, pad) in ButtonMap)
+ if (sdl.GameControllerGetButton(_pad, sdlButton) != 0) buttons |= pad;
+ return new GamepadState(
+ new Vector2(Axis(sdl, GameControllerAxis.Leftx), Axis(sdl, GameControllerAxis.Lefty)),
+ new Vector2(Axis(sdl, GameControllerAxis.Rightx), Axis(sdl, GameControllerAxis.Righty)),
+ Math.Max(0f, Axis(sdl, GameControllerAxis.Triggerleft)),
+ Math.Max(0f, Axis(sdl, GameControllerAxis.Triggerright)),
+ buttons);
+ }
+ catch (Exception ex)
+ {
+ if (EngineDebug.Input) EngineDebug.Write("input", $"gamepad disabled: {ex.Message}");
+ _failed = true;
+ return null;
+ }
+ }
+
+ private static readonly (GameControllerButton Sdl, PadButtons Pad)[] ButtonMap =
+ {
+ (GameControllerButton.A, PadButtons.A), (GameControllerButton.B, PadButtons.B),
+ (GameControllerButton.X, PadButtons.X), (GameControllerButton.Y, PadButtons.Y),
+ (GameControllerButton.Back, PadButtons.Back), (GameControllerButton.Start, PadButtons.Start),
+ (GameControllerButton.Leftshoulder, PadButtons.LeftShoulder), (GameControllerButton.Rightshoulder, PadButtons.RightShoulder),
+ (GameControllerButton.DpadUp, PadButtons.DpadUp), (GameControllerButton.DpadDown, PadButtons.DpadDown),
+ (GameControllerButton.DpadLeft, PadButtons.DpadLeft), (GameControllerButton.DpadRight, PadButtons.DpadRight),
+ };
+
+ private bool Init()
+ {
+ var sdl = Sdl.GetApi();
+ sdl.SetHint(Sdl.HintJoystickAllowBackgroundEvents, "1"); // the Avalonia window has focus, not SDL
+ sdl.SetHint(Sdl.HintNoSignalHandlers, "1");
+ if (sdl.InitSubSystem(Sdl.InitGamecontroller) != 0)
+ {
+ if (EngineDebug.Input) EngineDebug.Write("input", $"gamepad disabled: {sdl.GetErrorS()}");
+ _failed = true;
+ return false;
+ }
+ sdl.JoystickEventState(0); // SDL_IGNORE: state is polled; nothing drains SDL's event queue
+ sdl.GameControllerEventState(0);
+ _sdl = sdl;
+ return true;
+ }
+
+ private bool Open(Sdl sdl)
+ {
+ int count = sdl.NumJoysticks();
+ for (int i = 0; i < count; i++)
+ {
+ if (sdl.IsGameController(i) != SdlBool.True) continue;
+ _pad = sdl.GameControllerOpen(i);
+ if (_pad == null) continue;
+ if (EngineDebug.Input) EngineDebug.Write("input", $"gamepad attached: {sdl.GameControllerNameS(_pad)}");
+ return true;
+ }
+ return false;
+ }
+
+ private float Axis(Sdl sdl, GameControllerAxis axis) =>
+ Math.Clamp(sdl.GameControllerGetAxis(_pad, axis) / 32767f, -1f, 1f);
+
+ public void Dispose()
+ {
+ if (_sdl is null) return;
+ if (_pad != null) _sdl.GameControllerClose(_pad);
+ _pad = null;
+ _sdl.QuitSubSystem(Sdl.InitGamecontroller);
+ _sdl = null;
+ }
+}