From 4b457f8c5ef70a7831c996441099351a6823dfa4 Mon Sep 17 00:00:00 2001 From: DysektAI Date: Mon, 27 Jul 2026 06:42:37 -0400 Subject: [PATCH 01/24] Restore RatEye submodule and add scan diagnostics --- .csharpierignore | 1 + .github/workflows/build.yml | 1 + .github/workflows/release.yml | 1 + .gitmodules | 3 + .markdownlint-cli2.jsonc | 3 +- AGENTS.md | 12 +- CONTRIBUTING.md | 3 +- Directory.Build.targets | 7 + README.md | 9 +- RatScanner.sln | 2 +- docs/agent-context/README.md | 4 +- docs/agent-context/app-ui.md | 4 +- docs/agent-context/architecture.md | 2 +- docs/agent-context/build-and-validation.md | 9 +- docs/agent-context/configuration-and-cache.md | 3 + docs/agent-context/contribution-workflow.md | 5 +- docs/agent-context/dependency-management.md | 9 +- docs/agent-context/local-development.md | 5 +- docs/agent-context/project-overview.md | 4 +- docs/agent-context/release-and-versioning.md | 2 +- docs/agent-context/repository-map.md | 16 +- docs/agent-context/scan-engine.md | 36 +- scripts/check-agent-docs.ps1 | 43 +- scripts/test-agent-docs.ps1 | 31 +- src/App/AGENTS.md | 2 +- .../Pages/App/Settings/SettingsAdvanced.razor | 23 + src/App/RatScanner.csproj | 4 +- src/App/RatScannerMain.cs | 70 +- src/App/ScanDiagnosticStore.cs | 163 ++++ src/App/i18n/en.json | 5 + src/App/i18n/es.json | 5 + src/App/i18n/fr.json | 5 + src/App/i18n/pl.json | 5 + src/App/i18n/pt.json | 5 + src/App/i18n/ru.json | 5 + src/App/i18n/zh.json | 5 + src/ScanEngine | 1 + src/ScanEngine/AGENTS.md | 31 - src/ScanEngine/Config/Config.cs | 52 -- src/ScanEngine/Config/Path.cs | 139 --- src/ScanEngine/Config/Processing.cs | 97 -- src/ScanEngine/Config/Processing/Icon.cs | 68 -- .../Config/Processing/Inspection.cs | 128 --- src/ScanEngine/Config/Processing/Inventory.cs | 72 -- src/ScanEngine/Extensions.cs | 387 -------- src/ScanEngine/IconManager.cs | 862 ------------------ src/ScanEngine/Logger.cs | 117 --- src/ScanEngine/Processing/Icon.cs | 441 --------- src/ScanEngine/Processing/Inspection.cs | 471 ---------- src/ScanEngine/Processing/Inventory.cs | 559 ------------ src/ScanEngine/Processing/MultiInspection.cs | 162 ---- .../Properties/InternalsVisibleTo.cs | 3 - .../Properties/Resources.Designer.cs | 303 ------ src/ScanEngine/Properties/Resources.resx | 193 ---- src/ScanEngine/RatEye.csproj | 51 -- src/ScanEngine/RatEyeEngine.cs | 81 -- src/ScanEngine/RatLogo.png | Bin 23038 -> 0 bytes src/ScanEngine/Resources/cell_full_border.png | Bin 820 -> 0 bytes .../Resources/icon_mod/icon_mod_aux.png | Bin 261 -> 0 bytes .../Resources/icon_mod/icon_mod_barrel.png | Bin 252 -> 0 bytes .../Resources/icon_mod/icon_mod_bipod.png | Bin 280 -> 0 bytes .../Resources/icon_mod/icon_mod_charge.png | Bin 283 -> 0 bytes .../icon_mod/icon_mod_flashlight.png | Bin 334 -> 0 bytes .../Resources/icon_mod/icon_mod_gasblock.png | Bin 272 -> 0 bytes .../Resources/icon_mod/icon_mod_handguard.png | Bin 257 -> 0 bytes .../Resources/icon_mod/icon_mod_ironsight.png | Bin 298 -> 0 bytes .../Resources/icon_mod/icon_mod_launcher.png | Bin 251 -> 0 bytes .../icon_mod/icon_mod_lightlaser.png | Bin 334 -> 0 bytes .../Resources/icon_mod/icon_mod_magazine.png | Bin 290 -> 0 bytes .../Resources/icon_mod/icon_mod_mount.png | Bin 257 -> 0 bytes .../Resources/icon_mod/icon_mod_muzzle.png | Bin 286 -> 0 bytes .../icon_mod/icon_mod_pistol_grip.png | Bin 302 -> 0 bytes .../icon_mod/icon_mod_railcovers.png | Bin 217 -> 0 bytes .../Resources/icon_mod/icon_mod_receiver.png | Bin 245 -> 0 bytes .../Resources/icon_mod/icon_mod_sight.png | Bin 281 -> 0 bytes .../Resources/icon_mod/icon_mod_stock.png | Bin 294 -> 0 bytes .../Resources/icon_mod/icon_mod_tactical.png | Bin 271 -> 0 bytes src/ScanEngine/Resources/icon_search.png | Bin 771 -> 0 bytes .../Resources/icon_type/mod_gear.png | Bin 200 -> 0 bytes .../Resources/icon_type/mod_generic.png | Bin 225 -> 0 bytes .../Resources/icon_type/mod_vital.png | Bin 200 -> 0 bytes src/ScanEngine/VENDOR.md | 24 - src/ScanEngine/Vector2.cs | 303 ------ tests/AGENTS.md | 2 + tests/RatScanner.Tests/OpenCvPipelineTests.cs | 343 ------- .../PresentationServicesTests.cs | 150 --- tests/RatScanner.Tests/RatEyeCacheTests.cs | 123 --- .../ScanDiagnosticStoreTests.cs | 80 ++ .../ScanPipelineImageHarnessTests.cs | 14 +- 89 files changed, 514 insertions(+), 5255 deletions(-) create mode 100644 .gitmodules create mode 100644 Directory.Build.targets create mode 100644 src/App/ScanDiagnosticStore.cs create mode 160000 src/ScanEngine delete mode 100644 src/ScanEngine/AGENTS.md delete mode 100644 src/ScanEngine/Config/Config.cs delete mode 100644 src/ScanEngine/Config/Path.cs delete mode 100644 src/ScanEngine/Config/Processing.cs delete mode 100644 src/ScanEngine/Config/Processing/Icon.cs delete mode 100644 src/ScanEngine/Config/Processing/Inspection.cs delete mode 100644 src/ScanEngine/Config/Processing/Inventory.cs delete mode 100644 src/ScanEngine/Extensions.cs delete mode 100644 src/ScanEngine/IconManager.cs delete mode 100644 src/ScanEngine/Logger.cs delete mode 100644 src/ScanEngine/Processing/Icon.cs delete mode 100644 src/ScanEngine/Processing/Inspection.cs delete mode 100644 src/ScanEngine/Processing/Inventory.cs delete mode 100644 src/ScanEngine/Processing/MultiInspection.cs delete mode 100644 src/ScanEngine/Properties/InternalsVisibleTo.cs delete mode 100644 src/ScanEngine/Properties/Resources.Designer.cs delete mode 100644 src/ScanEngine/Properties/Resources.resx delete mode 100644 src/ScanEngine/RatEye.csproj delete mode 100644 src/ScanEngine/RatEyeEngine.cs delete mode 100644 src/ScanEngine/RatLogo.png delete mode 100644 src/ScanEngine/Resources/cell_full_border.png delete mode 100644 src/ScanEngine/Resources/icon_mod/icon_mod_aux.png delete mode 100644 src/ScanEngine/Resources/icon_mod/icon_mod_barrel.png delete mode 100644 src/ScanEngine/Resources/icon_mod/icon_mod_bipod.png delete mode 100644 src/ScanEngine/Resources/icon_mod/icon_mod_charge.png delete mode 100644 src/ScanEngine/Resources/icon_mod/icon_mod_flashlight.png delete mode 100644 src/ScanEngine/Resources/icon_mod/icon_mod_gasblock.png delete mode 100644 src/ScanEngine/Resources/icon_mod/icon_mod_handguard.png delete mode 100644 src/ScanEngine/Resources/icon_mod/icon_mod_ironsight.png delete mode 100644 src/ScanEngine/Resources/icon_mod/icon_mod_launcher.png delete mode 100644 src/ScanEngine/Resources/icon_mod/icon_mod_lightlaser.png delete mode 100644 src/ScanEngine/Resources/icon_mod/icon_mod_magazine.png delete mode 100644 src/ScanEngine/Resources/icon_mod/icon_mod_mount.png delete mode 100644 src/ScanEngine/Resources/icon_mod/icon_mod_muzzle.png delete mode 100644 src/ScanEngine/Resources/icon_mod/icon_mod_pistol_grip.png delete mode 100644 src/ScanEngine/Resources/icon_mod/icon_mod_railcovers.png delete mode 100644 src/ScanEngine/Resources/icon_mod/icon_mod_receiver.png delete mode 100644 src/ScanEngine/Resources/icon_mod/icon_mod_sight.png delete mode 100644 src/ScanEngine/Resources/icon_mod/icon_mod_stock.png delete mode 100644 src/ScanEngine/Resources/icon_mod/icon_mod_tactical.png delete mode 100644 src/ScanEngine/Resources/icon_search.png delete mode 100644 src/ScanEngine/Resources/icon_type/mod_gear.png delete mode 100644 src/ScanEngine/Resources/icon_type/mod_generic.png delete mode 100644 src/ScanEngine/Resources/icon_type/mod_vital.png delete mode 100644 src/ScanEngine/VENDOR.md delete mode 100644 src/ScanEngine/Vector2.cs delete mode 100644 tests/RatScanner.Tests/OpenCvPipelineTests.cs delete mode 100644 tests/RatScanner.Tests/RatEyeCacheTests.cs create mode 100644 tests/RatScanner.Tests/ScanDiagnosticStoreTests.cs diff --git a/.csharpierignore b/.csharpierignore index 8b647f90..250584d5 100644 --- a/.csharpierignore +++ b/.csharpierignore @@ -10,3 +10,4 @@ **/*.xml **/obj/** **/bin/** +src/ScanEngine/** diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c973f1b2..82725018 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -23,6 +23,7 @@ jobs: uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 with: persist-credentials: false + submodules: recursive - name: Setup .NET uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5 with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index faced5ad..01f2e434 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -33,6 +33,7 @@ jobs: uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 with: persist-credentials: false + submodules: recursive - name: Resolve release version and validated build id: release env: diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 00000000..35f7072e --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "RatEye"] + path = src/ScanEngine + url = https://github.com/tarkovtracker-org/RatEye.git diff --git a/.markdownlint-cli2.jsonc b/.markdownlint-cli2.jsonc index 15113be4..ec66ce33 100644 --- a/.markdownlint-cli2.jsonc +++ b/.markdownlint-cli2.jsonc @@ -11,6 +11,7 @@ "**/bin/**", "**/obj/**", "**/publish/**", - "**/.vs/**" + "**/.vs/**", + "src/ScanEngine/**" ] } diff --git a/AGENTS.md b/AGENTS.md index 50f2a9f8..d06e8dfd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,14 +4,14 @@ ## Product (one paragraph) -Windows x64-only Escape from Tarkov external item scanner. WPF hosts MudBlazor UI via WebView2; screenshots feed an in-repo scan engine (vendored RatEye sources under `src/ScanEngine/`). Catalog data comes mainly from **json.tarkov.dev**; maps use a slim GraphQL query on **api.tarkov.dev** with JSON fallback. Maintained at `tarkovtracker-org/RatScanner`, semver **4.x** (`v4.x.x`). +Windows x64-only Escape from Tarkov external item scanner. WPF hosts MudBlazor UI via WebView2; screenshots feed standalone RatEye source checked out as a Git submodule under `src/ScanEngine/`. Catalog data comes mainly from **json.tarkov.dev**; maps use a slim GraphQL query on **api.tarkov.dev** with JSON fallback. Maintained at `tarkovtracker-org/RatScanner`, semver **4.x** (`v4.x.x`). **Stack snapshot:** `net10.0-windows10.0.22621.0` · WPF + WinForms · Blazor WebView · MudBlazor · RatStash · OpenCvSharp · Tesseract · Newtonsoft.Json. Package versions live in `.csproj` files — do not copy versions into docs. ## Non-negotiable constraints 1. **Windows x64 only** — the OpenCvSharp native runtime is x64-only. Do not design, build, test, or document x86, Linux/WSL, or macOS runs. -2. **Scan engine is in-tree** — edit `src/ScanEngine/`. Never re-add a NuGet `PackageReference` for `RatEye`. App references it via `ProjectReference`. Namespaces remain `RatEye`; assembly remains RatEye. +2. **Scan engine is standalone** — `src/ScanEngine/` is the `tarkovtracker-org/RatEye` submodule. Engine changes belong in RatEye and RatEye must never reference RatScanner. Never add a NuGet `PackageReference` for `RatEye`; App uses a source `ProjectReference`. 3. **Bulk catalog via json.tarkov.dev** — use `TarkovDevAPI` (rate limit, dedup, offline cache, backoff). Do not bypass with ad-hoc HTTP for items/tasks/hideout/crafts/barters. Do not reintroduce a GraphQL schema generator for bulk catalog. Slim maps GraphQL is intentional; keep maps off cold-start critical path. 4. **Product version only in** `src/App/RatScanner.csproj` ``. Independent 4.x line; do not mirror historical upstream 3.x tags. 5. **No secrets in git** — tokens live in user `config.cfg` (DPAPI-protected fields where used). @@ -39,6 +39,12 @@ powershell -NoProfile -ExecutionPolicy Bypass -File scripts\lint-markdown.ps1 -F publish.bat :: release package only (not day-to-day) ``` +Initialize source dependencies after cloning: + +```bat +git submodule update --init --recursive +``` + Day-to-day coding uses `dev.bat` / `scripts\dev.ps1` (debounced **restart-on-save** by default — 15s quiet period prevents endless rebuilds during rapid agent edits; `-NoDebounce` restores instant `dotnet watch`). Not full WPF hot reload. CI: `.github/workflows/build.yml` (Windows, .NET 10, documentation and formatting checks, Release build/test, dependency audit, validated single-file package) plus `.github/workflows/release.yml` (manual promotion of the exact successful `master` build artifact to either the opt-in `testing` pre-release channel or broad `latest` channel). Build CI runs on PRs and `master` pushes; releases are manual only. ## Fork / remotes / branches / PRs @@ -119,7 +125,7 @@ Index and maintenance rules for the context set: [docs/agent-context/README.md]( | Path | Scope | | --- | --- | | `src/App/AGENTS.md` | App UI, hosting, data clients owned by App | -| `src/ScanEngine/AGENTS.md` | Vendored scan engine | +| `src/ScanEngine/AGENTS.md` | Standalone RatEye submodule | | `tests/AGENTS.md` | Unit tests | ## Source-of-truth reminder diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b588ee15..143eb159 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -28,6 +28,7 @@ Supported workflow for this fork: Requirements: **64-bit Windows**, [.NET 10 SDK](https://dotnet.microsoft.com/download). ```bat +git submodule update --init --recursive dev.bat :: watch rebuild + restart (preferred) dev.bat -Once :: run once dotnet restore RatScanner.sln @@ -51,7 +52,7 @@ Agent and architecture guidance: root `AGENTS.md` + `docs/agent-context/`. - Prefer clear structure over commentary that restates the code. - MudBlazor/CSS: prefer component parameters and specificity over `!important`. - Bulk catalog data goes through `TarkovDevAPI` (json.tarkov.dev); maps may use intentional slim GraphQL + JSON fallback. Do not reintroduce GraphQL schema generation for bulk catalog or a NuGet `RatEye` package. -- Edit the in-repo scan engine under `src/ScanEngine/` (namespaces remain `RatEye`). +- `src/ScanEngine/` is the `tarkovtracker-org/RatEye` submodule. Commit engine changes in RatEye first, then update RatScanner's gitlink; RatEye must not reference RatScanner. ## Documentation diff --git a/Directory.Build.targets b/Directory.Build.targets new file mode 100644 index 00000000..e7bd783c --- /dev/null +++ b/Directory.Build.targets @@ -0,0 +1,7 @@ + + + + false + + diff --git a/README.md b/README.md index 0120715d..e030bcd1 100644 --- a/README.md +++ b/README.md @@ -79,9 +79,12 @@ Help: [FAQ][faq-page] · [Discord][discord] · [TarkovTracker Discord][tt-discor Requirements: **64-bit Windows**, [.NET 10 SDK](https://dotnet.microsoft.com/download). -1. Clone **this** repo: `https://github.com/tarkovtracker-org/RatScanner` +1. Clone **this** repo recursively: `git clone --recurse-submodules https://github.com/tarkovtracker-org/RatScanner` 2. From the repo root, run **`dev.bat`** (downloads icons/OCR data and restores packages on first run) +For an existing clone, initialize RatEye with +`git submodule update --init --recursive`. + ### Day-to-day coding | What you want | Command | @@ -97,7 +100,7 @@ Requirements: **64-bit Windows**, [.NET 10 SDK](https://dotnet.microsoft.com/dow ```text src/App/ # Main WPF app -src/ScanEngine/ # Scan engine (historical RatEye; in-tree) +src/ScanEngine/ # Standalone RatEye Git submodule tests/ # Unit tests scripts/ # dev + data setup ``` @@ -139,7 +142,7 @@ See `CONTRIBUTING.md`. PRs and issues: **[tarkovtracker-org/RatScanner][fork-rep Default integration branch is **`master`**. Day-to-day work uses short-lived `feat/…` / `fix/…` branches and PRs against the fork. -**Agent / contributor architecture docs:** root [`AGENTS.md`](AGENTS.md) (control plane) and [`docs/agent-context/`](docs/agent-context/README.md) (focused context). Nested `AGENTS.md` files under `src/App`, `src/ScanEngine`, and `tests` apply path-scoped rules. Implementation and project files override stale documentation. +**Agent / contributor architecture docs:** root [`AGENTS.md`](AGENTS.md) (control plane) and [`docs/agent-context/`](docs/agent-context/README.md) (focused context). RatEye has its own scoped instructions in the `src/ScanEngine` submodule; `src/App/AGENTS.md` and `tests/AGENTS.md` apply to this repository. Implementation and project files override stale documentation. ## Community & support diff --git a/RatScanner.sln b/RatScanner.sln index ee0c22e0..058a5f3f 100644 --- a/RatScanner.sln +++ b/RatScanner.sln @@ -5,7 +5,7 @@ VisualStudioVersion = 17.1.31911.260 MinimumVisualStudioVersion = 10.0.40219.1 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "RatScanner", "src\App\RatScanner.csproj", "{6C9373BE-454F-4A4A-850E-17AC27C76992}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "RatEye", "src\ScanEngine\RatEye.csproj", "{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "RatEye", "src\ScanEngine\RatEye\RatEye.csproj", "{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RatScanner.Tests", "tests\RatScanner.Tests\RatScanner.Tests.csproj", "{C9A0B012-A8AD-4396-B7B8-4CA69308C4CA}" EndProject diff --git a/docs/agent-context/README.md b/docs/agent-context/README.md index 19d7e2c8..8d9c7d0a 100644 --- a/docs/agent-context/README.md +++ b/docs/agent-context/README.md @@ -23,7 +23,7 @@ Context docs are not a second source of product truth. If prose disagrees with c | [local-development.md](local-development.md) | Setup, `dev.bat`, data install, watch loop failures | | [build-and-validation.md](build-and-validation.md) | Restore/build/test/format, what to run per change type | | [app-ui.md](app-ui.md) | Razor, MudBlazor, CSS, themes, host pages, layouts | -| [scan-engine.md](scan-engine.md) | OCR/icon pipeline, RatEye engine, vendoring rules | +| [scan-engine.md](scan-engine.md) | OCR/icon pipeline and standalone RatEye boundary | | [data-integrations.md](data-integrations.md) | json.tarkov.dev, slim maps GraphQL, TarkovTracker, caches | | [configuration-and-cache.md](configuration-and-cache.md) | `config.cfg`, paths, TTL, offline cache | | [localization.md](localization.md) | UI `i18n` files and `LocalizationService` | @@ -38,7 +38,7 @@ Root routing table: [`AGENTS.md`](../../AGENTS.md). 1. When you change architecture, commands, packages, CI, paths, or user-visible behavior, update the **matching** context file(s) in the same change. 2. Prefer links to authoritative files (`*.csproj`, scripts, workflows) over restating content that will drift. 3. Do not copy package version numbers into these docs. -4. Nested `src/App/AGENTS.md`, `src/ScanEngine/AGENTS.md`, and `tests/AGENTS.md` hold **scoped** rules; keep them short; point here for depth. +4. Nested `src/App/AGENTS.md` and `tests/AGENTS.md` hold RatScanner-scoped rules. The `src/ScanEngine` submodule supplies RatEye's own `AGENTS.md`. 5. Do not re-expand the root control plane into a subsystem dump. 6. After structural doc changes, run `scripts/check-agent-docs.ps1` (also run in CI). 7. After any `*.md` edit, run `scripts/lint-markdown.ps1 -Fix` (markdownlint-cli2; tables/fences/whitespace). CI enforces check mode; the optional hook checks without rewriting staged content. diff --git a/docs/agent-context/app-ui.md b/docs/agent-context/app-ui.md index e460ee3c..dffd2b7d 100644 --- a/docs/agent-context/app-ui.md +++ b/docs/agent-context/app-ui.md @@ -116,9 +116,9 @@ The always-visible PVP/PVE selector in `AppLayout` switches mode-specific tarkov | Package family | Project | | --- | --- | | WebView.Wpf, MudBlazor, Win Compatibility, SingleInstanceCore | App | -| Scan/vision packages for processing | ScanEngine (plus App for Tesseract where referenced) | +| Scan/vision packages for processing | Standalone RatEye submodule (plus App for Tesseract where referenced) | -Do not move MudBlazor into ScanEngine. +Do not move MudBlazor into RatEye. ## Practical UI change checklist diff --git a/docs/agent-context/architecture.md b/docs/agent-context/architecture.md index c50bc924..ee358912 100644 --- a/docs/agent-context/architecture.md +++ b/docs/agent-context/architecture.md @@ -120,4 +120,4 @@ flowchart TD ## Related nested rules - `src/App/AGENTS.md` — App-scoped constraints. -- `src/ScanEngine/AGENTS.md` — vendoring / namespaces. +- `src/ScanEngine/AGENTS.md` — standalone RatEye boundaries, tests, and packaging. diff --git a/docs/agent-context/build-and-validation.md b/docs/agent-context/build-and-validation.md index 123526ee..72e4fc66 100644 --- a/docs/agent-context/build-and-validation.md +++ b/docs/agent-context/build-and-validation.md @@ -30,7 +30,7 @@ dotnet test RatScanner.sln -c Release --no-restore CI builds and tests Release on `windows-latest` with .NET 10.x. -Current test project: `tests/RatScanner.Tests` (xUnit v3). It covers selected pure logic and reliability contracts, configuration migration, localization fallback/key parity, and synthetic OpenCV/native-loading smoke. **Not** a full UI or live-scan accuracy suite. Scoped rules: `tests/AGENTS.md`. +Current App test project: `tests/RatScanner.Tests` (xUnit v3). It covers App logic and reliability contracts, configuration migration, localization fallback/key parity, and the optional App-owned capture/crop harness. RatEye's submodule owns engine/OpenCV/cache tests and fixture replay. **Neither** is a substitute for full UI or live-scan verification. Scoped rules: `tests/AGENTS.md`. ### Formatting @@ -75,7 +75,7 @@ powershell -NoProfile -ExecutionPolicy Bypass -File scripts\check-agent-docs.ps1 powershell -NoProfile -ExecutionPolicy Bypass -File scripts\test-agent-docs.ps1 ``` -The integrity check validates required paths, context routing, local Markdown links, structurally parsed MSBuild references/package versions, ScanEngine packaging guards, and primary-branch consistency while excluding generated output. The adversarial test builds a disposable path-with-spaces fixture and proves representative failures are non-zero and actionable. Both run in CI. +The integrity check validates required paths, context routing, local Markdown links, structurally parsed MSBuild references/package versions, RatEye submodule wiring, and primary-branch consistency while excluding generated output. The adversarial test builds a disposable path-with-spaces fixture and proves representative failures are non-zero and actionable. Both run in CI. ### Analyzers / warnings @@ -149,7 +149,7 @@ CI uploads the validated `RatScanner.zip` as an immutable build artifact. The se | Pure logic / helpers | build + tests for covered code | csharpier | | API client / cache | build + unit tests | manual warm/cold start smoke | | UI Razor/CSS | build | WebView smoke | -| ScanEngine processing | build + relevant tests | scan smoke if behaviorally risking accuracy | +| RatEye processing | standalone RatEye build/tests + integrated App build | fixture replay + scan smoke if accuracy-sensitive | | Config paths / display | build + tests | manual multi-monitor if possible | | i18n | build | all locale files updated; UI language switch | | CI / scripts | build (or script dry-run) | publish path once if packaging changed | @@ -164,7 +164,8 @@ CI uploads the validated `RatScanner.zip` as an immutable build artifact. The se | Unit tests | Pure functions, contracts, regressions that were encoded | Real OCR accuracy, WebView rendering, end-user scan UX | | Build | Compiles against current TFMs/packages | Runtime asset presence beyond compile | | Manual UI | WebView wiring, CSS, navigation | Automated coverage | -| Manual scan | End-to-end recognition path | Continuous regression without a fixture harness | +| RatEye fixture replay | Recorded accuracy/latency for versioned captures | Every display/game configuration | +| Manual scan | End-to-end recognition path | Continuous regression by itself | | Doc integrity script | Structural doc + packaging constraints | Narrative accuracy of every sentence | Do not claim “fully tested” when only `dotnet build` succeeded. diff --git a/docs/agent-context/configuration-and-cache.md b/docs/agent-context/configuration-and-cache.md index 184cb8b6..614fab4d 100644 --- a/docs/agent-context/configuration-and-cache.md +++ b/docs/agent-context/configuration-and-cache.md @@ -49,10 +49,13 @@ Defined under `RatConfig.Paths` (base = exe directory): | `I18nDir` | `i18n` under base (UI strings) | | `ConfigFile` | `config.cfg` | | `LogFile` | `Log.txt` | +| `Debug` | Explicitly exported scan-diagnostic bundles under `Debug/ScanDiagnostics` | | Dynamic EFT icon cache | Battlestate Games temp `Icon Cache` | When code assumes paths, prefer these constants over string literals. +The Advanced settings page exports only the most recent in-memory scan when the user explicitly requests it. Each bundle contains the exact captured PNG and a versioned `scan.ratdiag.json` sidecar with capture/display geometry, RatEye configuration, observed results, confidence, and stage timings. Users should review screenshots before sharing them. + ## API offline cache Helpers: diff --git a/docs/agent-context/contribution-workflow.md b/docs/agent-context/contribution-workflow.md index 4f59a46a..c7eef10b 100644 --- a/docs/agent-context/contribution-workflow.md +++ b/docs/agent-context/contribution-workflow.md @@ -64,10 +64,11 @@ When a PR changes architecture, commands, packages, CI, config paths, or behavio 2. Update the matching `docs/agent-context/*.md`. 3. Update nested `AGENTS.md` if scoped rules changed. 4. Update `README.md` / `CONTRIBUTING.md` when user-facing workflow changes. -5. Never leave agent docs claiming GraphQL bulk catalog, NuGet RatEye, or Linux support. +5. Never leave agent docs claiming GraphQL bulk catalog, NuGet RatEye consumption, vendored RatEye, or Linux support. ## License and attribution - Keep `LICENSE` in published output. - Preserve “software has been modified” notices (README, Credits). -- Scan engine provenance note: `src/ScanEngine/VENDOR.md`. +- Scan engine source: `src/ScanEngine` submodule from `tarkovtracker-org/RatEye`. +- When both repositories change, push the RatEye commit before any RatScanner branch that references its gitlink. diff --git a/docs/agent-context/dependency-management.md b/docs/agent-context/dependency-management.md index f2d43a91..b17c6eb2 100644 --- a/docs/agent-context/dependency-management.md +++ b/docs/agent-context/dependency-management.md @@ -5,9 +5,10 @@ **Project files are the source of truth** for package versions and project references: - `src/App/RatScanner.csproj` -- `src/ScanEngine/RatEye.csproj` +- `src/ScanEngine/RatEye/RatEye.csproj` (RatEye submodule) - `tests/RatScanner.Tests/RatScanner.Tests.csproj` - `NuGet.Config` / nuget.org +- `Directory.Build.targets` (disables RatEye package generation in RatScanner builds) - `dotnet-tools.json` (CSharpier) Do not document “current version is X” in prose. Read the csproj. @@ -21,11 +22,11 @@ Do not document “current version is X” in prose. Read the csproj. | RatStash, Tesseract, Newtonsoft.Json | Shared across App/ScanEngine as needed | | xUnit, test SDK | Tests only | -ScanEngine is **not** packable for NuGet release from this repo. +RatEye remains independently packable from its own repository. RatScanner does not publish or consume that package during development. ## Hard constraints -1. **No NuGet RatEye** — App uses ``. +1. **No NuGet RatEye** — App uses ``. 2. Keep **RatStash major** aligned between App and ScanEngine so one assembly loads. 3. Prefer existing libraries already in the graph over new packages. 4. Prefer versions published ≥ ~7 days; avoid floating `*` / open-ended ranges / `latest`. @@ -62,7 +63,7 @@ When upgrades **are** requested: ## Framework alignment - App: `net10.0-windows10.0.22621.0` with `EnableWindowsTargeting`. -- ScanEngine: `netstandard2.0` + LangVersion 9. +- RatEye library: `netstandard2.0`; confirm language settings in the submodule project. - App/tests: x64-only in all configurations to match the OpenCvSharp Windows native runtime; do not restore x86 solution configurations. - Tests: same Windows TFM family as App. - CI installs .NET `10.0.x`. diff --git a/docs/agent-context/local-development.md b/docs/agent-context/local-development.md index 16560aeb..6797f3fb 100644 --- a/docs/agent-context/local-development.md +++ b/docs/agent-context/local-development.md @@ -6,6 +6,7 @@ - [.NET 10 SDK](https://dotnet.microsoft.com/download) matching CI (`10.0.x`). - Network for first-time NuGet restore and RatScannerData download. - WebView2: installed automatically at runtime if missing; manual install still fine. +- Initialized RatEye submodule (`git submodule update --init --recursive`). - Optional: 7-Zip on PATH for publish zipping (`publish.bat` falls back to `Compress-Archive`). - Optional for markdown lint/auto-fix: [Node.js LTS](https://nodejs.org/) (npm). Used only by `scripts\lint-markdown.ps1` / CI — not required to run the app. @@ -16,7 +17,7 @@ Do not run or document the app under x86 Windows, WSL, or Linux. Targeting and n ## SDK / runtime expectations - App TFM: see `src/App/RatScanner.csproj` (`net10.0-windows10.0.22621.0`). -- ScanEngine: `netstandard2.0` (consumed by App). +- RatEye library: `netstandard2.0` (consumed from the submodule by App). - Tests: same Windows TFM family as App. - App and tests target x64 in every configuration; x86 solution configurations are intentionally absent. - App disables implicit usings — code must keep explicit `using` directives. @@ -114,4 +115,4 @@ dotnet run --project src\App\RatScanner.csproj - Do not use `publish.bat` as the everyday loop (slow; packages single-file). - Do not commit `src/App/Data` or `publish/`. -- Do not assume NuGet RatEye is a valid fix for scan bugs — edit `src/ScanEngine/`. +- Do not assume NuGet RatEye is a valid fix for scan bugs. Make engine changes in the RatEye submodule, commit RatEye first, then update RatScanner's gitlink. diff --git a/docs/agent-context/project-overview.md b/docs/agent-context/project-overview.md index 41357dec..ea39bddf 100644 --- a/docs/agent-context/project-overview.md +++ b/docs/agent-context/project-overview.md @@ -29,7 +29,7 @@ It does **not** read game memory or inject into the client. | Host | WPF (`PageSwitcher`, tray, jump list) | | Tray / multi-monitor | WinForms (`NotifyIcon`, `Screen`) — reason both WPF and WinForms are enabled | | Embedded UI | Blazor Hybrid (`Microsoft.AspNetCore.Components.WebView.Wpf`) + MudBlazor | -| Scan engine | In-repo `src/ScanEngine` (assembly/namespaces `RatEye`) | +| Scan engine | Standalone `tarkovtracker-org/RatEye` submodule at `src/ScanEngine` | | Item DB for matching | RatStash database built from tarkov.dev item projections | | OCR | Tesseract (+ traineddata under `Data/`) | | Vision | OpenCvSharp (ScanEngine) | @@ -61,7 +61,7 @@ License: root `LICENSE` (Elastic License 2.0–based). Redistribution must inclu - Not a memory cheat / ESP / aim aid. - Not an x86, Linux, or cross-platform client. -- Not a re-publish of the scan engine as a separate NuGet from this monorepo (`IsPackable=false` on ScanEngine). +- Not a NuGet consumer of RatEye. RatScanner references the checked-out submodule source; RatEye owns its package/version independently. - Not GraphQL-first bulk catalog (json documents are primary; maps are the intentional slim GraphQL exception). - Not day-to-day use of `publish.bat` for iteration. - Not tracking historical upstream version numbers for this project's releases. diff --git a/docs/agent-context/release-and-versioning.md b/docs/agent-context/release-and-versioning.md index b6b8d911..ad0771f7 100644 --- a/docs/agent-context/release-and-versioning.md +++ b/docs/agent-context/release-and-versioning.md @@ -11,7 +11,7 @@ **Bump only** `` in `src/App/RatScanner.csproj`. Do not mirror historical upstream patch numbers. -ScanEngine's own `` is historical engine packaging metadata; product releases are App-driven. +RatEye owns its own package version in the submodule. RatScanner product releases remain App-driven and do not reuse RatEye's version. ### Semver guidance diff --git a/docs/agent-context/repository-map.md b/docs/agent-context/repository-map.md index b45a03ea..f06d5e5b 100644 --- a/docs/agent-context/repository-map.md +++ b/docs/agent-context/repository-map.md @@ -7,7 +7,7 @@ Organized by **concern**. Exact filenames change; start here, then confirm on di ```text RatScanner.sln src/App/ # WPF + Blazor app (assembly RatScanner) -src/ScanEngine/ # Vendored scan engine (assembly RatEye) +src/ScanEngine/ # Standalone RatEye Git submodule tests/RatScanner.Tests/ # xUnit tests scripts/ # dev, data setup, zip helper, agent-docs check, markdown lint, optional bench .github/workflows/ # CI build + release @@ -60,11 +60,11 @@ examples/ # Sample resolution screenshots | Concern | Where | | --- | --- | -| Engine facade | `src/ScanEngine/RatEyeEngine.cs` | -| Config | `src/ScanEngine/Config/*` | -| Inspection / inventory / icon | `src/ScanEngine/Processing/*` | -| Icon templates | `src/ScanEngine/IconManager.cs`, `Resources/` | -| Provenance | `src/ScanEngine/VENDOR.md` | +| Engine facade | `src/ScanEngine/RatEye/RatEyeEngine.cs` | +| Config | `src/ScanEngine/RatEye/Config/*` | +| Inspection / inventory / icon | `src/ScanEngine/RatEye/Processing/*` | +| Icon templates | `src/ScanEngine/RatEye/IconManager.cs`, `Resources/` | +| Replay benchmark | `src/ScanEngine/RatEye.Benchmarks` | ## Data integrations @@ -139,9 +139,9 @@ examples/ # Sample resolution screenshots | CSharpier | `dotnet-tools.json`, `.csharpierrc.json`, `.csharpierignore`; invoke via `dotnet tool restore` then `dotnet csharpier check .` / `format .` | | Editor defaults | `.editorconfig`, `.vscode/settings.json` | -## Vendored code +## Source dependency -See `src/ScanEngine/VENDOR.md`. Do not reintroduce NuGet RatEye. +`src/ScanEngine` is the standalone RatEye submodule. Initialize it recursively and do not reintroduce a NuGet RatEye dependency. ## Intentionally not product source diff --git a/docs/agent-context/scan-engine.md b/docs/agent-context/scan-engine.md index dae43486..9943cd78 100644 --- a/docs/agent-context/scan-engine.md +++ b/docs/agent-context/scan-engine.md @@ -1,10 +1,10 @@ # Scan engine -Scoped mandatory rules: `src/ScanEngine/AGENTS.md`. Provenance: `src/ScanEngine/VENDOR.md`. +RatEye's scoped mandatory rules live in the `src/ScanEngine/AGENTS.md` submodule file. ## Role -The scan engine (folder `src/ScanEngine/`, assembly and namespaces still **`RatEye`**) turns screenshots into item matches using: +The standalone RatEye submodule (`src/ScanEngine/`, project under `RatEye/`, assembly and namespaces **`RatEye`**) turns screenshots into item matches using: - **Inspection / multi-inspection** — name-plate OCR path (Tesseract). - **Inventory + icon template matching** — icon scan path (OpenCvSharp), optional rotation. @@ -56,7 +56,7 @@ From App output `Data/` (installed via setup-data): - Tesseract traineddata - Correlation helpers as used by config paths -Native OpenCV bits come from OpenCvSharp Windows packages on ScanEngine. Missing Data degrades or breaks scanning; that is a setup problem, not a reason to restore NuGet RatEye. +Native OpenCV bits come from OpenCvSharp Windows packages on RatEye. Missing Data degrades or breaks scanning; that is a setup problem, not a reason to consume the RatEye NuGet package. ## Confidence and presentation handoff @@ -64,28 +64,30 @@ Engine results surface through App scan types (`ItemNameScan` / `ItemIconScan`) ## Fixture and accuracy expectations -- Upstream RatEye test binaries are **not** vendored (size). See `VENDOR.md`. -- App unit tests cover limited cache/presentation contracts plus OpenCV native/pipeline smoke (`OpenCvPipelineTests` uses **synthetic** bitmaps only — not game assets) and icon-cache regeneration. -- Accuracy-sensitive changes (OCR name match, icon template thresholds) still require manual scan smoke with real Data (+ game or carefully prepared captures). Do not treat synthetic OpenCV tests as accuracy proof. +- RatEye owns synthetic OpenCV, cache, processing, historical fixture, and replay-benchmark coverage. +- RatScanner keeps `ScanPipelineImageHarnessTests` because it mirrors App-owned data projection and capture/crop geometry. +- Accuracy-sensitive changes require a RatEye benchmark report and, when practical, a manual scan smoke with real Data. Do not treat synthetic OpenCV tests as accuracy proof. +- The App retains only the latest captured scan in memory and exports it from Advanced settings on explicit user action. RatEye owns the versioned replay contract and benchmark runner. - Prefer deterministic pure helpers when adding new regression coverage. - Keep OpenCvSharp Extensions + Windows packages on the **same** version; do not adopt slim runtimes unless every used module is present. -## Vendoring rules +## Repository boundary -- Sources vendored from historical RatEye tag noted in `VENDOR.md`. -- Referenced only via `ProjectReference` from App. -- **Never** add NuGet package `RatEye` back to the solution. -- Do not publish this project as a NuGet package from the monorepo (`IsPackable=false`). -- Namespaces stay `RatEye` unless a deliberate, coordinated rename is requested. +- `src/ScanEngine` is a Git submodule of `tarkovtracker-org/RatEye`. +- App references `src/ScanEngine/RatEye/RatEye.csproj` directly. +- **Never** add a NuGet package reference for RatEye to RatScanner. +- RatEye remains independently packable and versioned; RatScanner's product version remains App-owned. +- RatEye must never reference RatScanner, WPF, Blazor, HTTP clients, or App settings. +- Commit and validate RatEye first, then update RatScanner's gitlink. ## Changing processing behavior -1. Read this file + nested ScanEngine `AGENTS.md` + relevant Processing types. -2. Prefer minimal, testable changes inside ScanEngine when the logic is pure image matching. +1. Read this file + RatEye's `AGENTS.md` + relevant Processing types. +2. Prefer minimal, testable changes inside RatEye when the logic is pure image matching. 3. Keep App ↔ engine contract stable (Config shape, Item ids via RatStash). 4. Rebuild App after engine changes (ProjectReference). -5. Validate with build/tests; manual scan smoke for accuracy-sensitive edits. -6. Update `VENDOR.md` only when provenance or license facts change — not for every code tweak. +5. Validate RatEye independently, then RatScanner; replay fixtures/manual scan smoke for accuracy-sensitive edits. +6. Keep RatEye and RatScanner commits reviewable and preserve the dependency ordering. ## What not to put in ScanEngine @@ -94,4 +96,4 @@ Engine results surface through App scan types (`ItemNameScan` / `ItemIconScan`) - TarkovTracker progress - Application settings persistence -Keep the engine library-shaped even though it lives in this repo. +Keep the engine library-shaped in its standalone repository. diff --git a/scripts/check-agent-docs.ps1 b/scripts/check-agent-docs.ps1 index 24dd223e..a9c9ed8f 100644 --- a/scripts/check-agent-docs.ps1 +++ b/scripts/check-agent-docs.ps1 @@ -7,8 +7,8 @@ - Required AGENTS.md, context, tooling, project, and workflow files exist - Root AGENTS.md and the context index route every context document - Local Markdown links resolve - - App structurally ProjectReferences the in-tree ScanEngine (no NuGet RatEye) - - ScanEngine remains non-packable + - App structurally ProjectReferences standalone RatEye source (no NuGet RatEye) + - RatEye submodule path and URL remain explicit - MSBuild XML is valid and package versions are not floating or open-ended - Branch-policy documents identify master as the integration branch - CI pull requests and branch pushes target master @@ -325,10 +325,12 @@ $requiredFiles = @( 'CONTRIBUTING.md', 'README.md', 'LICENSE', + '.gitmodules', 'RatScanner.sln', 'dev.bat', 'publish.bat', 'dotnet-tools.json', + 'Directory.Build.targets', '.csharpierrc.json', 'scripts\dev.ps1', 'scripts\setup-data.ps1', @@ -341,8 +343,7 @@ $requiredFiles = @( '.markdownlint-cli2.jsonc', '.markdownlint.json', 'src\App\RatScanner.csproj', - 'src\ScanEngine\RatEye.csproj', - 'src\ScanEngine\VENDOR.md', + 'src\ScanEngine\RatEye\RatEye.csproj', 'tests\RatScanner.Tests\RatScanner.Tests.csproj', 'src\App\AGENTS.md', 'src\ScanEngine\AGENTS.md', @@ -368,6 +369,17 @@ foreach ($relative in $requiredFiles) { [void](Assert-PathExists -RelativePath $relative -Reason 'Required path') } +$gitmodulesPath = Join-Path $RepoRoot '.gitmodules' +if (Test-Path -LiteralPath $gitmodulesPath) { + $gitmodulesText = Get-Content -LiteralPath $gitmodulesPath -Raw + if ($gitmodulesText -notmatch '(?m)^\s*path\s*=\s*src/ScanEngine\s*$') { + Add-Failure '.gitmodules must map RatEye to src/ScanEngine' + } + if ($gitmodulesText -notmatch '(?m)^\s*url\s*=\s*https://github\.com/tarkovtracker-org/RatEye\.git\s*$') { + Add-Failure '.gitmodules must use https://github.com/tarkovtracker-org/RatEye.git' + } +} + $agentsPath = Join-Path $RepoRoot 'AGENTS.md' $contextIndexPath = Join-Path $RepoRoot 'docs\agent-context\README.md' $contextDir = Join-Path $RepoRoot 'docs\agent-context' @@ -419,7 +431,7 @@ foreach ($projectFile in $projectFiles) { $package.GetAttribute('Update').Trim() } if ($packageId -eq 'RatEye') { - Add-Failure ('NuGet RatEye PackageReference found in ' + $relative + ' - use the in-tree ProjectReference') + Add-Failure ('NuGet RatEye PackageReference found in ' + $relative + ' - use the submodule ProjectReference') } $rawVersion = Get-ItemVersion -Item $package $resolvedVersion = Resolve-MsBuildProperties -Value $rawVersion -Properties $properties @@ -452,7 +464,7 @@ foreach ($centralFile in $centralPackageFiles) { } $appCsprojPath = Join-Path $RepoRoot 'src\App\RatScanner.csproj' -$scanCsprojPath = Join-Path $RepoRoot 'src\ScanEngine\RatEye.csproj' +$scanCsprojPath = Join-Path $RepoRoot 'src\ScanEngine\RatEye\RatEye.csproj' if ($projectDocuments.ContainsKey($appCsprojPath)) { $appDocument = $projectDocuments[$appCsprojPath] $expectedScanPath = [System.IO.Path]::GetFullPath($scanCsprojPath) @@ -473,31 +485,24 @@ if ($projectDocuments.ContainsKey($appCsprojPath)) { } } if (-not $hasScanProjectReference) { - Add-Failure 'App must ProjectReference src\ScanEngine\RatEye.csproj' + Add-Failure 'App must ProjectReference src\ScanEngine\RatEye\RatEye.csproj' } if ($appDocument.SelectNodes("//*[local-name()='Version']").Count -eq 0) { Add-Failure 'App csproj missing Version element' } } -if ($projectDocuments.ContainsKey($scanCsprojPath)) { - $scanDocument = $projectDocuments[$scanCsprojPath] - $isPackable = $scanDocument.SelectSingleNode("//*[local-name()='IsPackable']") - $generatePackage = $scanDocument.SelectSingleNode("//*[local-name()='GeneratePackageOnBuild']") - if ($null -eq $isPackable -or $isPackable.InnerText.Trim() -ne 'false') { - Add-Failure 'ScanEngine must set IsPackable=false' - } - if ($null -eq $generatePackage -or $generatePackage.InnerText.Trim() -ne 'false') { - Add-Failure 'ScanEngine must set GeneratePackageOnBuild=false' - } -} - foreach ($branchDocument in @('AGENTS.md', 'CONTRIBUTING.md', 'README.md', 'docs\agent-context\contribution-workflow.md')) { Test-PrimaryBranchClaim -RelativePath $branchDocument } $ciPath = Join-Path $RepoRoot '.github\workflows\build.yml' if (Test-Path -LiteralPath $ciPath) { + $ciText = Get-Content -LiteralPath $ciPath -Raw + if ($ciText -notmatch '(?m)^\s*submodules:\s*recursive\s*$') { + Add-Failure 'CI checkout must initialize RatEye with submodules: recursive' + } + $pullRequestBranches = @(Get-WorkflowEventBranches -WorkflowPath $ciPath -EventName 'pull_request') if ($pullRequestBranches.Count -eq 0) { Add-Failure 'CI workflow must declare an explicit pull_request branches list containing master' diff --git a/scripts/test-agent-docs.ps1 b/scripts/test-agent-docs.ps1 index cf12ff03..a3d0e605 100644 --- a/scripts/test-agent-docs.ps1 +++ b/scripts/test-agent-docs.ps1 @@ -90,11 +90,13 @@ try { 'CONTRIBUTING.md', 'FAQ.md', 'LICENSE', + '.gitmodules', 'README.md', 'RatScanner.sln', 'dev.bat', 'publish.bat', 'dotnet-tools.json', + 'Directory.Build.targets', '.csharpierrc.json', '.markdownlint-cli2.jsonc', '.markdownlint.json', @@ -103,8 +105,7 @@ try { 'src\App\AGENTS.md', 'src\App\RatScanner.csproj', 'src\ScanEngine\AGENTS.md', - 'src\ScanEngine\RatEye.csproj', - 'src\ScanEngine\VENDOR.md', + 'src\ScanEngine\RatEye\RatEye.csproj', 'tests\AGENTS.md', 'tests\RatScanner.Tests\RatScanner.Tests.csproj' )) { @@ -164,13 +165,25 @@ try { Restore-FixtureFile -RelativePath 'src\App\RatScanner.csproj' } + $gitmodulesPath = Join-Path $fixtureRoot '.gitmodules' + [System.IO.File]::WriteAllText( + $gitmodulesPath, + "[submodule `"RatEye`"]`r`n`tpath = src/ScanEngine`r`n`turl = https://example.invalid/RatEye.git`r`n" + ) + try { + Invoke-IntegrityCheck -ShouldPass $false -ExpectedText '.gitmodules must use https://github.com/tarkovtracker-org/RatEye.git' -Scenario 'RatEye submodule points at an unexpected repository' + } + finally { + Restore-FixtureFile -RelativePath '.gitmodules' + } + [xml]$appProject = Get-Content -LiteralPath $appProjectPath -Raw foreach ($reference in @($appProject.SelectNodes("//*[local-name()='ProjectReference']"))) { [void]$reference.ParentNode.RemoveChild($reference) } $appProject.Save($appProjectPath) try { - Invoke-IntegrityCheck -ShouldPass $false -ExpectedText 'App must ProjectReference src\ScanEngine\RatEye.csproj' -Scenario 'missing in-tree ScanEngine ProjectReference' + Invoke-IntegrityCheck -ShouldPass $false -ExpectedText 'App must ProjectReference src\ScanEngine\RatEye\RatEye.csproj' -Scenario 'missing RatEye submodule ProjectReference' } finally { Restore-FixtureFile -RelativePath 'src\App\RatScanner.csproj' @@ -199,6 +212,18 @@ try { } $workflowPath = Join-Path $fixtureRoot '.github\workflows\build.yml' + $workflow = Get-Content -LiteralPath $workflowPath -Raw + [System.IO.File]::WriteAllText( + $workflowPath, + [regex]::Replace($workflow, '(?m)^\s*submodules:\s*recursive\r?\n', '') + ) + try { + Invoke-IntegrityCheck -ShouldPass $false -ExpectedText 'CI checkout must initialize RatEye with submodules: recursive' -Scenario 'CI omits RatEye submodule initialization' + } + finally { + Restore-FixtureFile -RelativePath '.github\workflows\build.yml' + } + $workflow = Get-Content -LiteralPath $workflowPath -Raw $wrongPushWorkflow = [regex]::Replace( $workflow, diff --git a/src/App/AGENTS.md b/src/App/AGENTS.md index b12a8a49..6b5c124c 100644 --- a/src/App/AGENTS.md +++ b/src/App/AGENTS.md @@ -9,7 +9,7 @@ WPF host, Blazor WebView UI, configuration, tarkov.dev/TarkovTracker clients, sc ## Mandatory 1. **Windows x64-only** host assumptions (WPF, WebView2, WinForms tray/screens/DPI, x64 OpenCvSharp native runtime, DPAPI). -2. Reference scan engine via **ProjectReference** to `../ScanEngine/RatEye.csproj` only — never NuGet `RatEye`. +2. Reference the standalone scan engine via **ProjectReference** to `../ScanEngine/RatEye/RatEye.csproj` only — never NuGet `RatEye`. 3. Bulk catalog I/O goes through **`TarkovDevAPI`**. Keep maps off cold-start critical path; slim GraphQL maps + JSON fallback is intentional. 4. Product **``** lives only in `RatScanner.csproj`. Product name is `RatScanner` (`Constants.Branding`); user-agent is `RatScanner/…`. 5. **UI styling:** prefer MudBlazor parameters and specificity; avoid new `!important` except clear a11y/third-party necessities. Global tokens: `wwwroot/css/theme.css`. diff --git a/src/App/Pages/App/Settings/SettingsAdvanced.razor b/src/App/Pages/App/Settings/SettingsAdvanced.razor index 7241e76d..c418a62b 100644 --- a/src/App/Pages/App/Settings/SettingsAdvanced.razor +++ b/src/App/Pages/App/Settings/SettingsAdvanced.razor @@ -106,6 +106,14 @@ ValueChanged="@(_ => StateHasChanged())" Label="@Localizer["LogDebugInfo"]" Persist="SettingsVM.SetLogDebugAsync" /> + + @Localizer["ExportScanDiagnostics"] + +

@Localizer["ExportScanDiagnosticsDescription"]

@@ -179,6 +187,21 @@ await PersistDisplayAsync(); } + private void ExportScanDiagnostics() + { + ScanDiagnosticExportResult result = RatScannerMain.Instance.ExportLastScanDiagnostics(); + if (result.Succeeded) + { + Snackbar.Add(string.Format(Localizer["ScanDiagnosticsExported"], result.Directory), Severity.Success); + return; + } + + string message = result.Error is null + ? Localizer["NoScanDiagnostics"] + : string.Format(Localizer["ScanDiagnosticsExportFailed"], result.Error); + Snackbar.Add(message, Severity.Error); + } + private void PropertyChangeHandler(object? sender, EventArgs e) => _ = InvokeAsync(StateHasChanged); public void Dispose() diff --git a/src/App/RatScanner.csproj b/src/App/RatScanner.csproj index 8a343aae..9521305f 100644 --- a/src/App/RatScanner.csproj +++ b/src/App/RatScanner.csproj @@ -61,8 +61,8 @@ - - + + diff --git a/src/App/RatScannerMain.cs b/src/App/RatScannerMain.cs index 719d7cee..41d89e59 100644 --- a/src/App/RatScannerMain.cs +++ b/src/App/RatScannerMain.cs @@ -63,6 +63,7 @@ internal static void DisposeInstance() private readonly object _ratEyeSetupLock = new(); private readonly object _trackerConfigurationLock = new(); private readonly CancellationTokenSource _lifetimeCancellation = new(); + private readonly ScanDiagnosticStore _scanDiagnostics = new(); private int _trackerRefreshInProgress; private bool _ratEyeReady; private bool _disposed; @@ -483,8 +484,20 @@ internal void NameScan(Vector2 position) // Scan the item RatEye.Processing.Inspection inspection = RatEyeEngine.NewInspection(screenshot); + Item? detectedItem = inspection.Item; + _scanDiagnostics.Record( + "inspection", + screenshot, + position, + new Vector2(markerScanSize / 2, markerScanSize / 2), + [new(detectedItem?.Id, detectedItem?.Name, inspection.ItemConfidence)], + inspection.Timings.Snapshot(), + RatEyeEngine.Config, + RatConfig.GameDisplayConfiguration, + RatConfig.VersionDisplay + ); - if (!inspection.ContainsMarker || inspection.Item == null) + if (!inspection.ContainsMarker || detectedItem == null) { Logger.LogDebug( $"NameScan: no marker or item (ContainsMarker={inspection.ContainsMarker} Item={inspection.Item != null})" @@ -540,11 +553,32 @@ internal void NameScanScreen(object? _ = null) // Scan the item RatEye.Processing.MultiInspection multiInspection = RatEyeEngine.NewMultiInspection(screenshot); + List inspections = multiInspection.Inspections; + List detections = []; + Dictionary stageMilliseconds = new(multiInspection.Timings.Snapshot()); + for (int index = 0; index < inspections.Count; index++) + { + RatEye.Processing.Inspection inspection = inspections[index]; + Item? detectedItem = inspection.Item; + detections.Add(new(detectedItem?.Id, detectedItem?.Name, inspection.ItemConfidence)); + AddTimings(stageMilliseconds, inspection.Timings.Snapshot(), $"inspection[{index}]."); + } + _scanDiagnostics.Record( + "multi-inspection", + screenshot, + position, + null, + detections, + stageMilliseconds, + RatEyeEngine.Config, + RatConfig.GameDisplayConfiguration, + RatConfig.VersionDisplay + ); - if (multiInspection.Inspections.Count == 0) + if (inspections.Count == 0) return; - foreach (RatEye.Processing.Inspection? inspection in multiInspection.Inspections) + foreach (RatEye.Processing.Inspection? inspection in inspections) { if (inspection.Item is null) continue; @@ -593,8 +627,23 @@ internal void IconScan(Vector2 position) // Scan the item using RatEye.Processing.Inventory inventory = RatEyeEngine.NewInventory(screenshot); RatEye.Processing.Icon? icon = inventory.LocateIcon(); + Item? detectedItem = icon?.Item; + Dictionary stageMilliseconds = new(inventory.Timings.Snapshot()); + if (icon is not null) + AddTimings(stageMilliseconds, icon.Timings.Snapshot()); + _scanDiagnostics.Record( + "inventory", + screenshot, + screenshotPosition, + new Vector2(size.Width / 2, size.Height / 2), + icon is null ? [] : [new(detectedItem?.Id, detectedItem?.Name, icon.DetectionConfidence)], + stageMilliseconds, + RatEyeEngine.Config, + RatConfig.GameDisplayConfiguration, + RatConfig.VersionDisplay + ); - if (icon?.Item == null || icon.DetectionConfidence < RatConfig.IconScan.MinAcceptConfidence) + if (detectedItem == null || icon!.DetectionConfidence < RatConfig.IconScan.MinAcceptConfidence) { if (icon?.Item != null) { @@ -628,6 +677,18 @@ internal void IconScan(Vector2 position) Logger.LogDebug("IconScan: EXIT (lock released)"); } + internal ScanDiagnosticExportResult ExportLastScanDiagnostics() => _scanDiagnostics.Export(); + + private static void AddTimings( + IDictionary destination, + IReadOnlyDictionary source, + string prefix = "" + ) + { + foreach ((string stage, double milliseconds) in source) + destination[prefix + stage] = milliseconds; + } + // Returns the ruff screenshot private static Bitmap GetScreenshot(Vector2 vector2, Size size) { @@ -757,6 +818,7 @@ public void Dispose() _ratEyeReady = false; } } + _scanDiagnostics.Dispose(); _lifetimeCancellation.Dispose(); } } diff --git a/src/App/ScanDiagnosticStore.cs b/src/App/ScanDiagnosticStore.cs new file mode 100644 index 00000000..a4610b52 --- /dev/null +++ b/src/App/ScanDiagnosticStore.cs @@ -0,0 +1,163 @@ +using System; +using System.Collections.Generic; +using System.Drawing; +using System.Drawing.Imaging; +using System.IO; +using System.Linq; +using Newtonsoft.Json; +using Newtonsoft.Json.Serialization; +using RatEye.Diagnostics; +using RatScanner.Display; + +namespace RatScanner; + +internal readonly record struct ScanDiagnosticDetection(string? ItemId, string? ItemName, float Confidence); + +internal readonly record struct ScanDiagnosticExportResult(bool Succeeded, string? Directory, string? Error); + +internal sealed class ScanDiagnosticStore : IDisposable +{ + private readonly object _sync = new(); + private Bitmap? _capture; + private ScanReplayManifest? _manifest; + private bool _disposed; + + internal void Record( + string scanType, + Bitmap capture, + RatEye.Vector2 capturePosition, + RatEye.Vector2? cursorPosition, + IReadOnlyCollection detections, + IReadOnlyDictionary stageMilliseconds, + RatEye.Config ratEyeConfig, + GameDisplayConfiguration display, + string applicationVersion + ) + { + ArgumentNullException.ThrowIfNull(capture); + + DateTime capturedAtUtc = DateTime.UtcNow; + string captureId = $"ratscanner-{capturedAtUtc:yyyyMMdd-HHmmss-fff}-{Guid.NewGuid():N}"; + Rectangle displayBounds = display.CaptureBounds; + ScanReplayManifest manifest = new() + { + Id = captureId, + ScanType = scanType, + ImageFile = "capture.png", + Configuration = new ScanReplayConfiguration + { + Scale = ratEyeConfig.ProcessingConfig.Scale, + Language = ratEyeConfig.ProcessingConfig.Language.ToString(), + OptimizeHighlighted = ratEyeConfig.ProcessingConfig.InventoryConfig.OptimizeHighlighted, + UseStaticIcons = ratEyeConfig.ProcessingConfig.IconConfig.UseStaticIcons, + ScanRotatedIcons = ratEyeConfig.ProcessingConfig.IconConfig.ScanRotatedIcons, + }, + Context = new ScanReplayContext + { + CapturedAtUtc = capturedAtUtc, + ApplicationVersion = applicationVersion, + CaptureX = capturePosition.X, + CaptureY = capturePosition.Y, + CaptureWidth = capture.Width, + CaptureHeight = capture.Height, + DisplayX = displayBounds.X, + DisplayY = displayBounds.Y, + DisplayWidth = displayBounds.Width, + DisplayHeight = displayBounds.Height, + DpiScale = (float)display.DisplayScale, + CursorX = cursorPosition?.X, + CursorY = cursorPosition?.Y, + }, + Observed = new ScanReplayObservedResult + { + ItemIds = detections + .Where(detection => !string.IsNullOrWhiteSpace(detection.ItemId)) + .Select(detection => detection.ItemId!) + .ToList(), + ItemNames = detections + .Where(detection => !string.IsNullOrWhiteSpace(detection.ItemName)) + .Select(detection => detection.ItemName!) + .ToList(), + Confidences = detections.Select(detection => detection.Confidence).ToList(), + StageMilliseconds = new Dictionary(stageMilliseconds), + }, + }; + Bitmap clonedCapture = new(capture); + + try + { + lock (_sync) + { + ObjectDisposedException.ThrowIf(_disposed, this); + _capture?.Dispose(); + _capture = clonedCapture; + _manifest = manifest; + } + } + catch + { + clonedCapture.Dispose(); + throw; + } + } + + internal ScanDiagnosticExportResult Export() + { + Bitmap capture; + ScanReplayManifest manifest; + lock (_sync) + { + if (_disposed) + return new(false, null, "Diagnostic storage is no longer available."); + if (_capture is null || _manifest is null) + return new(false, null, null); + + capture = new Bitmap(_capture); + manifest = _manifest; + } + + try + { + string directory = Path.Combine( + RatConfig.Paths.Debug, + "ScanDiagnostics", + $"{DateTime.UtcNow:yyyyMMdd-HHmmss-fff}-{Guid.NewGuid():N}" + ); + Directory.CreateDirectory(directory); + capture.Save(Path.Combine(directory, manifest.ImageFile), ImageFormat.Png); + + JsonSerializerSettings serializerSettings = new() + { + ContractResolver = new CamelCasePropertyNamesContractResolver(), + Formatting = Formatting.Indented, + }; + File.WriteAllText( + Path.Combine(directory, "scan.ratdiag.json"), + JsonConvert.SerializeObject(manifest, serializerSettings) + ); + return new(true, directory, null); + } + catch (Exception exception) + { + Logger.LogWarning("Unable to export scan diagnostics.", exception); + return new(false, null, exception.Message); + } + finally + { + capture.Dispose(); + } + } + + public void Dispose() + { + lock (_sync) + { + if (_disposed) + return; + _capture?.Dispose(); + _capture = null; + _manifest = null; + _disposed = true; + } + } +} diff --git a/src/App/i18n/en.json b/src/App/i18n/en.json index a95884e6..8ce63a70 100644 --- a/src/App/i18n/en.json +++ b/src/App/i18n/en.json @@ -235,6 +235,11 @@ "TooltipLong": "Long · 3 seconds", "DiagnosticsSection": "Diagnostics", "DiagnosticsDescription": "Technical logging for troubleshooting. Leave this off during normal use.", + "ExportScanDiagnostics": "Export scan diagnostics", + "ExportScanDiagnosticsDescription": "Saves the most recent scan capture, result, configuration, and stage timings for local replay. Review the screenshot before sharing it.", + "ScanDiagnosticsExported": "Saved scan diagnostics to {0}.", + "NoScanDiagnostics": "Run a name or icon scan before exporting diagnostics.", + "ScanDiagnosticsExportFailed": "RatScanner could not export scan diagnostics: {0}", "NameScanningSection": "Name scanning", "NameScanningDescription": "Recognize item names from inspection windows.", "NameScanLanguageDescription": "Match the language used by item names in Escape from Tarkov.", diff --git a/src/App/i18n/es.json b/src/App/i18n/es.json index a355b3eb..27150302 100644 --- a/src/App/i18n/es.json +++ b/src/App/i18n/es.json @@ -235,6 +235,11 @@ "TooltipLong": "Largo · 3 segundos", "DiagnosticsSection": "Diagnósticos", "DiagnosticsDescription": "Registro técnico para solución de problemas. Mantenga esto desactivado durante el uso normal.", + "ExportScanDiagnostics": "Exportar diagnósticos del escaneo", + "ExportScanDiagnosticsDescription": "Guarda la captura, el resultado, la configuración y los tiempos de la etapa del escaneo más reciente para reproducirlos localmente. Revise la captura antes de compartirla.", + "ScanDiagnosticsExported": "Diagnósticos del escaneo guardados en {0}.", + "NoScanDiagnostics": "Realice un escaneo de nombre o icono antes de exportar diagnósticos.", + "ScanDiagnosticsExportFailed": "RatScanner no pudo exportar los diagnósticos del escaneo: {0}", "NameScanningSection": "Escaneo por nombre", "NameScanningDescription": "Reconoce nombres de objetos desde las ventanas de inspección.", "NameScanLanguageDescription": "Coincida con el idioma utilizado por los nombres de objetos en Escape from Tarkov.", diff --git a/src/App/i18n/fr.json b/src/App/i18n/fr.json index fc3149bb..120576a5 100644 --- a/src/App/i18n/fr.json +++ b/src/App/i18n/fr.json @@ -235,6 +235,11 @@ "TooltipLong": "Long · 3 secondes", "DiagnosticsSection": "Diagnostics", "DiagnosticsDescription": "Journalisation technique pour le dépannage. Laissez cette option désactivée en utilisation normale.", + "ExportScanDiagnostics": "Exporter les diagnostics d'analyse", + "ExportScanDiagnosticsDescription": "Enregistre la capture, le résultat, la configuration et les durées des étapes de l'analyse la plus récente pour une relecture locale. Vérifiez la capture avant de la partager.", + "ScanDiagnosticsExported": "Diagnostics d'analyse enregistrés dans {0}.", + "NoScanDiagnostics": "Effectuez une analyse de nom ou d'icône avant d'exporter les diagnostics.", + "ScanDiagnosticsExportFailed": "RatScanner n'a pas pu exporter les diagnostics d'analyse : {0}", "NameScanningSection": "Scan par nom", "NameScanningDescription": "Reconnaît les noms d'objets depuis les fenêtres d'inspection.", "NameScanLanguageDescription": "Correspond à la langue utilisée par les noms d'objets dans Escape from Tarkov.", diff --git a/src/App/i18n/pl.json b/src/App/i18n/pl.json index 3425c2ce..c0bfb235 100644 --- a/src/App/i18n/pl.json +++ b/src/App/i18n/pl.json @@ -235,6 +235,11 @@ "TooltipLong": "Długie · 3 sekundy", "DiagnosticsSection": "Diagnostyka", "DiagnosticsDescription": "Rejestrowanie techniczne do rozwiązywania problemów. Pozostaw wyłączone podczas normalnego użytkowania.", + "ExportScanDiagnostics": "Eksportuj diagnostykę skanowania", + "ExportScanDiagnosticsDescription": "Zapisuje obraz, wynik, konfigurację i czasy etapów ostatniego skanowania do lokalnego odtworzenia. Sprawdź zrzut przed udostępnieniem.", + "ScanDiagnosticsExported": "Diagnostykę skanowania zapisano w {0}.", + "NoScanDiagnostics": "Wykonaj skanowanie nazwy lub ikony przed eksportem diagnostyki.", + "ScanDiagnosticsExportFailed": "RatScanner nie mógł wyeksportować diagnostyki skanowania: {0}", "NameScanningSection": "Skanowanie po nazwie", "NameScanningDescription": "Rozpoznaje nazwy przedmiotów z okien inspekcji.", "NameScanLanguageDescription": "Dopasuj język używany przez nazwy przedmiotów w Escape from Tarkov.", diff --git a/src/App/i18n/pt.json b/src/App/i18n/pt.json index 002c8cfc..4b551bea 100644 --- a/src/App/i18n/pt.json +++ b/src/App/i18n/pt.json @@ -235,6 +235,11 @@ "TooltipLong": "Longo · 3 segundos", "DiagnosticsSection": "Diagnósticos", "DiagnosticsDescription": "Registro técnico para solução de problemas. Deixe desativado durante o uso normal.", + "ExportScanDiagnostics": "Exportar diagnósticos da verificação", + "ExportScanDiagnosticsDescription": "Salva a captura, o resultado, a configuração e os tempos das etapas da verificação mais recente para reprodução local. Revise a captura antes de compartilhá-la.", + "ScanDiagnosticsExported": "Diagnósticos da verificação salvos em {0}.", + "NoScanDiagnostics": "Execute uma verificação de nome ou ícone antes de exportar os diagnósticos.", + "ScanDiagnosticsExportFailed": "O RatScanner não conseguiu exportar os diagnósticos da verificação: {0}", "NameScanningSection": "Scan por nome", "NameScanningDescription": "Reconhece nomes de itens das janelas de inspeção.", "NameScanLanguageDescription": "Corresponda ao idioma usado pelos nomes de itens em Escape from Tarkov.", diff --git a/src/App/i18n/ru.json b/src/App/i18n/ru.json index 21184364..e4fa01ae 100644 --- a/src/App/i18n/ru.json +++ b/src/App/i18n/ru.json @@ -235,6 +235,11 @@ "TooltipLong": "Длинная · 3 секунды", "DiagnosticsSection": "Диагностика", "DiagnosticsDescription": "Техническое журналирование для устранения неполадок. Оставьте выключенным при обычном использовании.", + "ExportScanDiagnostics": "Экспортировать диагностику сканирования", + "ExportScanDiagnosticsDescription": "Сохраняет снимок, результат, конфигурацию и время этапов последнего сканирования для локального воспроизведения. Проверьте снимок перед отправкой.", + "ScanDiagnosticsExported": "Диагностика сканирования сохранена в {0}.", + "NoScanDiagnostics": "Выполните сканирование имени или значка перед экспортом диагностики.", + "ScanDiagnosticsExportFailed": "RatScanner не удалось экспортировать диагностику сканирования: {0}", "NameScanningSection": "Сканирование по имени", "NameScanningDescription": "Распознаёт названия предметов из окон осмотра.", "NameScanLanguageDescription": "Соответствует языку, используемому для названий предметов в Escape from Tarkov.", diff --git a/src/App/i18n/zh.json b/src/App/i18n/zh.json index 58f0a287..b3bc539e 100644 --- a/src/App/i18n/zh.json +++ b/src/App/i18n/zh.json @@ -235,6 +235,11 @@ "TooltipLong": "长 · 3 秒", "DiagnosticsSection": "诊断", "DiagnosticsDescription": "用于故障排除的技术日志。正常使用时请保持关闭。", + "ExportScanDiagnostics": "导出扫描诊断", + "ExportScanDiagnosticsDescription": "保存最近一次扫描的截图、结果、配置和各阶段耗时,以便在本地重放。分享前请检查截图。", + "ScanDiagnosticsExported": "扫描诊断已保存到 {0}。", + "NoScanDiagnostics": "请先执行名称或图标扫描,再导出诊断。", + "ScanDiagnosticsExportFailed": "RatScanner 无法导出扫描诊断:{0}", "NameScanningSection": "名称扫描", "NameScanningDescription": "从检查窗口识别物品名称。", "NameScanLanguageDescription": "匹配 Escape from Tarkov 中物品名称使用的语言。", diff --git a/src/ScanEngine b/src/ScanEngine new file mode 160000 index 00000000..4f72caee --- /dev/null +++ b/src/ScanEngine @@ -0,0 +1 @@ +Subproject commit 4f72caee3fd98af7bf5853ebcbe7c065d0de01bb diff --git a/src/ScanEngine/AGENTS.md b/src/ScanEngine/AGENTS.md deleted file mode 100644 index 059294a8..00000000 --- a/src/ScanEngine/AGENTS.md +++ /dev/null @@ -1,31 +0,0 @@ -# ScanEngine (`src/ScanEngine`) — scoped agent instructions - -Read with root `AGENTS.md`, `VENDOR.md`, and `docs/agent-context/scan-engine.md` before material processing changes. - -## Scope - -Image processing / OCR / icon matching library used by the App. Folder name is ScanEngine; **assembly and namespaces remain `RatEye`**. - -## Mandatory - -1. **No NuGet reintroduction** of RatEye. Consumers use ProjectReference only. -2. **Do not pack/publish** this project as a NuGet package from this monorepo (`IsPackable` / `GeneratePackageOnBuild` stay false). -3. Keep the engine **free of** WPF, Blazor, HTTP API clients, and app settings persistence. -4. Treat `Config` as immutable after engine construction (existing API contract). -5. Dispose paths must continue releasing Tesseract engines, markers, and IconManager resources. -6. Align **RatStash major** with the App when changing that dependency. -7. Keep OpenCvSharp managed/native package versions paired (Extensions + Windows). -8. The current Windows native runtime is x64-only; do not claim or add x86 support without a compatible runtime and validation. -9. Preserve provenance notes in `VENDOR.md` when vendoring facts change; do not invent license claims. -10. Implementation overrides this file; update it when engine-scoped rules change. - -## Prefer - -- Minimal diffs in Processing/Config for accuracy fixes. -- Log through existing engine logger patterns. -- Let App own capture geometry and tarkov.dev item projection. -- Fixture-oriented pure helpers for new regression surface when practical (upstream binary fixtures are not vendored). - -## Validate - -Build the full solution (App must compile against engine). Run `dotnet test` when App tests cover related contracts. Manual scan smoke for accuracy-sensitive changes. See `docs/agent-context/build-and-validation.md`. diff --git a/src/ScanEngine/Config/Config.cs b/src/ScanEngine/Config/Config.cs deleted file mode 100644 index 37dc97e1..00000000 --- a/src/ScanEngine/Config/Config.cs +++ /dev/null @@ -1,52 +0,0 @@ -using RatStash; - -namespace RatEye -{ - /// - /// Top level config class which includes members for processing, file paths, etc. - /// - public partial class Config - { - /// - /// Log debug data - /// - public static bool LogDebug = false; - - /// - /// Path configuration object - /// - public Path PathConfig = new Path(); - - /// - /// Processing configuration object - /// - public Processing ProcessingConfig = new Processing(); - - /// - /// Icon manager - /// - /// - /// Initialized in - /// - internal IconManager IconManager; - - /// - /// Item database - /// - /// - /// Initialized in - /// - internal Database RatStashDB; - - /// - /// Create a new config instance - /// - public Config() { } - - internal string GetHash() - { - var components = new string[] { LogDebug.ToString(), PathConfig.GetHash(), ProcessingConfig.GetHash() }; - return string.Join("<#sep#>", components).SHA256Hash(); - } - } -} diff --git a/src/ScanEngine/Config/Path.cs b/src/ScanEngine/Config/Path.cs deleted file mode 100644 index c322fc49..00000000 --- a/src/ScanEngine/Config/Path.cs +++ /dev/null @@ -1,139 +0,0 @@ -using System; -using Tesseract; - -namespace RatEye -{ - public partial class Config - { - /// - /// The Path class contains all paths used by library like the output - /// path for log files, folders containing icons for pattern matching etc. - /// - public class Path - { - /// - /// BaseDir directory, which is used to create most other paths from. - /// Defaults to the base directory of the current app domain. - /// - private static string BaseDir => AppDomain.CurrentDomain.BaseDirectory; - - /// - /// DataDir directory, which is used to create some data related paths. - /// Defaults to %BaseDir%/Data - /// - private static string DataDir => Combine(BaseDir, "Data"); - - /// - /// Temporary directory path - /// - private static string TempDir => Combine(System.IO.Path.GetTempPath(), "RatEye"); - - /// - /// Path of the cache folder - /// - /// - /// Only used when is - /// - public string CacheDir => Combine(TempDir, "Cache"); - - /// - /// Path of the folder containing static icons - /// The icons must have their item-id as file name and be of the .png format - /// For example the correct file name for the RAM item icon would be: - /// - /// 57347baf24597738002c6178.png - /// - /// - public string StaticIcons = Combine(DataDir, "name"); - - /// - /// Path of the folder containing dynamic icons - /// - public string DynamicIcons = Combine(GetEfTTempPath(), "Icon Cache"); - - /// - /// Path of the file containing correlation data for icons and uid's. - /// The file must be a json file and be able to be parsed by . - /// - public string DynamicCorrelationData = Combine(GetEfTTempPath(), "Icon Cache", "index.json"); - - /// - /// Path of the icon to return when no icon matches the query - /// - public string UnknownIcon = Combine(DataDir, "unknown.png"); - - /// - /// Path to the folder containing LSTM model files - /// - /// Files have to be of the format [ISO-639-3].traineddata. - public string TrainedData = Combine(DataDir, "traineddata"); - - /// - /// Path at which tesseract searches for dependencies - /// - public static string TesseractLibSearchPath - { - get => TesseractEnviornment.CustomSearchPath; - set => TesseractEnviornment.CustomSearchPath = value; - } - - /// - /// Path of the debug folder which is used to store debug information - /// - public static string Debug = Combine(BaseDir, "Debug"); - - /// - /// Path of the log file - /// - public static string LogFile = Combine(BaseDir, "Log.txt"); - - /// - /// Create a new path config instance - /// - public Path() { } - - /// - /// Combine two paths - /// - /// BaseDir path - /// Path to be added - /// Path to be added - /// Path to be added - /// The combined path - private static string Combine(string basePath, string a, string b = "", string c = "") - { - return System.IO.Path.Combine(basePath, a, b, c); - } - - /// - /// Get the directory used by eft for temporary files like the icon cache - /// - /// The directory used by eft for temporary files - private static string GetEfTTempPath() - { - var eftTempDir = "Battlestate Games\\EscapeFromTarkov\\"; - return Combine(System.IO.Path.GetTempPath(), eftTempDir); - } - - internal string GetHash() - { - var components = new string[] - { - BaseDir, - DataDir, - TempDir, - CacheDir, - StaticIcons, - DynamicIcons, - DynamicCorrelationData, - UnknownIcon, - TrainedData, - TesseractLibSearchPath, - Debug, - LogFile, - }; - return string.Join("<#sep#>", components).SHA256Hash(); - } - } - } -} diff --git a/src/ScanEngine/Config/Processing.cs b/src/ScanEngine/Config/Processing.cs deleted file mode 100644 index f577fd51..00000000 --- a/src/ScanEngine/Config/Processing.cs +++ /dev/null @@ -1,97 +0,0 @@ -using System; -using RatStash; - -namespace RatEye -{ - public partial class Config - { - /// - /// The Processing class contains parameters, which - /// are shared amongst multiple processing types. - /// - public partial class Processing - { - /// - /// Use a persistent cache for objects which can be reused - /// - public bool UseCache = true; - - /// - /// The language to use when processing - /// - public Language Language = Language.English; - - /// - /// Scale of the image. 1f when the image is from a 1080p screen, 2f when 4k, ... - /// Use to compute the scale. - /// - public float Scale = 1; - - /// - /// Inverse scale of the image. 1f when the image is from a 1080p screen, 0.5f when 4k, ... - /// => 1f / - /// - internal float InverseScale => 1f / Scale; - - /// - /// The size of a single slot on 1080p resolution, measured in pixel - /// - public float BaseSlotSize = 63; - - /// - /// Slot size of a single slot in pixels, considering for scaling - /// Scale * BaseSlotSize - /// - internal float ScaledSlotSize => Scale * BaseSlotSize; - - /// - /// Icon configuration object - /// - public Icon IconConfig = new(); - - /// - /// Inspection configuration object - /// - public Inspection InspectionConfig = new(); - - /// - /// Inventory configuration object - /// - public Inventory InventoryConfig = new(); - - /// - /// Create a new processing config - /// - public Processing() { } - - /// - /// Convert a screen resolution to the corresponding scale - /// - /// - /// - /// The scale calculated from the screen resolution - public static float Resolution2Scale(int width, int height) - { - var screenScaleFactor1 = width / 1920f; - var screenScaleFactor2 = height / 1080f; - - return Math.Min(screenScaleFactor1, screenScaleFactor2); - } - - internal string GetHash() - { - var components = new string[] - { - UseCache.ToString(), - Language.ToString(), - Scale.ToString(), - BaseSlotSize.ToString(), - IconConfig.GetHash(), - InspectionConfig.GetHash(), - InventoryConfig.GetHash(), - }; - return string.Join("<#sep#>", components).SHA256Hash(); - } - } - } -} diff --git a/src/ScanEngine/Config/Processing/Icon.cs b/src/ScanEngine/Config/Processing/Icon.cs deleted file mode 100644 index 42318f46..00000000 --- a/src/ScanEngine/Config/Processing/Icon.cs +++ /dev/null @@ -1,68 +0,0 @@ -using Tesseract; - -namespace RatEye -{ - public partial class Config - { - public partial class Processing - { - /// - /// The Icon class contains parameters, used by the icon processing module. - /// - public class Icon - { - /// - /// Different kinds of scan modes which can be used to scan an icon - /// - public enum ScanModes - { - /// - /// Scan the icon by matching it against a template image of every icon - /// - TemplateMatching, - - /// - /// Scan the icon by recognizing the short name in the top right corner - /// - OCR, - } - - /// - /// The scanning mode used for icon scanning - /// - public ScanModes ScanMode = Icon.ScanModes.TemplateMatching; - - /// - /// Use the static icons for template matching - /// - public bool UseStaticIcons = false; - - /// - /// Scan for 90° rotated icons - /// - public bool ScanRotatedIcons = true; - - /// - /// Tesseract Engine instance used and set by - /// - internal TesseractEngine TesseractEngine; - - /// - /// Create a new icon config instance - /// - public Icon() { } - - internal string GetHash() - { - var components = new string[] - { - ScanMode.ToString(), - UseStaticIcons.ToString(), - ScanRotatedIcons.ToString(), - }; - return string.Join("<#sep#>", components).SHA256Hash(); - } - } - } - } -} diff --git a/src/ScanEngine/Config/Processing/Inspection.cs b/src/ScanEngine/Config/Processing/Inspection.cs deleted file mode 100644 index 47f75c1d..00000000 --- a/src/ScanEngine/Config/Processing/Inspection.cs +++ /dev/null @@ -1,128 +0,0 @@ -using System.Drawing; -using System.IO; -using RatEye.Properties; -using Tesseract; - -namespace RatEye -{ - public partial class Config - { - public partial class Processing - { - /// - /// The Inspection class contains parameters, used by the inspection processing module - /// - public class Inspection - { - /// - /// Marker bitmap to identify regions of interest. This should be a cropped image of the magnifier icon - /// - public Bitmap Marker = LoadMarker(); - - /// - /// Detection threshold of the marker bitmap - /// - public float MarkerThreshold = 0.82f; - - /// - /// Minimum OCR-to-item-name similarity required to accept a match. - /// - /// Prevents weak fuzzy matches from inventory chrome (for example the - /// "Subject Search" bar, whose magnifier resembles the inspect marker) - /// from being reported as items. - /// - /// - public float MinItemConfidence = 0.55f; - - /// - /// The scale of the marker used by item inspection windows - /// - public float MarkerItemScale = 16f / 28f; - - /// - /// The background color used for the marker if it uses a alpha channel - /// - public Color MarkerBackgroundColor = Color.FromArgb(25, 27, 27); - - /// - /// Unscaled width of the box which will be searched for the title - /// - public int BaseTitleSearchWidth = 500; - - /// - /// Unscaled height of the box which will be searched for the title - /// - public int BaseTitleSearchHeight = 17; - - /// - /// Right padding of the title search box, used when the close button got detected - /// - /// This helps to ignore extra buttons like the sort buttons in some container inspection windows. - /// - /// See and . - /// - public int BaseTitleSearchRightPadding = 64; - - /// - /// The horizontal offset factor of the box which will be searched for the title - /// - /// The factor is applied to the scaled width of the . - /// The horizontal offset is originating from the left most edge of the detected marker. - /// searchBox.left = detectedMarker.left + (Marker.width * Scale) - /// - /// - public float HorizontalTitleSearchOffsetFactor = 1.2f; - - /// - /// Lower bound color to match the close button which is positioned at the top right of inspection windows - /// - public Color CloseButtonColorLowerBound = Color.FromArgb(50, 10, 10); - - /// - /// Upper bound color to match the close button which is positioned at the top right of inspection windows - /// - public Color CloseButtonColorUpperBound = Color.FromArgb(80, 15, 15); - - /// - /// Tesseract Engine instance used and set by - /// - internal TesseractEngine TesseractEngine; - - /// - /// Create a new inspection config instance - /// - public Inspection() { } - - private static Bitmap LoadMarker() - { - using var stream = new MemoryStream(Resources.icon_search); - using var marker = new Bitmap(stream); - return new Bitmap(marker); - } - - internal void EnsureMarker() - { - Marker ??= LoadMarker(); - } - - internal string GetHash() - { - var components = new string[] - { - MarkerThreshold.ToString(), - MinItemConfidence.ToString(), - MarkerItemScale.ToString(), - MarkerBackgroundColor.ToString(), - BaseTitleSearchWidth.ToString(), - BaseTitleSearchHeight.ToString(), - BaseTitleSearchRightPadding.ToString(), - HorizontalTitleSearchOffsetFactor.ToString(), - CloseButtonColorLowerBound.ToString(), - CloseButtonColorUpperBound.ToString(), - }; - return string.Join("<#sep#>", components).SHA256Hash(); - } - } - } - } -} diff --git a/src/ScanEngine/Config/Processing/Inventory.cs b/src/ScanEngine/Config/Processing/Inventory.cs deleted file mode 100644 index f8a52179..00000000 --- a/src/ScanEngine/Config/Processing/Inventory.cs +++ /dev/null @@ -1,72 +0,0 @@ -using OpenCvSharp; - -namespace RatEye -{ - public partial class Config - { - public partial class Processing - { - /// - /// The Inventory class contains parameters, used by the inventory processing module. - /// - public class Inventory - { - /// - /// Color of the grid as defined by EFT - /// - internal Scalar GridColor => new(84, 81, 73, 255); - - /// - /// Alpha value of item background colors as defined by EFT - /// - internal int BackgroundAlpha => 77; - - /// - /// Minimum color for thresholding the grid - /// - public (int hue, int saturation, int value) MinGridColor = (100, 15, 63); - - /// - /// Maximum color for thresholding the grid - /// - public (int hue, int saturation, int value) MaxGridColor = (146, 46, 96); - - /// - /// Minimum color for thresholding the highlighting background of an item - /// - public (int hue, int saturation, int value) MinHighlightingColor = (0, 0, 80); - - /// - /// Maximum color for thresholding the highlighting background of an item. - /// EFT 1.0.6 brightened the hover highlight to roughly value 109-112. - /// - public (int hue, int saturation, int value) MaxHighlightingColor = (255, 3, 120); - - /// - /// If , all processing will be optimized for highlighted items - /// - public bool OptimizeHighlighted = false; - - /// - /// Create a new inventory config instance - /// - public Inventory() { } - - internal string GetHash() - { - var components = new string[] - { - GridColor.ToString(), - BackgroundAlpha.ToString(), - MinGridColor.ToString(), - MaxGridColor.ToString(), - MinHighlightingColor.ToString(), - MaxHighlightingColor.ToString(), - OptimizeHighlighted.ToString(), - }; - return string.Join("<#sep#>", components).SHA256Hash(); - } - } - } - } -} diff --git a/src/ScanEngine/Extensions.cs b/src/ScanEngine/Extensions.cs deleted file mode 100644 index cead0071..00000000 --- a/src/ScanEngine/Extensions.cs +++ /dev/null @@ -1,387 +0,0 @@ -using System; -using System.Drawing; -using System.Drawing.Imaging; -using System.Linq; -using System.Security.Cryptography; -using System.Text; -using OpenCvSharp; -using OpenCvSharp.Extensions; -using Color = System.Drawing.Color; -using Point = System.Drawing.Point; -using Size = OpenCvSharp.Size; - -namespace RatEye -{ - /// - /// Extension methods for OpenCvSharp - /// - public static class Extensions - { - #region Bitmap Extensions - - /// - /// Rescales a bitmap - /// - /// The image to be rescaled - /// Scale factor by which the dimensions will be multiplied - /// The rescaled input image - /// - /// Uses for downscaling and for upscaling - /// - public static Bitmap Rescale(this Bitmap image, float scale) - { - if (scale == 1f) - return image; - - using var mat = image.ToMat(); - var rescaledSize = new Size((int)(mat.Width * scale), (int)(mat.Height * scale)); - var rescaleMode = scale < 1 ? InterpolationFlags.Area : InterpolationFlags.Cubic; - Cv2.Resize(mat, mat, rescaledSize, 0, 0, rescaleMode); - var rescaledImage = mat.ToBitmap(); - return rescaledImage; - } - - /// - /// Rotates a bitmap - /// - /// The image to be rotated - /// The rotated input image - public static Bitmap Rotate(this Bitmap image) - { - using var mat = image.ToMat(); - Cv2.Rotate(mat, mat, RotateFlags.Rotate90Counterclockwise); - return mat.ToBitmap(); - } - - /// - /// Alpha blends the input image with a target color - /// - /// The input image - /// The color which will replace the transparency - /// The input image with replaced alpha in 24bppRgb format - public static Bitmap TransparentToColor(this Bitmap image, Color target) - { - var output = new Bitmap(image.Width, image.Height, PixelFormat.Format24bppRgb); - var rect = new Rectangle(Point.Empty, image.Size); - using var g = Graphics.FromImage(output); - g.Clear(target); - g.DrawImageUnscaledAndClipped(image, rect); - return output; - } - - /// - /// Crops the input image - /// - /// The input image - /// Horizontal position of the lower left corner - /// Vertical position of the lower left corner - /// Width of the output image - /// Height of the output image - /// Ignore if the given parameters go out of the image bounds and crop anyways - /// The cropped input image - /// x, y, width and height accept non negative values only - /// The input image is not big enough for the desired crop - public static Bitmap Crop(this Bitmap image, int x, int y, int width, int height, bool ignoreBounds = true) - { - if (width <= 0) - throw new ArgumentOutOfRangeException(nameof(width), "Crop width must be positive."); - if (height <= 0) - throw new ArgumentOutOfRangeException(nameof(height), "Crop height must be positive."); - - var rect = new Rectangle(x, y, width, height); - if (ignoreBounds) - { - rect = Rectangle.Intersect(rect, new Rectangle(Point.Empty, image.Size)); - if (rect.Width == 0 || rect.Height == 0) - throw new ArgumentOutOfRangeException(nameof(image), "The crop does not intersect the image."); - } - else - { - if (x < 0 || y < 0 || width < 0 || height < 0) - { - const string message = "x, y, width and height accept non negative values only."; - throw new ArgumentException(message); - } - - if (x + width > image.Width || y + height > image.Height) - { - const string message = "The input image is not big enough for the desired crop"; - throw new ArgumentOutOfRangeException(nameof(image), message); - } - } - - return image.Clone(rect, image.PixelFormat); - } - - /// - /// Finds the horizontal position of the first pixel, on a given height and in a color range. - /// Pixels are iterated from left to right. - /// - /// The input image - /// The height on which to search for a matching pixel - /// The lower bound matching color - /// The upper bound matching color - /// Start position for searching - /// The horizontal position of the first matching pixel. -1 if no match was found - internal static int FindPixelInRange( - this Bitmap image, - int searchHeight, - Color lowerBoundColor, - Color upperBoundColor, - int start = 0 - ) - { - using var mat = image.ToMat(); - using var mat3 = new Mat(mat); - var indexer = mat3.GetIndexer(); - // Cache Width before the loop — property access is a P/Invoke each time (OCVS002). - int width = mat.Width; - - for (var x = start; x < width; x++) - { - var (blue, green, red) = indexer[searchHeight, x]; - - if (blue < lowerBoundColor.B || blue > upperBoundColor.B) - continue; - if (green < lowerBoundColor.G || green > upperBoundColor.G) - continue; - if (red < lowerBoundColor.R || red > upperBoundColor.R) - continue; - - return x; - } - - return -1; - } - - #endregion - - #region Mat Extensions - - /// - /// Alpha blend two 8UC4 matrices - /// - /// Top matrix of type 8UC4 - /// Bottom matrix of type 8UC4 - /// A blended matrix of type 8UC4 - internal static Mat AlphaBlend(this Mat a, Mat b) - { - // Extract a alpha - using var aAlpha = a.ExtractChannel(3); - aAlpha.ConvertTo(aAlpha, MatType.CV_32FC1, 1 / 255f); - - // Extract b alpha - using var bAlpha = b.ExtractChannel(3); - bAlpha.ConvertTo(bAlpha, MatType.CV_32FC1, 1 / 255f); - - using var ones = new Mat(aAlpha.Size(), MatType.CV_32FC1, Scalar.All(1)); - using var invBottomAlpha = new Mat(); - Cv2.Subtract(ones, aAlpha, invBottomAlpha); - - using var aColor = a.RemoveChannel4(); - aColor.ConvertTo(aColor, MatType.CV_32FC3, 1 / 255f); - - using var bColor = b.RemoveChannel4(); - bColor.ConvertTo(bColor, MatType.CV_32FC3, 1 / 255f); - - using var weightedBottomAlpha = new Mat(); - Cv2.Multiply(invBottomAlpha, bAlpha, weightedBottomAlpha); - using var alpha = new Mat(); - Cv2.Add(aAlpha, weightedBottomAlpha, alpha); - - using var tmp = weightedBottomAlpha.CvtColor(ColorConversionCodes.GRAY2BGR); - Cv2.Multiply(tmp, bColor, tmp); - - using var alpha3C = alpha.CvtColor(ColorConversionCodes.GRAY2BGR); - - using var result = aAlpha.CvtColor(ColorConversionCodes.GRAY2BGR); - Cv2.Multiply(result, aColor, result); - Cv2.Add(result, tmp, result); - Cv2.Divide(result, alpha3C, result); - result.ConvertTo(result, MatType.CV_8UC3, 255); - - var output = new Mat(a.Size(), MatType.CV_8UC4); - using var blue = result.ExtractChannel(0); - using var green = result.ExtractChannel(1); - using var red = result.ExtractChannel(2); - blue.InsertChannel(output, 0); - green.InsertChannel(output, 1); - red.InsertChannel(output, 2); - alpha.ConvertTo(alpha, MatType.CV_8UC1, 255); - using var outputAlpha = alpha.ExtractChannel(0); - outputAlpha.InsertChannel(output, 3); - return output; - } - - /// - /// Remove the 4th channel of a matrix - /// - /// Source matrix - /// Matrix with truncated channels - internal static Mat RemoveChannel4(this Mat src) - { - var type = src.Type() == MatType.CV_8UC4 ? MatType.CV_8UC3 : MatType.CV_32FC3; - var output = new Mat(src.Size(), type); - using var blue = src.ExtractChannel(0); - using var green = src.ExtractChannel(1); - using var red = src.ExtractChannel(2); - blue.InsertChannel(output, 0); - green.InsertChannel(output, 1); - red.InsertChannel(output, 2); - return output; - } - - /// - /// Remove all transparency from a 8UC4 matrix - /// - /// Source matrix - /// Matrix of type 8UC4 - internal static Mat RemoveTransparency(this Mat src) - { - var output = new Mat(src.Size(), MatType.CV_8UC4); - using var clear = new Mat(src.Size(), MatType.CV_8UC4).SetTo(new Scalar(1, 1, 1, 0)); - using var full = new Mat(src.Size(), MatType.CV_8UC4).SetTo(new Scalar(0, 0, 0, 255)); - Cv2.Multiply(src, clear, output); - Cv2.Add(output, full, output); - return output; - } - - /// - /// Replicates the input matrix the specified number of times in the horizontal and/or vertical direction - /// - /// Source matrix - /// How many times the src is repeated along the horizontal axis - /// How many times the src is repeated along the vertical axis - /// Horizontal left-padding - /// Vertical top-padding - /// The repeated matrix - internal static Mat Repeat(this Mat src, int nx, int ny, int dx, int dy) - { - var dst = new Mat((src.Rows + dy) * ny - dy, (src.Cols + dx) * nx - dx, src.Type()); - for (var iy = 0; iy < ny; ++iy) - { - for (var ix = 0; ix < nx; ++ix) - { - var roi = new Rect((src.Cols + dx) * ix, (src.Rows + dy) * iy, src.Cols, src.Rows); - src.CopyTo(dst[roi]); - } - } - - return dst; - } - - /// - /// Add padding to a mat - /// - /// Source matrix - /// Amount of left padding - /// Amount of top padding - /// Amount of right padding - /// Amount of bottom padding - /// New matrix of same type with padding - internal static Mat AddPadding(this Mat src, int left, int top, int right, int bottom) - { - var resultSize = new Size(src.Width + left + right, src.Height + top + bottom); - var result = new Mat(resultSize, src.Type()).SetTo(new Scalar(0, 0, 0, 0)); - src.CopyTo(result[top, top + src.Rows, left, left + src.Cols]); - return result; - } - - /// - /// Remove padding to a mat - /// - /// Source matrix - /// Amount of left padding - /// Amount of top padding - /// Amount of right padding - /// Amount of bottom padding - /// New matrix of same type with removed padding - internal static Mat RemovePadding(this Mat src, int left, int top, int right, int bottom) - { - var resultSize = new Size(src.Width - left - right, src.Height - top - bottom); - var result = new Mat(resultSize, src.Type()).SetTo(new Scalar(0, 0, 0, 0)); - src[top, src.Rows - bottom, left, src.Cols - right].CopyTo(result); - return result; - } - - #endregion - - #region String Extensions - /// - /// Calculates the normed Levenshtein distance between two strings - /// - /// Source string - /// Target string - /// Levenshtein distance - public static float NormedLevenshteinDistance(this string source, string target) - { - if (string.IsNullOrEmpty(source) || string.IsNullOrEmpty(target)) - return 0; - - if (source.Length > target.Length) - (source, target) = (target, source); - - var m = target.Length; - var n = source.Length; - var distance = new int[2, m + 1]; - // Initialize the distance matrix - for (var j = 1; j <= m; j++) - distance[0, j] = j; - - var currentRow = 0; - for (var i = 1; i <= n; ++i) - { - currentRow = i & 1; - distance[currentRow, 0] = i; - var previousRow = currentRow ^ 1; - for (var j = 1; j <= m; j++) - { - var cost = (target[j - 1] == source[i - 1] ? 0 : 1); - distance[currentRow, j] = Math.Min( - Math.Min(distance[previousRow, j] + 1, distance[currentRow, j - 1] + 1), - distance[previousRow, j - 1] + cost - ); - } - } - - return (float)(target.Length - distance[currentRow, m]) / target.Length; - } - - /// - /// Computes the SHA256 hash of the string - /// - /// String to be hashed - /// Hash as hex string - public static string SHA256Hash(this string value) - { - var buffer = Encoding.UTF8.GetBytes(value); - using var sha256 = SHA256.Create(); - var hash = sha256.ComputeHash(buffer); - return string.Concat(hash.Select(x => x.ToString("X2"))); - } - - /// - /// Computes key which can be used in the cache which is unique given the config - /// - /// String which will influence the key - /// Config which will influence the key - /// Cache key - internal static string CacheKey(this string value, Config config) - { - return value.CacheKey(config.GetHash()); - } - - /// - /// Computes key which can be used in the cache which is unique given the config - /// - /// String which will influence the key - /// Hash of the config which will influence the key - /// Cache key - internal static string CacheKey(this string value, string configHash) - { - return (value + configHash).SHA256Hash(); - } - - #endregion - } -} diff --git a/src/ScanEngine/IconManager.cs b/src/ScanEngine/IconManager.cs deleted file mode 100644 index caef95fe..00000000 --- a/src/ScanEngine/IconManager.cs +++ /dev/null @@ -1,862 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Drawing; -using System.IO; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using OpenCvSharp; -using OpenCvSharp.Extensions; -using RatEye.Properties; -using RatStash; -using Color = RatStash.Color; - -namespace RatEye -{ - internal class IconManager : IDisposable - { - private const long MaxCacheBytes = 256L * 1024 * 1024; - private const int MaxCacheFiles = 10_000; - private static readonly TimeSpan MaxCacheAge = TimeSpan.FromDays(30); - private static readonly TimeSpan MaxTemporaryCacheFileAge = TimeSpan.FromDays(1); - - private enum IconType - { - Static, - Dynamic, - } - - private readonly Config _config; - private readonly string _cacheDirectory; - - /// - /// Static icons are those which are rendered ahead of time. - /// For example keys, medical supply's, containers, standalone mods, - /// and especially items like screws, drill, wires, milk and so on. - /// - /// Dictionary<slotSize, Dictionary<iconKey, icon>> - /// - /// - /// Use the when accessing this collection. - /// Icon is of type 8UC3. - /// - internal Dictionary> StaticIcons = new(); - - /// - /// Dynamic icons are those which need to be rendered at runtime - /// due to the items appearance being altered by attached items. - /// For example weapons are considered dynamic items since their - /// icon changes when you add rails, magazines, scopes and so on. - /// - /// ConcurrentDictionary<slotSize, Dictionary<iconKey, icon>> - /// - /// - /// Use the when accessing this collection. - /// Icon is of type 8UC3. - /// - internal Dictionary> DynamicIcons = new(); - - /// - /// Reader / Writer lock of - /// - internal readonly ReaderWriterLockSlim StaticIconsLock = new(); - - /// - /// Reader / Writer lock of - /// - internal readonly ReaderWriterLockSlim DynamicIconsLock = new(); - - /// - /// The icon paths connected to each icon key - /// ConcurrentDictionary<iconKey, iconPath> - /// - private readonly Dictionary _iconPaths = new(); - - /// - /// The data used to match icon keys of static icons to their item - /// - /// Dictionary<iconKey, item> - /// - private Dictionary _staticCorrelationData = new(); - - /// - /// The data used to match icon keys of dynamic icons to their item - /// - /// Dictionary<iconKey, item> - /// - private readonly Dictionary _dynamicCorrelationData = new(); - - /// - /// Reader / Writer lock of - /// - private readonly ReaderWriterLockSlim _staticCorrelationDataLock = new(); - - /// - /// Reader / Writer lock of - /// - private readonly ReaderWriterLockSlim _dynamicCorrelationDataLock = new(); - private readonly object _staticIconLoadLock = new(); - private readonly HashSet _loadedStaticIconSizes = new(); - private bool _disposed; - - /// - /// Constructor for icon manager object - /// - /// The config to use for this instance> - /// Depends on and - internal IconManager(Config config) - : this(config, config.PathConfig.CacheDir) { } - - internal IconManager(Config config, string cacheDirectory) - { - _config = config; - _cacheDirectory = cacheDirectory; - - if (_config.ProcessingConfig.UseCache) - { - Directory.CreateDirectory(_cacheDirectory); - PruneCacheBestEffort(); - } - - var iconConfig = _config.ProcessingConfig.IconConfig; - if (iconConfig.UseStaticIcons) - { - if (Directory.Exists(_config.PathConfig.StaticIcons)) - { - LoadStaticCorrelationData(); - } - else - { - Logger.LogDebug( - "Static icon folder is missing; name scanning remains available but icon matching is disabled." - ); - } - } - } - - #region Icon loading - - internal void EnsureStaticIconsLoaded(Vector2 slotSize) - { - lock (_staticIconLoadLock) - { - if (_loadedStaticIconSizes.Contains(slotSize)) - return; - - Dictionary> newIcons; - try - { - newIcons = LoadNewIcons(_config.PathConfig.StaticIcons, IconType.Static, slotSize); - } - catch (Exception e) when (e is DirectoryNotFoundException or FileNotFoundException) - { - // Missing Data/icons is a recoverable packaging issue; keep scanning alive. - Logger.LogDebug( - "Static icon folder is missing; icon matching for this slot size will stay empty until data is installed.", - e - ); - return; - } - - StaticIconsLock.EnterWriteLock(); - try - { - foreach (var icons in newIcons) - { - if (!StaticIcons.ContainsKey(icons.Key)) - StaticIcons.Add(icons.Key, new Dictionary()); - foreach (var icon in icons.Value) - StaticIcons[icons.Key].Add(icon.Key, icon.Value); - } - - _loadedStaticIconSizes.Add(slotSize); - } - finally - { - StaticIconsLock.ExitWriteLock(); - } - } - } - - private Dictionary> LoadNewIcons( - string folderPath, - IconType iconType, - Vector2 slotSizeFilter = null - ) - { - if (!Directory.Exists(folderPath)) - { - var message = "Could not find icon folder at: " + folderPath; - throw new FileNotFoundException(message); - } - - var loadedIcons = new Dictionary>(); - try - { - var iconPathArray = Directory.GetFiles(folderPath, "*.png"); - if (iconType == IconType.Static) - { - _staticCorrelationDataLock.EnterReadLock(); - StaticIconsLock.EnterReadLock(); - } - else if (iconType == IconType.Dynamic) - { - _dynamicCorrelationDataLock.EnterReadLock(); - DynamicIconsLock.EnterReadLock(); - } - try - { - var configHash = GetConfigHash(); - - Parallel.ForEach( - iconPathArray, - iconPath => - { - Mat icon = null; - try - { - var iconKey = GetIconKey(iconPath, iconType); - - var item = GetItemUnsafe(iconKey); - if (item == null) - return; - if (slotSizeFilter != null && new Vector2(item.GetSlotSize()) != slotSizeFilter) - return; - - // Skip existing icons - if (iconType == IconType.Static && StaticIcons.Any(x => x.Value.ContainsKey(iconKey))) - return; - if (iconType == IconType.Dynamic && DynamicIcons.Any(x => x.Value.ContainsKey(iconKey))) - return; - - var useCache = _config.ProcessingConfig.UseCache; - var cacheIconPath = Path.Combine( - _cacheDirectory, - $"{iconKey.CacheKey(configHash)}.bmp" - ); - icon = useCache ? TryLoadCachedIcon(cacheIconPath) : null; - var cacheHit = icon != null; - - if (!cacheHit) - { - using var mat = Cv2.ImRead(iconPath, ImreadModes.Unchanged); - if (mat.Empty()) - throw new InvalidDataException("The icon image is empty or unreadable."); - icon = GetIconWithBackground(mat, item); - } - - // Do not add the icon to the list, if its size cannot be converted to slots - if (!IsValidPixelSize(icon.Width) || !IsValidPixelSize(icon.Height)) - return; - - // Add the icon to the cache if caching is enabled and doesn't already contain it - if (useCache && !cacheHit) - SaveCacheIconAtomic(icon, cacheIconPath); - - var size = new Vector2(PixelsToSlots(icon.Width), PixelsToSlots(icon.Height)); - lock (loadedIcons) - { - if (!loadedIcons.ContainsKey(size)) - loadedIcons.Add(size, new Dictionary()); - - // Ownership of the Mat transfers to the returned collection. - loadedIcons[size][iconKey] = icon; - _iconPaths[iconKey] = cacheHit ? cacheIconPath : iconPath; - icon = null; - } - } - catch (Exception e) when (IsRecoverableIconLoadException(e)) - { - Logger.LogDebug("Could not load icon: " + iconPath, e); - } - finally - { - icon?.Dispose(); - } - } - ); - } - finally - { - if (iconType == IconType.Static) - { - _staticCorrelationDataLock.ExitReadLock(); - StaticIconsLock.ExitReadLock(); - } - else if (iconType == IconType.Dynamic) - { - _dynamicCorrelationDataLock.ExitReadLock(); - DynamicIconsLock.ExitReadLock(); - } - } - } - catch - { - foreach (Mat icon in loadedIcons.Values.SelectMany(group => group.Values)) - icon.Dispose(); - throw; - } - - if (_config.ProcessingConfig.UseCache) - PruneCacheBestEffort(); - - return loadedIcons; - } - - private static bool IsRecoverableIconLoadException(Exception exception) => - IsRecoverableFileSystemException(exception) - || exception is ArgumentException or OpenCVException or OpenCvSharpException; - - private static bool IsRecoverableFileSystemException(Exception exception) => - exception - is IOException - or InvalidDataException - or UnauthorizedAccessException - or NotSupportedException - or System.Security.SecurityException; - - private Mat TryLoadCachedIcon(string cacheIconPath) - { - if (!File.Exists(cacheIconPath)) - return null; - - Mat icon = null; - try - { - icon = Cv2.ImRead(cacheIconPath, ImreadModes.Unchanged); - var valid = - !icon.Empty() - && icon.Type() == MatType.CV_8UC3 - && IsValidPixelSize(icon.Width) - && IsValidPixelSize(icon.Height); - if (valid) - return icon; - - icon.Dispose(); - icon = null; - DeleteCacheFileBestEffort(cacheIconPath); - Logger.LogDebug("Regenerating invalid cached icon: " + cacheIconPath); - return null; - } - catch (Exception e) when (IsRecoverableIconLoadException(e)) - { - icon?.Dispose(); - DeleteCacheFileBestEffort(cacheIconPath); - Logger.LogDebug("Regenerating unreadable cached icon: " + cacheIconPath, e); - return null; - } - } - - private static void SaveCacheIconAtomic(Mat icon, string cacheIconPath) - { - var cacheDirectory = Path.GetDirectoryName(cacheIconPath); - var temporaryPath = Path.Combine( - cacheDirectory, - $"{Path.GetFileNameWithoutExtension(cacheIconPath)}.{Guid.NewGuid():N}.tmp.bmp" - ); - - try - { - Directory.CreateDirectory(cacheDirectory); - icon.SaveImage(temporaryPath); - if (new FileInfo(temporaryPath).Length == 0) - throw new InvalidDataException("The encoded cache image was empty."); - - if (File.Exists(cacheIconPath)) - { - File.Replace(temporaryPath, cacheIconPath, null, true); - } - else - { - try - { - File.Move(temporaryPath, cacheIconPath); - } - catch (IOException) when (File.Exists(cacheIconPath)) - { - // Another process published the same complete cache entry first. - } - } - } - catch (Exception e) - when (IsRecoverableFileSystemException(e) || e is OpenCVException or OpenCvSharpException) - { - // Cache persistence must never prevent the in-memory icon from being used. - Logger.LogDebug("Could not persist cached icon: " + cacheIconPath, e); - } - finally - { - DeleteCacheFileBestEffort(temporaryPath); - } - } - - private void PruneCacheBestEffort() - { - PruneCache(_cacheDirectory, DateTime.UtcNow, MaxCacheAge, MaxCacheBytes, MaxCacheFiles); - } - - internal static void PruneCache( - string cacheDirectory, - DateTime utcNow, - TimeSpan maxAge, - long maxBytes, - int maxFiles - ) - { - try - { - if (!Directory.Exists(cacheDirectory)) - return; - - var cacheFiles = new List<(FileInfo file, long length)>(); - var staleCutoff = utcNow - maxAge; - var temporaryCutoff = utcNow - MaxTemporaryCacheFileAge; - - foreach (var path in Directory.EnumerateFiles(cacheDirectory, "*.bmp", SearchOption.TopDirectoryOnly)) - { - var file = new FileInfo(path); - var isTemporary = file.Name.EndsWith(".tmp.bmp", StringComparison.OrdinalIgnoreCase); - if ( - (isTemporary && file.LastWriteTimeUtc <= temporaryCutoff) - || file.LastWriteTimeUtc <= staleCutoff - ) - { - DeleteCacheFileBestEffort(path); - continue; - } - - if (!isTemporary) - cacheFiles.Add((file, file.Length)); - } - - var totalBytes = cacheFiles.Sum(entry => entry.length); - var fileCount = cacheFiles.Count; - foreach (var entry in cacheFiles.OrderBy(entry => entry.file.LastWriteTimeUtc)) - { - if (totalBytes <= maxBytes && fileCount <= maxFiles) - break; - if (!DeleteCacheFileBestEffort(entry.file.FullName)) - continue; - - totalBytes -= entry.length; - fileCount--; - } - } - catch (Exception e) when (IsRecoverableFileSystemException(e)) - { - // Cleanup is opportunistic; cache failures must not block scanning. - Logger.LogDebug("Could not prune the icon cache.", e); - } - } - - private static bool DeleteCacheFileBestEffort(string path) - { - try - { - File.Delete(path); - return !File.Exists(path); - } - catch (Exception e) when (IsRecoverableFileSystemException(e)) - { - return false; - } - } - - /// - /// Generate the background of an item as it would appear in game - /// - /// The transparent icon of the item - /// The item of the icon - /// 8UC3 matrix of transparent icon with blended background - private Mat GetIconWithBackground(Mat transparentIcon, Item item) - { - // Generate layers - var black = new Scalar(0, 0, 0, 255); - using var background = new Mat(transparentIcon.Size(), MatType.CV_8UC4).SetTo(black); - - using var cellStream = new MemoryStream(Resources.cell_full_border); - using var cellBitmap = new Bitmap(cellStream); - using var baseGridCell = cellBitmap.ToMat(); - using var gridCell = baseGridCell.Repeat( - PixelsToSlots(transparentIcon.Width), - PixelsToSlots(transparentIcon.Height), - -1, - -1 - ); - - var optimizeHighlighted = _config.ProcessingConfig.InventoryConfig.OptimizeHighlighted; - var bgColor = optimizeHighlighted ? new Color(255, 255, 255) : item.BackgroundColor.ToColor(); - var bgAlpha = _config.ProcessingConfig.InventoryConfig.BackgroundAlpha; - var bgScalar = new Scalar(bgColor.B, bgColor.G, bgColor.R, bgAlpha); - using var gridColor = new Mat(transparentIcon.Size(), MatType.CV_8UC4).SetTo(bgScalar); - - using var border = new Mat(transparentIcon.Size(), MatType.CV_8UC4).SetTo(new Scalar(0, 0, 0, 0)); - var borderRect = new Rect(Vector2.Zero, transparentIcon.Size()); - border.Rectangle(borderRect, _config.ProcessingConfig.InventoryConfig.GridColor); - - // Blend layers - using var blendedGrid = gridCell.AlphaBlend(background); - using var tmp1 = blendedGrid.RemoveTransparency(); - using var tmp2 = gridColor.AlphaBlend(tmp1); - using var tmp3 = border.AlphaBlend(tmp2); - using var result = transparentIcon.AlphaBlend(tmp3); - - // Add weapon mod icon - if (item is WeaponMod) - { - using var weaponModIcon = GetWeaponModIcon(item); - if (weaponModIcon != null) - { - var top = result.Height - weaponModIcon.Height - 2; - var right = result.Width - weaponModIcon.Width - 2; - using var weaponModIconPadded = weaponModIcon.AddPadding(2, top, right, 2); - using var resultWithMod = weaponModIconPadded.AlphaBlend(result); - return resultWithMod.CvtColor(ColorConversionCodes.BGRA2BGR, 3); - } - } - - // Convert to 8UC3 and return - return result.CvtColor(ColorConversionCodes.BGRA2BGR, 3); - } - - private Mat GetWeaponModIcon(Item item) - { - var bg = GetWeaponModIconBackground(item); - using var fg = GetWeaponModIconForeground(item); - if (fg == null) - return bg; - - using (bg) - { - using var paddedBg = bg.AddPadding(bg.Width, bg.Height, bg.Width, bg.Height); - - var hPadding = (bg.Width * 3) - fg.Width; - var vPadding = (bg.Height * 3) - fg.Height; - - var left = hPadding / 2 + hPadding % 2; - var top = vPadding / 2 + vPadding % 2; - var right = hPadding / 2; - var bottom = vPadding / 2; - using var paddedFg = fg.AddPadding(left, top, right, bottom); - - using var blended = paddedFg.AlphaBlend(paddedBg); - return blended.RemovePadding(bg.Width, bg.Height, bg.Width, bg.Height); - } - } - - private Mat GetWeaponModIconBackground(Item item) - { - var background = item switch - { - EssentialMod => Resources.mod_vital, - FunctionalMod => Resources.mod_generic, - GearMod => Resources.mod_gear, - _ => null, - }; - if (background == null) - return null; - using var stream = new MemoryStream(background); - using var bitmap = new Bitmap(stream); - return bitmap.ToMat(); - } - - private Mat GetWeaponModIconForeground(Item item) - { - var foreground = item switch - { - AuxiliaryMod => Resources.icon_mod_aux, - Barrel => Resources.icon_mod_barrel, - Bipod => Resources.icon_mod_bipod, - ChargingHandle => Resources.icon_mod_charge, - Flashlight => Resources.icon_mod_flashlight, - GasBlock => Resources.icon_mod_gasblock, - Handguard => Resources.icon_mod_handguard, - IronSight => Resources.icon_mod_ironsight, - Launcher => Resources.icon_mod_launcher, - LaserDesignator => Resources.icon_mod_lightlaser, - Magazine => Resources.icon_mod_magazine, - Mount => Resources.icon_mod_mount, - MuzzleDevice => Resources.icon_mod_muzzle, - PistolGrip => Resources.icon_mod_pistol_grip, - RailCovers => Resources.icon_mod_railcovers, - Receiver => Resources.icon_mod_receiver, - Sights => Resources.icon_mod_sight, - Stock => Resources.icon_mod_stock, - CombTactDevice => Resources.icon_mod_tactical, - Foregrip => Resources.icon_mod_tactical, - _ => null, - }; - if (foreground == null) - return null; - using var stream = new MemoryStream(foreground); - using var bitmap = new Bitmap(stream); - return bitmap.ToMat(); - } - - #endregion - - #region Correlation Data Loading - - private void LoadStaticCorrelationData() - { - var correlationData = new Dictionary(); - - var iconPathArray = Directory.GetFiles(_config.PathConfig.StaticIcons, "*.png"); - foreach (var iconPath in iconPathArray) - { - var itemId = System.IO.Path.GetFileNameWithoutExtension(iconPath); - var item = _config.RatStashDB.GetItem(itemId); - - // Filter out items which are not in the item database - if (item == null) - continue; - - // Add the item to the correlation data - var iconKey = GetIconKey(iconPath, IconType.Static); - correlationData[iconKey] = item; - } - - _staticCorrelationDataLock.EnterWriteLock(); - try - { - _staticCorrelationData = correlationData; - } - finally - { - _staticCorrelationDataLock.ExitWriteLock(); - } - } - - #endregion - - /// - /// Get the unique icon key for a icon path and its type - /// - /// - /// Keep this method coherent with and - /// - /// The path to the icon - /// The type of the icon - /// Unique identifier of the icon - private string GetIconKey(string iconPath, IconType iconType) - { - var basePath = iconType switch - { - IconType.Static => _config.PathConfig.StaticIcons, - IconType.Dynamic => _config.PathConfig.DynamicIcons, - _ => throw new ArgumentOutOfRangeException(nameof(iconType), iconType, null), - }; - return System.IO.Path.Combine(basePath, System.IO.Path.GetFileName(iconPath)); - } - - /// - /// Get the item, referenced by its icon key - /// - /// - /// Keep this method coherent with - /// - /// The icon key - /// The matching item - internal Item GetItem(string iconKey) - { - if (iconKey.StartsWith(_config.PathConfig.StaticIcons)) - { - _staticCorrelationDataLock.EnterReadLock(); - try - { - _staticCorrelationData.TryGetValue(iconKey, out var item); - return item; - } - finally - { - _staticCorrelationDataLock.ExitReadLock(); - } - } - - if (iconKey.StartsWith(_config.PathConfig.DynamicIcons)) - { - _dynamicCorrelationDataLock.EnterReadLock(); - try - { - _dynamicCorrelationData.TryGetValue(iconKey, out var item); - return item.Item1; - } - finally - { - _dynamicCorrelationDataLock.ExitReadLock(); - } - } - - return null; - } - - /// - /// Get the item, referenced by its icon key while assuming that - /// or - /// got correctly acquired before. - /// - /// The icon key - /// The matching item - private Item GetItemUnsafe(string iconKey) - { - if (iconKey.StartsWith(_config.PathConfig.StaticIcons)) - { - _staticCorrelationData.TryGetValue(iconKey, out var item); - return item; - } - - if (iconKey.StartsWith(_config.PathConfig.DynamicIcons)) - { - _dynamicCorrelationData.TryGetValue(iconKey, out var item); - return item.Item1; - } - - return null; - } - - /// - /// Get the item extra info, referenced by its icon key - /// - /// - /// Keep this method coherent with - /// - /// The icon key - /// The matching item extra info - internal ItemExtraInfo GetItemExtraInfo(string iconKey) - { - if (iconKey.StartsWith(_config.PathConfig.DynamicIcons)) - { - _dynamicCorrelationDataLock.EnterReadLock(); - try - { - return _dynamicCorrelationData[iconKey].Item2; - } - finally - { - _dynamicCorrelationDataLock.ExitReadLock(); - } - } - - return null; - } - - /// - /// Resolve the icon path for a item possible item extra info - /// - /// The item which icon path shall be resolved - /// The item extra info which shall be used to further distinguish icons - /// The path to the icon of the item - internal string GetIconPath(Item item, ItemExtraInfo itemExtraInfo) - { - _dynamicCorrelationDataLock.EnterReadLock(); - try - { - string dynamicPath = _dynamicCorrelationData - .FirstOrDefault(entry => entry.Value.Item1 == item && entry.Value.Item2 == itemExtraInfo) - .Key; - if (dynamicPath != null) - return dynamicPath; - } - finally - { - _dynamicCorrelationDataLock.ExitReadLock(); - } - - _staticCorrelationDataLock.EnterReadLock(); - try - { - return _staticCorrelationData.FirstOrDefault(entry => entry.Value == item).Key; - } - finally - { - _staticCorrelationDataLock.ExitReadLock(); - } - } - - /// - /// Calculate the hash of the current config but only consider used values - /// - /// Hash as hex string - private string GetConfigHash() - { - string configHash = new Config() - { - ProcessingConfig = new Config.Processing() - { - // Language = _config.ProcessingConfig.Language, - IconConfig = new Config.Processing.Icon() - { - ScanRotatedIcons = _config.ProcessingConfig.IconConfig.ScanRotatedIcons, - }, - InventoryConfig = new Config.Processing.Inventory() - { - OptimizeHighlighted = _config.ProcessingConfig.InventoryConfig.OptimizeHighlighted, - }, - }, - }.GetHash(); - return ("template-v2:" + configHash).SHA256Hash(); - } - - /// - /// Converts the pixel unit of a icon into the slot unit - /// - /// The pixel size of the icon - /// Slot size of the icon - private int PixelsToSlots(int pixels) - { - // Use converter class to round to nearest int instead of always rounding down - return Convert.ToInt32((pixels - 1) / _config.ProcessingConfig.BaseSlotSize); - } - - /// - /// Checks if the give pixels can be converted into slot unit - /// - /// The pixel size of the icon - /// True if the pixels can be converted to slots - private bool IsValidPixelSize(int pixels) - { - return Math.Abs(1 - pixels % _config.ProcessingConfig.BaseSlotSize) < 0.01f; - } - - /// - /// Reads a file with - /// - /// The path of the file - /// The file content as string - private static string ReadFileNonBlocking(string path) - { - using var fileStream = File.Open(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); - using var textReader = new StreamReader(fileStream); - return textReader.ReadToEnd(); - } - - public void Dispose() - { - if (_disposed) - return; - - StaticIconsLock.EnterWriteLock(); - DynamicIconsLock.EnterWriteLock(); - try - { - foreach (Mat icon in StaticIcons.Values.SelectMany(group => group.Values)) - icon.Dispose(); - foreach (Mat icon in DynamicIcons.Values.SelectMany(group => group.Values)) - icon.Dispose(); - StaticIcons.Clear(); - DynamicIcons.Clear(); - } - finally - { - DynamicIconsLock.ExitWriteLock(); - StaticIconsLock.ExitWriteLock(); - } - - StaticIconsLock.Dispose(); - DynamicIconsLock.Dispose(); - _staticCorrelationDataLock.Dispose(); - _dynamicCorrelationDataLock.Dispose(); - _disposed = true; - } - } -} diff --git a/src/ScanEngine/Logger.cs b/src/ScanEngine/Logger.cs deleted file mode 100644 index 92a08f15..00000000 --- a/src/ScanEngine/Logger.cs +++ /dev/null @@ -1,117 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Drawing; -using System.IO; -using System.Text; -using System.Threading; -using OpenCvSharp; - -namespace RatEye -{ - /// - /// Class for logging events and control flow of RatEye - /// - internal static class Logger - { - private static List _backlog = new(); - private static readonly object Sync = new(); - - internal static void LogDebug(string message, Exception e) - { - LogDebug(message + "\nException: " + e); - } - - internal static void LogDebug(string message) - { - if (Config.LogDebug) - AppendToLog("[Debug] " + message); - } - - internal static void LogDebugBitmap(Bitmap bitmap, string fileName = "bitmap") - { - if (Config.LogDebug) - { - bitmap.Save(GetUniquePath(Config.Path.Debug, fileName, ".png")); - } - } - - internal static void LogDebugMat(OpenCvSharp.Mat mat, string fileName = "mat") - { - if (!Config.LogDebug) - return; - - if (mat.Type() == MatType.CV_32FC1) - { - using var converted = new Mat(mat.Size(), MatType.CV_8UC1); - mat.ConvertTo(converted, MatType.CV_8UC1, 255); - converted.SaveImage(GetUniquePath(Config.Path.Debug, fileName, ".png")); - return; - } - mat.SaveImage(GetUniquePath(Config.Path.Debug, fileName, ".png")); - } - - private static string GetUniquePath(string basePath, string fileName, string extension) - { - fileName = fileName.Replace(' ', '_'); - - var index = 0; - var uniquePath = Path.Combine(basePath, fileName + "(" + index + ")" + extension); - - while (File.Exists(uniquePath)) - { - index += 1; - uniquePath = Path.Combine(basePath, fileName + "(" + index + ")" + extension); - } - - Directory.CreateDirectory(Path.GetDirectoryName(uniquePath)); - return uniquePath; - } - - private static void AppendToLog(string content) - { - lock (Sync) - { - ProcessBacklog(); - - var prefix = "[" + DateTime.UtcNow.ToUniversalTime().TimeOfDay + "] > "; - - try - { - AppendToLogRaw(prefix + content + "\n"); - } - catch (Exception e) - { - _backlog.Add(prefix + "Could not write to log file\n" + e + "\n"); - _backlog.Add(prefix + content + "\n"); - Thread.Sleep(250); - ProcessBacklog(); - } - } - } - - private static void AppendToLogRaw(string text) - { - System.Diagnostics.Debug.WriteLine(text); - File.AppendAllText(Config.Path.LogFile, text, Encoding.UTF8); - } - - private static void ProcessBacklog() - { - var newBacklog = new List(); - - foreach (var text in _backlog) - { - try - { - AppendToLogRaw(text); - } - catch - { - newBacklog.Add(text); - } - } - - _backlog = newBacklog; - } - } -} diff --git a/src/ScanEngine/Processing/Icon.cs b/src/ScanEngine/Processing/Icon.cs deleted file mode 100644 index 05767f59..00000000 --- a/src/ScanEngine/Processing/Icon.cs +++ /dev/null @@ -1,441 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Drawing; -using System.Linq; -using System.Text.RegularExpressions; -using System.Threading.Tasks; -using OpenCvSharp; -using OpenCvSharp.Extensions; -using RatStash; -using Tesseract; - -namespace RatEye.Processing -{ - /// - /// Represents an icon in the inventory - /// - public class Icon : IDisposable - { - private readonly Config _config; - private readonly Bitmap _icon; - private Bitmap _scaledIcon; - private Item _item; - private ItemExtraInfo _itemExtraInfo; - private float _detectionConfidence; - private Vector2 _itemPosition; - private bool _rotated; - private string _ocrTitle = ""; - private bool _disposed; - - private Config.Processing ProcessingConfig => _config.ProcessingConfig; - private Config.Path PathConfig => _config.PathConfig; - private Config.Processing.Icon IconConfig => ProcessingConfig.IconConfig; - - /// - /// Sync object to synchronize control flow of multiple threads - /// - private readonly object _sync = new(); - - /// - /// Position of the icon inside the inventory (top, left) - /// - public Vector2 Position { get; } - - /// - /// Size of the icon image, measured in pixel - /// - /// - /// This might not be the exact size of the item and does not account for rotation - /// - public Vector2 Size { get; } - - /// - /// Size of the item, measured in pixels - /// - public Vector2 ItemSize - { - get - { - var size = IconSlotSize(); - size.X = (int)(size.X * ProcessingConfig.ScaledSlotSize); - size.Y = (int)(size.Y * ProcessingConfig.ScaledSlotSize); - return size; - } - } - - /// - /// The detected item - /// - public Item Item - { - get - { - SatisfyState(State.Scanned); - return _item; - } - } - - /// - /// The detected item extra info - /// - public ItemExtraInfo ItemExtraInfo - { - get - { - SatisfyState(State.Scanned); - return _itemExtraInfo; - } - } - - /// - /// The path to the icon of the detected item - /// - public string IconPath => - Item == null ? null : _config.IconManager.GetIconPath(Item, ItemExtraInfo ?? new ItemExtraInfo()); - - /// - /// Confidence with which the was detected/> - /// - public float DetectionConfidence - { - get - { - SatisfyState(State.Scanned); - return _detectionConfidence; - } - } - - /// - /// The exact position of the item - /// - public Vector2 ItemPosition - { - get - { - SatisfyState(State.Scanned); - return _itemPosition; - } - } - - /// - /// if the icon is rotated - /// - public bool Rotated - { - get - { - SatisfyState(State.Scanned); - return _rotated; - } - } - - internal Icon(Bitmap icon, Vector2 position, Vector2 size, Config config) - { - _config = config; - _icon = icon; - Position = position; - Size = size; - } - - private enum State - { - Default, - Rescaled, - Scanned, - } - - private State _currentState = State.Default; - - private void SatisfyState(State targetState) - { - while (_currentState < targetState) - { - switch (_currentState + 1) - { - case State.Default: - break; - case State.Rescaled: - RescaleIcon(); - break; - case State.Scanned: - if (IconConfig.ScanMode == Config.Processing.Icon.ScanModes.TemplateMatching) - { - TemplateMatch(); - if (IconConfig.ScanRotatedIcons) - TemplateMatch(true); - } - else if (IconConfig.ScanMode == Config.Processing.Icon.ScanModes.OCR) - OCR(); - break; - default: - throw new Exception("Cannot satisfy unknown state."); - } - - _currentState++; - } - } - - private void RescaleIcon() - { - Logger.LogDebugBitmap(_icon, "icon/_icon"); - var mul = IconConfig.ScanMode == Config.Processing.Icon.ScanModes.OCR ? 2 : 1; - _scaledIcon = _icon.Rescale(ProcessingConfig.InverseScale * mul); - Logger.LogDebugBitmap(_scaledIcon, "icon/_scaledIcon"); - } - - private void TemplateMatch(bool rotated = false) - { - SatisfyState(State.Rescaled); - - // NOTE The source image is scaled hence all outgoing pixel values need to be adjusted accordingly - using var source = _scaledIcon.ToMat(); - if (rotated) - Cv2.Rotate(source, source, RotateFlags.Rotate90Counterclockwise); - - Logger.LogDebugMat(source, "icon/source"); - - (string match, float confidence, Vector2 pos) staticResult = default; - (string match, float confidence, Vector2 pos) dynamicResult = default; - - if (!IconConfig.UseStaticIcons) - { - throw new Exception( - "No icons for template matching can be used." + nameof(IconConfig.UseStaticIcons) + " is false." - ); - } - - var iconManager = _config.IconManager; - var iconSlotSize = IconSlotSize(); - var slotSize = rotated ? new Vector2(iconSlotSize.Y, iconSlotSize.X) : iconSlotSize; - if (IconConfig.UseStaticIcons) - { - iconManager.EnsureStaticIconsLoaded(slotSize); - if (iconManager.StaticIcons.ContainsKey(slotSize)) - { - iconManager.StaticIconsLock.EnterReadLock(); - try - { - staticResult = TemplateMatchSub(source, iconManager.StaticIcons[slotSize]); - } - finally - { - iconManager.StaticIconsLock.ExitReadLock(); - } - } - } - - var (match, confidence, pos) = - staticResult.confidence > dynamicResult.confidence ? staticResult : dynamicResult; - - if (!(confidence > _detectionConfidence)) - return; - - _rotated = rotated; - _itemPosition = (rotated ? new(pos.Y, pos.X) : pos) * _config.ProcessingConfig.Scale; - _detectionConfidence = confidence; - _item = _config.IconManager.GetItem(match); - _itemExtraInfo = _config.IconManager.GetItemExtraInfo(match); - } - - private (string match, float confidence, Vector2 pos) TemplateMatchSub( - Mat source, - Dictionary icons - ) - { - var bestMatch = ""; - var confidence = 0f; - var position = Vector2.Zero; - - Parallel.ForEach( - icons, - icon => - { - using var matches = source.MatchTemplate(icon.Value, TemplateMatchModes.SqDiffNormed); - matches.MinMaxLoc(out var minVal, out _, out var minLoc, out _); - - lock (_sync) - { - minVal = 1 - minVal; - if (!(minVal > confidence)) - return; - confidence = (float)minVal; - bestMatch = icon.Key; - position = new Vector2(minLoc); - //Logger.LogDebugMat(icon.Value, $"icon/conf-{confidence}.png"); - //Logger.LogDebugMat(matches, $"icon/match-conf-{confidence}.png"); - } - } - ); - - return (bestMatch, confidence, position); - } - - /// - /// Converts the pixel unit of the icon into the slot unit - /// - /// Slot size of the icon - private Vector2 IconSlotSize() - { - // Use converter class to round to nearest int instead of always rounding down - var x = (Size.X - 1) / ProcessingConfig.ScaledSlotSize; - var y = (Size.Y - 1) / ProcessingConfig.ScaledSlotSize; - return new Vector2((int)x, (int)y); - } - - /// - /// Perform optical character recognition on the scaled icon image. - /// - private void OCR() - { - var topCutoff = 0; - var leftCutoff = 4; - - // Setup tesseract - using var scaledIconMat = _scaledIcon.ToMat(); - Logger.LogDebugMat(scaledIconMat); - using var topText = _scaledIcon.Crop(leftCutoff, topCutoff, _scaledIcon.Width - leftCutoff, 24); - using var topTextMat = topText.ToMat(); - - // Gray scale image - //Logger.LogDebug("Gray scaling..."); - //var cvu83 = topTextMat.CvtColor(ColorConversionCodes.BGR2GRAY, 1); - //Logger.LogDebugMat(cvu83); - - // Binarize image - //Logger.LogDebug("Binarizing..."); - //cvu83 = cvu83.Threshold(120, 255, ThresholdTypes.BinaryInv); - //Logger.LogDebugMat(cvu83); - - using var hsv = topTextMat.CvtColor(ColorConversionCodes.BGR2HSV_FULL); - using var colorFilter = hsv.InRange( - new Scalar(93 * (255f / 180f), 16, 97), - new Scalar(113 * (255f / 180f), 24, 217) - ); - - using var morphologyStructure = new Mat(2, 2, MatType.CV_8U, Scalar.All(1)); - Cv2.MorphologyEx(colorFilter, colorFilter, MorphTypes.Close, morphologyStructure); - Cv2.BitwiseNot(colorFilter, colorFilter); - Logger.LogDebugMat(colorFilter); - - using var filteredBitmap = colorFilter.ToBitmap(); - Bitmap final = filteredBitmap.Rescale(2); - try - { - Logger.LogDebugBitmap(final); - - // Convert to Pix - using var pix = PixConverter.ToPix(final); - - // OCR - Logger.LogDebug("Applying OCR..."); - using var result = GetTesseractEngine().Process(pix); - var text = result.GetText(); - - var r = result.GetSegmentedRegions(PageIteratorLevel.TextLine); - foreach (var x in r) - { - Logger.LogDebug("TEXT: " + x); - } - Logger.LogDebugMat(topTextMat, "lalalla"); - - var rgx = new Regex("[^a-zA-Z0-9 -\\.]"); - _itemPosition = Vector2.Zero; - _ocrTitle = rgx.Replace(text.CyrillicToLatin().Trim(), "").Trim(); - Logger.LogDebug("Read: " + _ocrTitle); - SetOCRItem(); - } - finally - { - if (!ReferenceEquals(final, filteredBitmap)) - final.Dispose(); - } - } - - /// - /// Set the item to one, best matching the scanned title - /// - private void SetOCRItem() - { - var slotSize = IconSlotSize(); - var items = _config.RatStashDB.GetItems(i => - { - var v = new Vector2(i.GetSlotSize()); - return v == slotSize || v == slotSize.Flipped; - }); - _item = null; - _detectionConfidence = 0; - if (string.IsNullOrWhiteSpace(_ocrTitle)) - return; - - foreach (Item item in items) - { - float confidence = item - .ShortName.Replace("I", "T") - .CyrillicToLatin() - .NormedLevenshteinDistance(_ocrTitle); - if (confidence <= _detectionConfidence) - continue; - - _item = item; - _detectionConfidence = confidence; - } - if (_item == null) - return; - _rotated = new Vector2(_item.GetSlotSize()) != slotSize; - } - - /// - /// Creates an instance of the OCRTesseract class. Initializes Tesseract. - /// - /// Tesseract instance trained for the bender font - private TesseractEngine GetTesseractEngine() - { - // Return if tesseract instance was already created - var tesseractEngine = IconConfig.TesseractEngine; - if (tesseractEngine != null) - return tesseractEngine; - - // Check if trained data is present - var langCode = _config.ProcessingConfig.Language.ToISO3Code(); - var traineddataPath = $"{PathConfig.TrainedData}\\{langCode}.traineddata"; - if (!System.IO.File.Exists(traineddataPath)) - { - var message = "Could not find traineddata at: " + traineddataPath; - throw new System.IO.FileNotFoundException(message, traineddataPath); - } - - // Load additional language to expand the primary one - var addLang = _config.ProcessingConfig.Language switch - { - //Language.Chinese => "eng", - Language.Czech => "+eng", - Language.Japanese => "+eng", - Language.Korean => "+eng", - Language.Russian => "+eng", - _ => "", - }; - - var language = langCode + addLang; - - // Create a tesseract instance - IconConfig.TesseractEngine = new TesseractEngine(PathConfig.TrainedData, language, EngineMode.LstmOnly) - { - DefaultPageSegMode = PageSegMode.RawLine, - }; - - return IconConfig.TesseractEngine; - } - - public void Dispose() - { - if (_disposed) - return; - - if (_scaledIcon != null && !ReferenceEquals(_scaledIcon, _icon)) - _scaledIcon.Dispose(); - _icon.Dispose(); - _disposed = true; - GC.SuppressFinalize(this); - } - } -} diff --git a/src/ScanEngine/Processing/Inspection.cs b/src/ScanEngine/Processing/Inspection.cs deleted file mode 100644 index da931331..00000000 --- a/src/ScanEngine/Processing/Inspection.cs +++ /dev/null @@ -1,471 +0,0 @@ -using System; -using System.Drawing; -using System.IO; -using System.Linq; -using OpenCvSharp; -using OpenCvSharp.Extensions; -using RatStash; -using Tesseract; - -namespace RatEye.Processing -{ - /// - /// Represents an inspection view - /// - public class Inspection - { - private readonly Config _config; - private readonly Bitmap _image; - - private Config.Path PathConfig => _config.PathConfig; - private Config.Processing ProcessingConfig => _config.ProcessingConfig; - private Config.Processing.Inspection InspectionConfig => ProcessingConfig.InspectionConfig; - - // Backing property fields - private Vector2 _markerPosition; - private float _markerConfidence; - private string _title = ""; - private Item _item; - private float _itemConfidence; - - /// - /// Position of the marker in the given image - /// - /// if no marker above threshold found - public Vector2 MarkerPosition - { - get - { - SatisfyState(State.SearchedMarker); - return _markerPosition; - } - private set => _markerPosition = value; - } - - /// - /// The confidence that the image contains a marker - /// - public float MarkerConfidence - { - get - { - SatisfyState(State.SearchedMarker); - return _markerConfidence; - } - private set => _markerConfidence = value; - } - - /// - /// if the provided image contains a marker above the threshold - /// - public bool ContainsMarker => MarkerConfidence >= InspectionConfig.MarkerThreshold; - - /// - /// Title of the inspection window - /// The title which is to the right of the marker - /// - public string Title - { - get - { - SatisfyState(State.ScannedTitle); - return _title; - } - private set => _title = value; - } - - /// - /// Detected item - /// - public Item Item - { - get - { - SatisfyState(State.ScannedTitle); - return _item; - } - } - - /// - /// Similarity between the OCR title and the detected item's name. - /// - public float ItemConfidence - { - get - { - SatisfyState(State.ScannedTitle); - return _itemConfidence; - } - } - - /// - /// The path to the icon of the detected item - /// - public string IconPath => Item == null ? null : _config.IconManager.GetIconPath(Item, new ItemExtraInfo()); - - /// - /// Constructor for inspection view processing object - /// - /// Image of the inspection view which will be processed - /// The config to use for this instance - /// Provided image has to be in RGB - internal Inspection(Bitmap image, Config config) - { - _config = config; - _image = image; - } - - /// - /// Constructor for inspection view processing object - /// - /// Image of the inspection view which will be processed - /// The config to use for this instance - /// Position of the marker in the given image - /// Confidence of the marker in the given image - /// Provided image has to be in RGB - internal Inspection(Bitmap image, Config config, Vector2 markerPosition, float markerConfidence) - { - _config = config; - _image = image; - _currentState = State.SearchedMarker; - _markerPosition = markerPosition; - _markerConfidence = markerConfidence; - } - - #region Processing state handling - - private enum State - { - Default, - SearchedMarker, - ScannedTitle, - } - - private State _currentState = State.Default; - - private void SatisfyState(State targetState) - { - while (_currentState < targetState) - { - switch (_currentState + 1) - { - case State.Default: - break; - case State.SearchedMarker: - SearchMarker(); - break; - case State.ScannedTitle: - ScanTitle(); - break; - default: - throw new Exception("Cannot satisfy unknown state."); - } - - _currentState++; - } - } - - #endregion - - /// - /// Search for markers and pick the best matching one - /// - private void SearchMarker() - { - SatisfyState(State.Default); - - using Bitmap marker = GetScaledMarker(); - var (confidence, position) = GetMarkerPosition(marker); - MarkerConfidence = confidence; - MarkerPosition = position; - } - - /// - /// Identify the give marker inside the source - /// - /// The marker template to identify - /// Provided marker has to be in RGB - /// Confidence and position of the best match - private (float confidence, Vector2 position) GetMarkerPosition(Bitmap marker) - { - using var refMat = _image.ToMat(); - using var tplMat = marker.ToMat(); // tpl = template - using var res = new Mat(refMat.Rows - tplMat.Rows + 1, refMat.Cols - tplMat.Cols + 1, MatType.CV_32FC1); - - // Gray scale both reference and template image - using var gref = refMat.CvtColor(ColorConversionCodes.RGB2GRAY); - using var gtpl = tplMat.CvtColor(ColorConversionCodes.RGB2GRAY); - - Cv2.MatchTemplate(gref, gtpl, res, TemplateMatchModes.CCoeffNormed); - //Cv2.Threshold(res, res, 0.8, 1.0, ThresholdTypes.Tozero); - Cv2.MinMaxLoc(res, out _, out var maxVal, out _, out var maxLoc); - - return ((float)maxVal, new Vector2(maxLoc)); - } - - private void ScanTitle() - { - if (!ContainsMarker) - { - // No marker? Why even bother scanning... - Logger.LogDebug("No marker found!"); - return; - } - - // Compute title search box dimensions - var position = new Vector2(MarkerPosition.X, MarkerPosition.Y); - position.X += GetHorizontalTitleSearchOffset(); - - // Find end of the title bar - using Bitmap scaledMarker = GetScaledMarker(); - var closeBtnCenterHeight = MarkerPosition.Y + (scaledMarker.Height / 2); - var lowC = InspectionConfig.CloseButtonColorLowerBound; - var upC = InspectionConfig.CloseButtonColorUpperBound; - var closeButtonPosition = _image.FindPixelInRange(closeBtnCenterHeight, lowC, upC, position.X); - - // Construct final search box dimensions - var scaledTitleSearchHeight = (int)(InspectionConfig.BaseTitleSearchHeight * ProcessingConfig.Scale); - var scaledTitleSearchWidth = (int)(InspectionConfig.BaseTitleSearchWidth * ProcessingConfig.Scale); - var height = Math.Min(scaledTitleSearchHeight, _image.Height - position.Y); - var width = Math.Min(scaledTitleSearchWidth, _image.Width - position.X); - if (closeButtonPosition > 0) - { - // Calculate width of search box to the close button edge - var tmpWidth = closeButtonPosition - position.X; - // Shorten the width to account for extra buttons ( for example sort buttons ) - var titleSearchRightPadding = (int)( - InspectionConfig.BaseTitleSearchRightPadding * ProcessingConfig.Scale - ); - tmpWidth -= titleSearchRightPadding; - // Apply new width if its the new minimum width - width = Math.Min(width, tmpWidth); - } - - // A marker detected near the right edge can push the search box origin past the - // image bounds, yielding a non-positive width/height. Crop would throw and abort - // the scan, so bail out to an empty title (treated as "no match") instead. - if (width <= 0 || height <= 0) - { - Logger.LogDebug("Title search box has non-positive dimensions; skipping title scan."); - return; - } - - // Crop image to title search box - using Bitmap searchBox = _image.Crop(position.X, position.Y, width, height); - - // Rescale title search box to 4k, adjusting the font size to the training data - // We multiply the inverse scale with 2f to rescale to 4k instead of 1080p - Bitmap rescaledSearchBox = searchBox.Rescale(ProcessingConfig.InverseScale * 2f); - try - { - // Use the _title backing field below: the Title getter calls - // SatisfyState(State.ScannedTitle), which re-enters ScanTitle() - // because _currentState only advances after this method returns. - Title = OCR(rescaledSearchBox); - if (IsUiChromeTitle(_title)) - { - Logger.LogDebug("Title matches inventory/UI chrome; skipping item match: " + _title); - _item = null; - _itemConfidence = 0; - return; - } - - MatchItem(); - } - finally - { - if (!ReferenceEquals(rescaledSearchBox, searchBox)) - rescaledSearchBox.Dispose(); - } - } - - /// - /// Perform optical character recognition on a image - /// - /// Image to perform OCR on - /// Detected characters in image - private string OCR(Bitmap image) - { - // Setup tesseract - using var mat = image.ToMat(); - - // Gray scale image - Logger.LogDebug("Gray scaling..."); - using var grayscale = mat.CvtColor(ColorConversionCodes.BGR2GRAY, 1); - - // Binarize image - Logger.LogDebug("Binarizing..."); - using var binary = grayscale.Threshold(120, 255, ThresholdTypes.BinaryInv); - - // Convert to Pix - using Bitmap binaryBitmap = binary.ToBitmap(); - using var pix = PixConverter.ToPix(binaryBitmap); - - // OCR - Logger.LogDebug("Applying OCR..."); - using var result = GetTesseractEngine().Process(pix); - var text = result.GetText(); - - Logger.LogDebug("Read: " + text); - return text.CyrillicToLatin().Trim(); - } - - /// - /// Creates an instance of the OCRTesseract class. Initializes Tesseract. - /// - /// Tesseract instance trained for the bender font - private TesseractEngine GetTesseractEngine() - { - // Return if tesseract instance was already created - var tesseractEngine = InspectionConfig.TesseractEngine; - if (tesseractEngine != null) - return tesseractEngine; - - // Check if trained data is present - var langCode = _config.ProcessingConfig.Language.ToISO3Code(); - var traineddataPath = $"{PathConfig.TrainedData}\\{langCode}.traineddata"; - if (!File.Exists(traineddataPath)) - { - var message = "Could not find traineddata at: " + traineddataPath; - throw new FileNotFoundException(message, traineddataPath); - } - - // Load additional language to expand the primary one - var addLang = _config.ProcessingConfig.Language switch - { - //Language.Chinese => "eng", - Language.Czech => "+eng", - Language.Japanese => "+eng", - Language.Korean => "+eng", - Language.Russian => "+eng", - _ => "", - }; - - var language = langCode + addLang; - - // Create a tesseract instance - InspectionConfig.TesseractEngine = new TesseractEngine( - PathConfig.TrainedData, - language, - EngineMode.LstmOnly - ) - { - DefaultPageSegMode = PageSegMode.RawLine, - }; - - return InspectionConfig.TesseractEngine; - } - - /// - /// Generate a marker bitmap - /// - /// is already accounted for. - /// A rescaled and alpha blended version of - private Bitmap GetScaledMarker() - { - Bitmap output = InspectionConfig.Marker.Rescale(InspectionConfig.MarkerItemScale * ProcessingConfig.Scale); - try - { - return output.TransparentToColor(InspectionConfig.MarkerBackgroundColor); - } - finally - { - if (!ReferenceEquals(output, InspectionConfig.Marker)) - output.Dispose(); - } - } - - /// - /// Scaled horizontal offset of the inspection window title search box - /// See . - /// - /// The distance between the right edge of the marker and the beginning of the title search box - private int GetHorizontalTitleSearchOffset() - { - using Bitmap marker = GetScaledMarker(); - var width = marker.Width; - return (int)(width * InspectionConfig.HorizontalTitleSearchOffsetFactor); - } - - /// - /// Get the item, best matching the scanned title - /// - /// Item instance - private void MatchItem() - { - _item = null; - _itemConfidence = 0; - if (string.IsNullOrWhiteSpace(_title)) - return; - - Item best = null; - float bestConfidence = 0; - foreach (Item item in _config.RatStashDB.GetItems()) - { - float confidence = item.Name.CyrillicToLatin().NormedLevenshteinDistance(_title); - if (confidence <= bestConfidence) - continue; - - best = item; - bestConfidence = confidence; - } - - // Always surface the best score for diagnostics/UI, but only accept - // items when similarity clears MinItemConfidence. - _itemConfidence = bestConfidence; - if (best != null && bestConfidence >= InspectionConfig.MinItemConfidence) - _item = best; - else - Logger.LogDebug( - $"Best title match '{best?.Name}' confidence {bestConfidence:F3} below threshold {InspectionConfig.MinItemConfidence:F3}." - ); - } - - /// - /// Titles that come from non-inspect UI (inventory search bar, etc.). - /// OCR of these must never resolve to a catalog item. - /// - /// - /// Matching is case-insensitive and tolerates minor OCR noise via - /// . - /// - // Prefer multi-word / distinctive phrases. Single short tokens (e.g. DE "Suchen") - // are too easy to collide with real short item names under fuzzy matching. - internal static readonly string[] UiChromeTitles = - { - "Subject Search", - // Common localizations of the inventory filter placeholder. - "Поиск предмета", - "Rechercher un objet", - "Buscar objeto", - "Procurar item", - "Cerca oggetto", - "Szukaj przedmiotu", - "搜索物品", - "아이템 검색", - "アイテム検索", - }; - - private const float UiChromeTitleMatchThreshold = 0.85f; - - /// - /// Returns when is inventory/UI chrome - /// rather than an inspection-window item name. - /// - internal static bool IsUiChromeTitle(string title) - { - if (string.IsNullOrWhiteSpace(title)) - return false; - - string normalized = title.CyrillicToLatin().Trim().ToLowerInvariant(); - foreach (string chrome in UiChromeTitles) - { - string chromeNormalized = chrome.CyrillicToLatin().Trim().ToLowerInvariant(); - if (chromeNormalized.NormedLevenshteinDistance(normalized) >= UiChromeTitleMatchThreshold) - return true; - } - - return false; - } - } -} diff --git a/src/ScanEngine/Processing/Inventory.cs b/src/ScanEngine/Processing/Inventory.cs deleted file mode 100644 index e56fa713..00000000 --- a/src/ScanEngine/Processing/Inventory.cs +++ /dev/null @@ -1,559 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using OpenCvSharp; -using OpenCvSharp.Extensions; - -namespace RatEye.Processing -{ - /// - /// Represents a grid style inventory which can contain multiple - /// - public class Inventory : IDisposable - { - private readonly Config _config; - private readonly Mat _image; - private Mat _grid; - private Mat _vertGrid; - private List _boundingBoxes = new(); - private List _icons = new(); - private bool _disposed; - - private Config.Processing ProcessingConfig => _config.ProcessingConfig; - - /// - /// All icons, contained inside the inventory - /// - public IEnumerable Icons - { - get - { - SatisfyState(State.GridParsed); - return _icons; - } - } - - /// - /// Constructor for inventory view processing object - /// - /// Image of the inventory which will be processed - /// The config to use for this instance> - internal Inventory(System.Drawing.Bitmap image, Config config) - { - _config = config; - _image = image.ToMat(); - } - - private enum State - { - Default, - GridDetected, - GridParsed, - } - - private State _currentState = State.Default; - - private void SatisfyState(State targetState) - { - while (_currentState < targetState) - { - switch (_currentState + 1) - { - case State.Default: - break; - case State.GridDetected: - DetectInventoryGrid(); - break; - case State.GridParsed: - ParseInventoryGrid(); - break; - default: - throw new Exception("Cannot satisfy unknown state."); - } - - _currentState++; - } - } - - private void DetectInventoryGrid() - { - if (_config.ProcessingConfig.InventoryConfig.OptimizeHighlighted) - DetectInventoryGridHighlighted(); - else - DetectInventoryGridNormal(); - } - - private void DetectInventoryGridHighlighted() - { - var (minHue, minSaturation, minValue) = _config.ProcessingConfig.InventoryConfig.MinHighlightingColor; - var (maxHue, maxSaturation, maxValue) = _config.ProcessingConfig.InventoryConfig.MaxHighlightingColor; - var minBackgroundScalar = new Scalar(minHue, minSaturation, minValue); - var maxBackgroundScalar = new Scalar(maxHue, maxSaturation, maxValue); - using var hsv = _image.CvtColor(ColorConversionCodes.BGR2HSV_FULL); - var colorFilter = hsv.InRange(minBackgroundScalar, maxBackgroundScalar); - Logger.LogDebugMat(colorFilter, "inventory/colorFilter"); - - var scale = _config.ProcessingConfig.Scale; - - using var hStructure = new Mat(1, (int)(2 * scale), MatType.CV_8U, Scalar.All(1)); - using var vStructure = new Mat((int)(2 * scale), 1, MatType.CV_8U, Scalar.All(1)); - - Cv2.Dilate(colorFilter, colorFilter, hStructure, null, 1); - Cv2.Dilate(colorFilter, colorFilter, vStructure, null, 1); - Logger.LogDebugMat(colorFilter, "inventory/colorFilterPostDilate"); - - _grid = colorFilter; - } - - private void DetectInventoryGridNormal() - { - var (minHue, minSaturation, minValue) = _config.ProcessingConfig.InventoryConfig.MinGridColor; - var (maxHue, maxSaturation, maxValue) = _config.ProcessingConfig.InventoryConfig.MaxGridColor; - var minGridScalar = new Scalar(minHue, minSaturation, minValue); - var maxGridScalar = new Scalar(maxHue, maxSaturation, maxValue); - using var hsv = _image.CvtColor(ColorConversionCodes.BGR2HSV_FULL); - using var colorFilter = hsv.InRange(minGridScalar, maxGridScalar); - - Logger.LogDebugMat(colorFilter, "inventory/colorFilter"); - - // Extract vertical and horizontal lines - var scaledSlotSize = (int)ProcessingConfig.ScaledSlotSize; - var size = scaledSlotSize - (scaledSlotSize % 2) + 1; - using var lineStructure = new Mat(size, 1, MatType.CV_8U, Scalar.All(1)); - using var horizontalLineStructure = new Mat(1, size, MatType.CV_8U, Scalar.All(1)); - var verticalLines = colorFilter.Erode(lineStructure); - Cv2.Dilate(verticalLines, verticalLines, lineStructure); - using var horizontalLines = colorFilter.Erode(horizontalLineStructure); - Cv2.Dilate(horizontalLines, horizontalLines, horizontalLineStructure); - - SmoothJaggedLines(verticalLines, false); - SmoothJaggedLines(horizontalLines, true); - - var grid = CombineWithoutPeeks(verticalLines, horizontalLines); - using var closeStructure = new Mat(2, 2, MatType.CV_8U, Scalar.All(1)); - Cv2.Dilate(grid, grid, closeStructure); - Cv2.Erode(grid, grid, closeStructure); - - _grid = grid; - _vertGrid = verticalLines; - } - - private void ParseInventoryGrid() - { - if (_config.ProcessingConfig.InventoryConfig.OptimizeHighlighted) - ParseInventoryGridHighlighted(); - else - ParseInventoryGridNormal(); - } - - private void ParseInventoryGridHighlighted() - { - var contours = _grid.FindContoursAsArray(RetrievalModes.External, ContourApproximationModes.ApproxSimple); - - using var debugMat = _image.Clone(); - if (Config.LogDebug) - Cv2.DrawContours(debugMat, contours, -1, new(255, 0, 0)); - - var boundingBoxes = contours.Select(Cv2.BoundingRect).ToList(); - if (Config.LogDebug) - boundingBoxes.ForEach(b => debugMat.Rectangle(b, new(0, 255, 0))); - - // Merge overlapping bounding boxes - for (var b = 0; b < boundingBoxes.Count; b++) - { - for (var i = b + 1; i < boundingBoxes.Count; i++) - { - if (!boundingBoxes[b].IntersectsWith(boundingBoxes[i])) - continue; - boundingBoxes[b] = boundingBoxes[b].Union(boundingBoxes[i]); - boundingBoxes.RemoveAt(i); - i = b; - } - } - - if (Config.LogDebug) - boundingBoxes.ForEach(b => debugMat.Rectangle(b, new(0, 0, 255))); - Logger.LogDebugMat(debugMat, "inventory/boundingBoxes"); - - _boundingBoxes = boundingBoxes; - } - - /// - /// Scan along multiple spaced apart rows of the processed input image - /// If the pixel under the scan position is white, check if there is - /// a valid icon at that position and add it as done by the - /// method. - /// At last, remove detected icons which are overlapping. - /// - private void ParseInventoryGridNormal() - { - var gridIndexer = GetByteGridIndexer(_grid, nameof(_grid)); - var vertGridIndexer = GetByteGridIndexer(_vertGrid, nameof(_vertGrid)); - var scaledSlotSize = (int)(_config.ProcessingConfig.ScaledSlotSize); - if (scaledSlotSize < 2) - { - throw new InvalidOperationException( - "The scaled inventory slot size must be at least two pixels for grid parsing." - ); - } - - var maxRows = _vertGrid.Rows; - var vertGridCols = _vertGrid.Cols; - var gridRows = _grid.Rows; - var gridCols = _grid.Cols; - if (gridRows != maxRows || gridCols != vertGridCols) - throw new InvalidOperationException("Inventory grid matrices must have matching dimensions."); - - var maxCols = vertGridCols - scaledSlotSize / 2; - - for (var y = scaledSlotSize / 2; y < maxRows; y += scaledSlotSize) - { - for (var x = 0; x < maxCols; x++) - { - if (vertGridIndexer[y, x] != 0xFF) - continue; - - TryAddIcon(gridIndexer, gridRows, gridCols, x, y); - } - } - - // Quarter of the normal sized slot - var overlapThreshold = scaledSlotSize / 2; - - for (var i = _icons.Count - 1; i >= 0; i--) - { - var iconA = _icons[i]; - var iconARect = new Rect(iconA.Position, iconA.Size); - - for (var j = _icons.Count - 1; j >= 0; j--) - { - // Skip if comparing to itself - if (i == j) - continue; - - var iconB = _icons[j]; - - // Only remove icon A from the list if it is bigger then the compare to icon - if (iconA.Size.Area < iconB.Size.Area) - continue; - - var iconBRect = new Rect(iconB.Position, iconB.Size); - - var overlapRect = iconARect.Intersect(iconBRect); - - // Remove icon A from the icons and continue to the next one - if (overlapRect.Width > overlapThreshold && overlapRect.Height > overlapThreshold) - { - _icons.RemoveAt(i); - break; - } - } - } - } - - /// - /// Creates the stride-aware fast indexer used by the grid hot path. - /// - /// - /// OpenCvSharp's unsafe indexer skips per-access type and bounds checks, but retains the matrix row steps so it also - /// works for non-contiguous submatrices. Validate the matrix contract once before entering the pixel loops. - /// - private static Mat.UnsafeIndexer GetByteGridIndexer(Mat mat, string name) - { - if (mat.Empty() || mat.Dims != 2 || mat.Type() != MatType.CV_8UC1) - { - throw new InvalidOperationException( - $"Inventory {name} must be a non-empty two-dimensional CV_8UC1 matrix." - ); - } - - return mat.GetUnsafeGenericIndexer(); - } - - private void SmoothJaggedLines(Mat mat, bool horizontal) - { - using var extendedLines = new Mat(); - using var thickenedLines = new Mat(); - - var size = (int)(ProcessingConfig.ScaledSlotSize * 2); - size = size - (size % 2) + 1; - var extendStructureSize = horizontal ? new[] { 1, size } : new[] { size, 1 }; - using var extendStructure = new Mat( - extendStructureSize[0], - extendStructureSize[1], - MatType.CV_8U, - Scalar.All(1) - ); - - var thickenStructureSize = horizontal ? new[] { 3, 1 } : new[] { 1, 3 }; - using var thickenStructure = new Mat( - thickenStructureSize[0], - thickenStructureSize[1], - MatType.CV_8U, - Scalar.All(1) - ); - - Cv2.Dilate(mat, extendedLines, extendStructure, null, 10); - Cv2.Dilate(mat, thickenedLines, thickenStructure); - - Cv2.BitwiseAnd(extendedLines, thickenedLines, mat); - } - - private Mat CombineWithoutPeeks(Mat verticalLines, Mat horizontalLines) - { - using var holes = new Mat(); - using var vLinesWithHoles = new Mat(); - using var hLinesWithHoles = new Mat(); - - Cv2.BitwiseAnd(verticalLines, horizontalLines, holes); - Cv2.BitwiseXor(verticalLines, holes, vLinesWithHoles); - Cv2.BitwiseXor(horizontalLines, holes, hLinesWithHoles); - - var scaledSlotSize = (int)(ProcessingConfig.ScaledSlotSize / 2); - var size = scaledSlotSize - (scaledSlotSize % 2) + 1; - - using var vStructure = new Mat(size, 1, MatType.CV_8U, Scalar.All(1)); - Cv2.Erode(vLinesWithHoles, vLinesWithHoles, vStructure); - Cv2.Dilate(vLinesWithHoles, vLinesWithHoles, vStructure); - - using var hStructure = new Mat(1, size, MatType.CV_8U, Scalar.All(1)); - Cv2.Erode(hLinesWithHoles, hLinesWithHoles, hStructure); - Cv2.Dilate(hLinesWithHoles, hLinesWithHoles, hStructure); - - var grid = new Mat(); - Cv2.BitwiseOr(vLinesWithHoles, hLinesWithHoles, grid); - Cv2.BitwiseOr(grid, holes, grid); - return grid; - } - - /// - /// If the position at the indexer is part of a rectangle, it will be added to - /// - /// Stride-aware byte indexer of the binary grid mat - /// Number of rows in the grid - /// Number of columns in the grid - /// X position of the assumed icon - /// Y position of the assumed icon - /// if it is a valid icon, else - private bool TryAddIcon(Mat.UnsafeIndexer indexer, int rows, int cols, int x, int y) - { - /* - * The idea is, that we walk along the most inner - * edge of the assumed rectangle. If we do not find - * a left turn before reaching the end of our image, - * we know that the assumed rectangle was indeed not - * a rectangle and we return false. - * - * There are some optimizations to reduce the amount - * of loop iterations down to a minimum - */ - - Vector2 bottomLeft = null; - Vector2 bottomRight = null; - Vector2 topRight = null; - Vector2 topLeft = null; - - int a, - b, - c, - d, - e; - - // Go south - for (a = y; a < rows; a++) - { - if (indexer[a, x] == 0x00) - return false; - if (indexer[a, x + 1] == 0xFF) - { - bottomLeft = new Vector2(x, a); - break; - } - - if (!(a + 1 < rows)) - return false; - } - - // Go east - for (b = x + 1; b < cols; b++) - { - if (indexer[a, b] == 0x00) - return false; - if (indexer[a - 1, b] == 0xFF) - { - bottomRight = new Vector2(b, a); - break; - } - - if (!(b + 1 < cols)) - return false; - } - - // Go north - for (c = a - 1; c > 0; c--) - { - if (indexer[c, b] == 0x00) - return false; - if (indexer[c, b - 1] == 0xFF) - { - topRight = new Vector2(b, c); - break; - } - - if (!(c - 1 > 0)) - return false; - } - - // Go west - for (d = b - 1; d >= x; d--) - { - if (indexer[c, d] == 0x00) - return false; - if (indexer[c + 1, d] == 0xFF) - { - topLeft = new Vector2(d, c); - break; - } - - if (!(d - 1 >= x)) - return false; - } - - // Go south to origin - for (e = c + 1; e <= y; e++) - { - if (indexer[e, d] == 0x00) - return false; - if (!(e == y && d == x)) - continue; - - var size = bottomRight - topLeft + Vector2.One; - - var altWidth = topRight.X - bottomLeft.X + 1; - var altHeight = bottomLeft.Y - topRight.Y + 1; - - if (size != new Vector2(altWidth, altHeight)) - return false; - if (size == new Vector2(2, 2)) - return false; - if (_icons.Any(i => i.Position == topLeft)) - return false; - - // Expand the rect by a small bit to make sure the icon is included - var scaledSlotSize = (int)_config.ProcessingConfig.ScaledSlotSize; - var scaledSlotSizeVec = new Vector2(scaledSlotSize, scaledSlotSize); - topLeft -= scaledSlotSizeVec / 8; - size += scaledSlotSizeVec / 4; - - using var image = _image.ToBitmap(); - var icon = image.Crop(topLeft.X, topLeft.Y, size.X, size.Y); - _icons.Add(new Icon(icon, topLeft, size, _config)); - return true; - } - - return false; - } - - /// - /// Try to locate a icon at a given position - /// - /// Position at which to locate the icon. = center - /// if no icon was found - /// corresponds top left corner of the image - public Icon LocateIcon(Vector2 position = null) - { - SatisfyState(State.GridParsed); - - if (position == null) - position = new Vector2(_grid.Size()) / 2; - - if (_config.ProcessingConfig.InventoryConfig.OptimizeHighlighted) - return LocateIconHighlighted(position); - - if (Config.LogDebug) - { - Logger.LogDebugMat(_grid, "inventory/_grid"); - - using var debugGrid = new Mat(); - Cv2.CvtColor(_grid.Clone(), debugGrid, ColorConversionCodes.GRAY2BGR); - for (var i = 0; i < _icons.Count; i++) - { - var icon = _icons[i]; - var color = Scalar.RandomColor(); - for (var j = 0; j <= 20; j++) - { - var inc = Vector2.One * j; - var rect = new Rect(icon.Position + inc, icon.Size - inc * 2); - debugGrid.Rectangle(rect, color.Mul(new Scalar(j / 20f, j / 20f, j / 20f))); - } - } - Logger.LogDebugMat(debugGrid, "inventory/iconRects"); - } - - foreach (var icon in _icons) - { - if (position.X <= icon.Position.X || position.Y <= icon.Position.Y) - continue; - - var bottomRight = icon.Position + icon.Size; - if (position.X < bottomRight.X && position.Y < bottomRight.Y) - { - return icon; - } - } - - return null; - } - - private Icon LocateIconHighlighted(Vector2 position) - { - if (_boundingBoxes.Count == 0) - return null; - - for (var i = 0; i < _boundingBoxes.Count; i++) - { - var bb = _boundingBoxes[i]; - if (!bb.Contains(position)) - continue; - - // Compensate more in height as the top and bottom text often interfere - var scaledSlotSize = (int)_config.ProcessingConfig.ScaledSlotSize; - bb.X -= scaledSlotSize / 8; - bb.Y -= scaledSlotSize / 4; - bb.Width += scaledSlotSize / 4; - bb.Height += scaledSlotSize / 2; - - if (Config.LogDebug) - { - using var debugMat = _image.Clone(); - debugMat.Rectangle(bb, new(255, 128, 0)); - Logger.LogDebugMat(debugMat, "inventory/icon"); - } - - using var image = _image.ToBitmap(); - var iconImage = image.Crop(bb.X, bb.Y, bb.Width, bb.Height); - var icon = new Icon(iconImage, new(bb.X, bb.Y), new(bb.Width, bb.Height), _config); - _icons = new List { icon }; - return icon; - } - return null; - } - - /// - /// Inventory destructor - /// - public void Dispose() - { - if (_disposed) - return; - - foreach (Icon icon in _icons) - icon.Dispose(); - _image.Dispose(); - _grid?.Dispose(); - _vertGrid?.Dispose(); - _disposed = true; - GC.SuppressFinalize(this); - } - } -} diff --git a/src/ScanEngine/Processing/MultiInspection.cs b/src/ScanEngine/Processing/MultiInspection.cs deleted file mode 100644 index 551cf4ee..00000000 --- a/src/ScanEngine/Processing/MultiInspection.cs +++ /dev/null @@ -1,162 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Drawing; -using System.Linq; -using OpenCvSharp; -using OpenCvSharp.Extensions; -using Point = OpenCvSharp.Point; - -namespace RatEye.Processing -{ - /// - /// Represents multiple - /// - public class MultiInspection - { - private readonly Config _config; - private readonly Bitmap _image; - - private Config.Path PathConfig => _config.PathConfig; - private Config.Processing ProcessingConfig => _config.ProcessingConfig; - private Config.Processing.Inspection InspectionConfig => ProcessingConfig.InspectionConfig; - - // Backing property fields - private List _inspections; - - /// - /// List of all inspections found in the image - /// - public List Inspections - { - get - { - SatisfyState(State.SearchedMarkers); - return _inspections; - } - private set => _inspections = value; - } - - /// - /// Constructor for MultiInspection view processing object - /// - /// Image of the multiInspection view which will be processed - /// The config to use for this instance> - /// Provided image has to be in RGB - internal MultiInspection(Bitmap image, Config config) - { - _config = config; - _image = image; - } - - #region Processing state handling - - private enum State - { - Default, - SearchedMarkers, - } - - private State _currentState = State.Default; - - private void SatisfyState(State targetState) - { - while (_currentState < targetState) - { - switch (_currentState + 1) - { - case State.Default: - break; - case State.SearchedMarkers: - SearchMarker(); - break; - default: - throw new Exception("Cannot satisfy unknown state."); - } - - _currentState++; - } - } - - #endregion - - /// - /// Search for all different marker types and pick the best matching one - /// - private void SearchMarker() - { - SatisfyState(State.Default); - - using Bitmap marker = GetScaledMarker(); - var markers = GetMarkerPositions(marker); - _inspections = markers - .Select(match => new Inspection(_image, _config, match.position, match.confidence)) - .ToList(); - } - - /// - /// Identify the give marker inside the source - /// - /// The marker template to identify - /// Provided marker has to be in RGB - /// List of markers which confidence is above - private List<(Vector2 position, float confidence)> GetMarkerPositions(Bitmap marker) - { - using var refMat = _image.ToMat(); - using var tplMat = marker.ToMat(); // tpl = template - using var res = new Mat(refMat.Rows - tplMat.Rows + 1, refMat.Cols - tplMat.Cols + 1, MatType.CV_32FC1); - - // Gray scale both reference and template image - using var gref = refMat.CvtColor(ColorConversionCodes.RGB2GRAY); - using var gtpl = tplMat.CvtColor(ColorConversionCodes.RGB2GRAY); - - Cv2.MatchTemplate(gref, gtpl, res, TemplateMatchModes.CCoeffNormed); - - return ExtractMarkerPeaks(res, marker.Size, InspectionConfig.MarkerThreshold); - } - - internal static List<(Vector2 position, float confidence)> ExtractMarkerPeaks( - Mat response, - System.Drawing.Size markerSize, - float threshold - ) - { - var matches = new List<(Vector2 position, float confidence)>(); - while (true) - { - Cv2.MinMaxLoc(response, out _, out double maxValue, out _, out Point maxLocation); - if (maxValue < threshold) - break; - - matches.Add((new Vector2(maxLocation), (float)maxValue)); - - int left = Math.Max(0, maxLocation.X - markerSize.Width / 2); - int top = Math.Max(0, maxLocation.Y - markerSize.Height / 2); - int right = Math.Min(response.Width, maxLocation.X + markerSize.Width / 2 + 1); - int bottom = Math.Min(response.Height, maxLocation.Y + markerSize.Height / 2 + 1); - using Mat suppressionRegion = response[new Rect(left, top, right - left, bottom - top)]; - suppressionRegion.SetTo(Scalar.All(-1)); - } - - return matches; - } - - /// - /// Generate a marker bitmap - /// - /// is already accounted for. - /// A rescaled and alpha blended version of - private Bitmap GetScaledMarker() - { - Bitmap output = InspectionConfig.Marker.Rescale(InspectionConfig.MarkerItemScale * ProcessingConfig.Scale); - try - { - return output.TransparentToColor(InspectionConfig.MarkerBackgroundColor); - } - finally - { - if (!ReferenceEquals(output, InspectionConfig.Marker)) - output.Dispose(); - } - } - } -} diff --git a/src/ScanEngine/Properties/InternalsVisibleTo.cs b/src/ScanEngine/Properties/InternalsVisibleTo.cs deleted file mode 100644 index f101d560..00000000 --- a/src/ScanEngine/Properties/InternalsVisibleTo.cs +++ /dev/null @@ -1,3 +0,0 @@ -using System.Runtime.CompilerServices; - -[assembly: InternalsVisibleTo("RatScanner.Tests")] diff --git a/src/ScanEngine/Properties/Resources.Designer.cs b/src/ScanEngine/Properties/Resources.Designer.cs deleted file mode 100644 index 5663631c..00000000 --- a/src/ScanEngine/Properties/Resources.Designer.cs +++ /dev/null @@ -1,303 +0,0 @@ -//------------------------------------------------------------------------------ -// -// This code was generated by a tool. -// Runtime Version:4.0.30319.42000 -// -// Changes to this file may cause incorrect behavior and will be lost if -// the code is regenerated. -// -//------------------------------------------------------------------------------ - -namespace RatEye.Properties { - using System; - - - /// - /// A strongly-typed resource class, for looking up localized strings, etc. - /// - // This class was auto-generated by the StronglyTypedResourceBuilder - // class via a tool like ResGen or Visual Studio. - // To add or remove a member, edit your .ResX file then rerun ResGen - // with the /str option, or rebuild your VS project. - [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "16.0.0.0")] - [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] - [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] - internal class Resources { - - private static global::System.Resources.ResourceManager resourceMan; - - private static global::System.Globalization.CultureInfo resourceCulture; - - [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] - internal Resources() { - } - - /// - /// Returns the cached ResourceManager instance used by this class. - /// - [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] - internal static global::System.Resources.ResourceManager ResourceManager { - get { - if (object.ReferenceEquals(resourceMan, null)) { - global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("RatEye.Properties.Resources", typeof(Resources).Assembly); - resourceMan = temp; - } - return resourceMan; - } - } - - /// - /// Overrides the current thread's CurrentUICulture property for all - /// resource lookups using this strongly typed resource class. - /// - [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] - internal static global::System.Globalization.CultureInfo Culture { - get { - return resourceCulture; - } - set { - resourceCulture = value; - } - } - - /// - /// Looks up a localized resource of type System.Byte[]. - /// - internal static byte[] cell_full_border { - get { - object obj = ResourceManager.GetObject("cell_full_border", resourceCulture); - return ((byte[])(obj)); - } - } - - /// - /// Looks up a localized resource of type System.Byte[]. - /// - internal static byte[] icon_mod_aux { - get { - object obj = ResourceManager.GetObject("icon_mod_aux", resourceCulture); - return ((byte[])(obj)); - } - } - - /// - /// Looks up a localized resource of type System.Byte[]. - /// - internal static byte[] icon_mod_barrel { - get { - object obj = ResourceManager.GetObject("icon_mod_barrel", resourceCulture); - return ((byte[])(obj)); - } - } - - /// - /// Looks up a localized resource of type System.Byte[]. - /// - internal static byte[] icon_mod_bipod { - get { - object obj = ResourceManager.GetObject("icon_mod_bipod", resourceCulture); - return ((byte[])(obj)); - } - } - - /// - /// Looks up a localized resource of type System.Byte[]. - /// - internal static byte[] icon_mod_charge { - get { - object obj = ResourceManager.GetObject("icon_mod_charge", resourceCulture); - return ((byte[])(obj)); - } - } - - /// - /// Looks up a localized resource of type System.Byte[]. - /// - internal static byte[] icon_mod_flashlight { - get { - object obj = ResourceManager.GetObject("icon_mod_flashlight", resourceCulture); - return ((byte[])(obj)); - } - } - - /// - /// Looks up a localized resource of type System.Byte[]. - /// - internal static byte[] icon_mod_gasblock { - get { - object obj = ResourceManager.GetObject("icon_mod_gasblock", resourceCulture); - return ((byte[])(obj)); - } - } - - /// - /// Looks up a localized resource of type System.Byte[]. - /// - internal static byte[] icon_mod_handguard { - get { - object obj = ResourceManager.GetObject("icon_mod_handguard", resourceCulture); - return ((byte[])(obj)); - } - } - - /// - /// Looks up a localized resource of type System.Byte[]. - /// - internal static byte[] icon_mod_ironsight { - get { - object obj = ResourceManager.GetObject("icon_mod_ironsight", resourceCulture); - return ((byte[])(obj)); - } - } - - /// - /// Looks up a localized resource of type System.Byte[]. - /// - internal static byte[] icon_mod_launcher { - get { - object obj = ResourceManager.GetObject("icon_mod_launcher", resourceCulture); - return ((byte[])(obj)); - } - } - - /// - /// Looks up a localized resource of type System.Byte[]. - /// - internal static byte[] icon_mod_lightlaser { - get { - object obj = ResourceManager.GetObject("icon_mod_lightlaser", resourceCulture); - return ((byte[])(obj)); - } - } - - /// - /// Looks up a localized resource of type System.Byte[]. - /// - internal static byte[] icon_mod_magazine { - get { - object obj = ResourceManager.GetObject("icon_mod_magazine", resourceCulture); - return ((byte[])(obj)); - } - } - - /// - /// Looks up a localized resource of type System.Byte[]. - /// - internal static byte[] icon_mod_mount { - get { - object obj = ResourceManager.GetObject("icon_mod_mount", resourceCulture); - return ((byte[])(obj)); - } - } - - /// - /// Looks up a localized resource of type System.Byte[]. - /// - internal static byte[] icon_mod_muzzle { - get { - object obj = ResourceManager.GetObject("icon_mod_muzzle", resourceCulture); - return ((byte[])(obj)); - } - } - - /// - /// Looks up a localized resource of type System.Byte[]. - /// - internal static byte[] icon_mod_pistol_grip { - get { - object obj = ResourceManager.GetObject("icon_mod_pistol_grip", resourceCulture); - return ((byte[])(obj)); - } - } - - /// - /// Looks up a localized resource of type System.Byte[]. - /// - internal static byte[] icon_mod_railcovers { - get { - object obj = ResourceManager.GetObject("icon_mod_railcovers", resourceCulture); - return ((byte[])(obj)); - } - } - - /// - /// Looks up a localized resource of type System.Byte[]. - /// - internal static byte[] icon_mod_receiver { - get { - object obj = ResourceManager.GetObject("icon_mod_receiver", resourceCulture); - return ((byte[])(obj)); - } - } - - /// - /// Looks up a localized resource of type System.Byte[]. - /// - internal static byte[] icon_mod_sight { - get { - object obj = ResourceManager.GetObject("icon_mod_sight", resourceCulture); - return ((byte[])(obj)); - } - } - - /// - /// Looks up a localized resource of type System.Byte[]. - /// - internal static byte[] icon_mod_stock { - get { - object obj = ResourceManager.GetObject("icon_mod_stock", resourceCulture); - return ((byte[])(obj)); - } - } - - /// - /// Looks up a localized resource of type System.Byte[]. - /// - internal static byte[] icon_mod_tactical { - get { - object obj = ResourceManager.GetObject("icon_mod_tactical", resourceCulture); - return ((byte[])(obj)); - } - } - - /// - /// Looks up a localized resource of type System.Byte[]. - /// - internal static byte[] icon_search { - get { - object obj = ResourceManager.GetObject("icon_search", resourceCulture); - return ((byte[])(obj)); - } - } - - /// - /// Looks up a localized resource of type System.Byte[]. - /// - internal static byte[] mod_gear { - get { - object obj = ResourceManager.GetObject("mod_gear", resourceCulture); - return ((byte[])(obj)); - } - } - - /// - /// Looks up a localized resource of type System.Byte[]. - /// - internal static byte[] mod_generic { - get { - object obj = ResourceManager.GetObject("mod_generic", resourceCulture); - return ((byte[])(obj)); - } - } - - /// - /// Looks up a localized resource of type System.Byte[]. - /// - internal static byte[] mod_vital { - get { - object obj = ResourceManager.GetObject("mod_vital", resourceCulture); - return ((byte[])(obj)); - } - } - } -} diff --git a/src/ScanEngine/Properties/Resources.resx b/src/ScanEngine/Properties/Resources.resx deleted file mode 100644 index 9a86d5cf..00000000 --- a/src/ScanEngine/Properties/Resources.resx +++ /dev/null @@ -1,193 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - text/microsoft-resx - - - 2.0 - - - System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - - System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - - - ../Resources/cell_full_border.png;System.Byte[], mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - - ../Resources/icon_mod/icon_mod_aux.png;System.Byte[], mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - - ../Resources/icon_mod/icon_mod_barrel.png;System.Byte[], mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - - ../Resources/icon_mod/icon_mod_bipod.png;System.Byte[], mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - - ../Resources/icon_mod/icon_mod_charge.png;System.Byte[], mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - - ../Resources/icon_mod/icon_mod_flashlight.png;System.Byte[], mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - - ../Resources/icon_mod/icon_mod_gasblock.png;System.Byte[], mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - - ../Resources/icon_mod/icon_mod_handguard.png;System.Byte[], mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - - ../Resources/icon_mod/icon_mod_ironsight.png;System.Byte[], mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - - ../Resources/icon_mod/icon_mod_launcher.png;System.Byte[], mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - - ../Resources/icon_mod/icon_mod_lightlaser.png;System.Byte[], mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - - ../Resources/icon_mod/icon_mod_magazine.png;System.Byte[], mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - - ../Resources/icon_mod/icon_mod_mount.png;System.Byte[], mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - - ../Resources/icon_mod/icon_mod_muzzle.png;System.Byte[], mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - - ../Resources/icon_mod/icon_mod_pistol_grip.png;System.Byte[], mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - - ../Resources/icon_mod/icon_mod_railcovers.png;System.Byte[], mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - - ../Resources/icon_mod/icon_mod_receiver.png;System.Byte[], mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - - ../Resources/icon_mod/icon_mod_sight.png;System.Byte[], mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - - ../Resources/icon_mod/icon_mod_stock.png;System.Byte[], mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - - ../Resources/icon_mod/icon_mod_tactical.png;System.Byte[], mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - - ../Resources/icon_search.png;System.Byte[], mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - - ../Resources/icon_type/mod_gear.png;System.Byte[], mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - - ../Resources/icon_type/mod_generic.png;System.Byte[], mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - - ../Resources/icon_type/mod_vital.png;System.Byte[], mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - diff --git a/src/ScanEngine/RatEye.csproj b/src/ScanEngine/RatEye.csproj deleted file mode 100644 index 9deb4de8..00000000 --- a/src/ScanEngine/RatEye.csproj +++ /dev/null @@ -1,51 +0,0 @@ - - - - netstandard2.0 - 9 - RatScanner - Blightbuster - Image processing library for Escape from Tarkov - - false - false - https://ratscanner.com/ - https://github.com/RatScanner/RatEye.git - RatLogo.png - git - true - - $(NoWarn);1591 - - - - - - - - - - - - - - - - - True - True - Resources.resx - - - - - - ResXFileCodeGenerator - Resources.Designer.cs - - - - - - - diff --git a/src/ScanEngine/RatEyeEngine.cs b/src/ScanEngine/RatEyeEngine.cs deleted file mode 100644 index fe63b7e7..00000000 --- a/src/ScanEngine/RatEyeEngine.cs +++ /dev/null @@ -1,81 +0,0 @@ -using System.Drawing; -using RatEye.Processing; -using Inventory = RatEye.Processing.Inventory; - -namespace RatEye -{ - /// - /// Core class which allows creating new processing objects - /// - public class RatEyeEngine : System.IDisposable - { - private bool _disposed; - - /// - /// The config which is used for this - /// - /// Do not modify this config object - public Config Config { get; } - - /// - /// Create a instance which is the basis of all processing - /// - /// The config to use for this instance - /// The which contains all matchable items. For example, if no quest items should be matched, pass a previously filtered . - /// Do not modify the config after passing it - public RatEyeEngine(Config config, RatStash.Database itemDatabase) - { - Config = config; - - config.RatStashDB = itemDatabase; - - System.IO.Directory.CreateDirectory(config.PathConfig.CacheDir); - - config.ProcessingConfig.InspectionConfig.EnsureMarker(); - Config.IconManager = new IconManager(config); - } - - /// - /// Create new instance - /// - /// The image to process - public MultiInspection NewMultiInspection(Bitmap image) - { - return new MultiInspection(image, Config); - } - - /// - /// Create new instance - /// - /// The image to process - public Inspection NewInspection(Bitmap image) - { - return new Inspection(image, Config); - } - - /// - /// Create new instance - /// - /// The image to process - public Inventory NewInventory(Bitmap image) - { - return new Inventory(image, Config); - } - - public void Dispose() - { - if (_disposed) - return; - - Config.IconManager?.Dispose(); - Config.ProcessingConfig.InspectionConfig.TesseractEngine?.Dispose(); - Config.ProcessingConfig.InspectionConfig.TesseractEngine = null; - Config.ProcessingConfig.IconConfig.TesseractEngine?.Dispose(); - Config.ProcessingConfig.IconConfig.TesseractEngine = null; - Config.ProcessingConfig.InspectionConfig.Marker?.Dispose(); - Config.ProcessingConfig.InspectionConfig.Marker = null; - _disposed = true; - System.GC.SuppressFinalize(this); - } - } -} diff --git a/src/ScanEngine/RatLogo.png b/src/ScanEngine/RatLogo.png deleted file mode 100644 index a1fc809753354b5175077a44bd39f603db1aa4bc..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 23038 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 BS~36t diff --git a/src/ScanEngine/Resources/icon_mod/icon_mod_barrel.png b/src/ScanEngine/Resources/icon_mod/icon_mod_barrel.png deleted file mode 100644 index eddc05fa990fd08ec3dcb1ca85063f0a540d9349..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 252 zcmeAS@N?(olHy`uVBq!ia0vp^!a&T&!3HGHN~m`NDaPU;cPEB*=VV?2IV|apzK#qG z8~eHcB(eheoCO|{#Xt>*L74G){)!Z!;6zUs#}EtuWC@`L*^Z?R5!@YL7fKunJKX4) zZFLQfjVdm5N6dsY=Jzopr0HWwuBme*a diff --git a/src/ScanEngine/Resources/icon_mod/icon_mod_bipod.png b/src/ScanEngine/Resources/icon_mod/icon_mod_bipod.png deleted file mode 100644 index 18072824344caaec1c9c5ca2d46a16cfdad74bf6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 280 zcmeAS@N?(olHy`uVBq!ia0vp^AT~D#8<2F%laT;YjKx9jP7LeL$-D$|SkfJR9T^xl z_H+M9WCij$3p^r=85sBugD~Uq{1quc!L^<)jv*Ssr4u%C9WoGc-ThVmPXm|UKgasR zYvms->gHJ5?7Xm~AUH-v!Q#cEMa|9s#qM)%cjS$;c=BwY_@wD?oOc=;sWJIa>o{~K zgLR%xVB(ej=ck$DA{SlAxO4Qr+r6vv&V}<=q{ctGxOC%v**>`srye=X*l1|37rXy! zL%5r+iE(7=j$Jn|6fjpVkD6h}voT^_+o33n)WQ`H|8rQ_idJm;G5=2gLlN!Y^L0+& ZPv2p#dh6VlTA;%jJYD@<);T3K0RYO>ZW#ao diff --git a/src/ScanEngine/Resources/icon_mod/icon_mod_charge.png b/src/ScanEngine/Resources/icon_mod/icon_mod_charge.png deleted file mode 100644 index d7c168a0b1b36de7e47d237f53fdf028cfa56def..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 283 zcmeAS@N?(olHy`uVBq!ia0vp^!a&T=!3HGRT)yZ4DaPU;cPEB*=VV?2IV|apzK#qG z8~eHcB(eheoCO|{#S9F5he4R}c>anMpx_2i7sn6_|73|v4YD0e8zQ(nzAiNB;1^Pt zY%pnKF60*Y?)cI|A&fm~4%?#L3~hfKDh^GPt^Rk|V1hk=ha+3lm1NczdJPX}I_web z_}=v4Rq5=1%?1;AgV;AoXft?jITO3#i@hGtl>6OB*dkeqEW;Sg*5n_W@_&axK%Ksj z^!h}X%FXK*KDFmyzFD!i)0~N``_zA++5QGh1&k%)%^6Le%;FB^h8=7lJ` z!ahIUO+xP@5{pWA-mPW0^J~uH;O?xq6;{;>rJ}FQ+|z%(TXy%~1LgnsBDg!gPBiG?7h*s8 zm_>p0p~D@fBefn9N7y@?pA<4p;MI*Td-Jou&+$<}5584w+6=8(@v+XQ>vJSj z*h8B|8*=q@wEp+=Ha}UAwSg(-qDx4~LoWpd9wFwF4}DdfnG6gV7^Y{W>mKsh&jNHX NgQu&X%Q~loCIG>fT}}W1 diff --git a/src/ScanEngine/Resources/icon_mod/icon_mod_handguard.png b/src/ScanEngine/Resources/icon_mod/icon_mod_handguard.png deleted file mode 100644 index ec687f8579a09afdaae56dff9bcfeab332a6b2df..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 257 zcmeAS@N?(olHy`uVBq!ia0vp^0zk~i!3HGN^yhQ|DaPU;cPEB*=VV?2IV|apzK#qG z8~eHcB(eheoCO|{#Xt>*L74G){)!Z!;51Jc#}EtuWQmXl*^Z?R5!@YLHzrK5@Q_== zr_JEm#$3oP@ICRMLg54(57{t=O$$>`_u2k0Oq^g70^}B@-S0A${`dHhLgfmpH4Hfi zzC?a@;QIb(0jKnZOV|F&i?f|{zM9DTz(7N9?H~R_4&S&G<}ojtc;rJHW06infssJ9 xbLI?*dl3$P>xqTPvmaJLyvGn6b zpE8Aw;_JrwTq6k{p0*+={uENcy@jLY@c*P*887UWm-rbpYmVr q*w-@;PVz~`_j~`J-7IE*dcQDdu8;Hg7&D+(7(8A5T-G@yGywpB$8tvi diff --git a/src/ScanEngine/Resources/icon_mod/icon_mod_launcher.png b/src/ScanEngine/Resources/icon_mod/icon_mod_launcher.png deleted file mode 100644 index 8d260b9ca3e3ca772b357e920860ef23056979fd..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 251 zcmeAS@N?(olHy`uVBq!ia0vp^JV4CJ!3HGRcAO0XQjEnx?oJHr&dIz4a#+$GeH|GX zHuiJ>Nn{17EbxddW?yFZX=&s!KC%M}9cSEVCD3fz=#z1CQ}X$Gg}st4i<1}puu)*kWRm3Dwk`kY zEMA4qy$Vv&Y^NG?fI=4yYOmRBU=!%&ZNB1g)yZVbmeOX^hKw8u4fgGfZ`vPS@|y4< pO`x~0@sUQIg5?yC4t6#+hIx|3?MnJb_5$6;;OXk;vd$@?2>_3~P-XxC diff --git a/src/ScanEngine/Resources/icon_mod/icon_mod_lightlaser.png b/src/ScanEngine/Resources/icon_mod/icon_mod_lightlaser.png deleted file mode 100644 index 7707e8c302f867d61c7530c4c3f7713c0c2a727d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 334 zcmeAS@N?(olHy`uVBq!ia0vp@Ak4uAB#T}@sR2@q#X;^)4C~IxyaaMs(j9#r85lP9 zbN@+X1@buyJR*x382Ao@Fyrz36)8Z$$DS^ZAsWH86AifzIS9C3u4Lzbz-rSo*ZhLp zmJS6jv8LA61Vs%qrHCa4O3^bmul?M1bAqoJyPa|UxAbT4)x2H&FNpK#9G-kpgpW1Z zF;8-yj6<@D!w$ii`|n<#Jod7` z!ahIUO+xP@5{pWA-mPW0^J~uH;O?xq6;{;>rJ}FQ+|z%(TXy%~1LguK=kd?46HU z64(MYTKI|pXJ*c|A;!=;XR_j>^N&9(}d5zuI1hNB`?c- z)Nxxg&oQn8Qqy(fnS*wSw=JxhaM|%w1Y^YGGnU-{7bj1!2$9of$bK{1fc2=G#{3P( zmQCyX|3X5AZ|ZTNuG8AtfjKRoK4!7FyT{d?tS{jaYCo0C`ht_8&9P?B$BubV5+sfs hpIyXenvlT2P_-uOd{U|M3ZNGlJYD@<);T3K0RW7|YJdO$ diff --git a/src/ScanEngine/Resources/icon_mod/icon_mod_mount.png b/src/ScanEngine/Resources/icon_mod/icon_mod_mount.png deleted file mode 100644 index 94d49e725b12638108ab7907e25727af2b641862..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 257 zcmeAS@N?(olHy`uVBq!ia0vp^JV4CJ!3HGRcAO0XQjEnx?oJHr&dIz4a#+$GeH|GX zHuiJ>Nn{17EbxddW?2;H$7zfLHrk&X0b6>2Rl=wV8)4lIaqU*~Tlh#}wquoo)N-HspXFN`-|RIoGvf*3zRG;@ z#=V=rAtF+;&PM}H!>*eAVNX=3*6-=%Wq$gwj-@@T&hcfv0NbWczR*pNC-zJ^e1L&L Y|NBIx6LN~8K<6`fy85}Sb4q9e0Qg~JMF0Q* diff --git a/src/ScanEngine/Resources/icon_mod/icon_mod_pistol_grip.png b/src/ScanEngine/Resources/icon_mod/icon_mod_pistol_grip.png deleted file mode 100644 index 7c0b511a13afa4175722595c566209e2b3a7eb5e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 302 zcmeAS@N?(olHy`uVBq!ia0vp^{6H+g!3HExhN-duDaPU;cPEB*=VV?2IV|apzK#qG z8~eHcB(eheoCO|{#S9F5he4R}c>anMpx|Lo7sn6_|Fe?}xehr9xL&Sgx0Ys=j?Pa? zf6RErsU;y%P(U!4(@aP}M?@uJ$%YSkkAF{URei8-DRY17zr1JXQ_O#MXE;j#GFTD* z^wQ#m&B`Yf)?YXLq7dca7x}ejX7e5f(SU~uE3W?ex8e+YF1s_wuKGFWr_cVmAU2W7 zu{?Lx(MJU~Gx?*g#7bw?az0JlZO;DF|DEg%=b7uSJ!@XJfBnUMZsM0-<|O)FSS)Y; v{?@{nstvhk*D@Df_`?UZum9bP0l+XkKr_y(C diff --git a/src/ScanEngine/Resources/icon_mod/icon_mod_railcovers.png b/src/ScanEngine/Resources/icon_mod/icon_mod_railcovers.png deleted file mode 100644 index e1faf9dfaf4d4ec4d740cb5f43d79c4ac5042c07..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 217 zcmeAS@N?(olHy`uVBq!ia0vp@K+MU(1|(lrEz1H@jKx9jP7LeL$-D$|SkfJR9T^xl z_H+M9WCf`#@Q5sCVBk9p!i>lBSEK+1^E_P~Lo|Yu8JNlxrZFYyDqK6>?D$EcRN-ra zn}xu3hC+Eco=NO0nOCyUzOX6v_`Lru1|G5@wl`kw6U{bjVEHbzz|pMfinEFDd{cfM z&jz=vS1w)Sou=Y&*tGF)NKDkv`2uV!n^&ppfAQ#1XDeeke7aiDv$ISEXfuPStDnm{ Hr-UW|TZ%<< diff --git a/src/ScanEngine/Resources/icon_mod/icon_mod_receiver.png b/src/ScanEngine/Resources/icon_mod/icon_mod_receiver.png deleted file mode 100644 index c4a602bd68f52707925d1fa94a1893d02f1d4c10..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 245 zcmeAS@N?(olHy`uVBq!ia0vp^LLfE=8<1S$XD11y7>k44ofy`glX(f`u%tWsIx;Y9 z?C1WI$O`0h7I;J!GcfQS24TkI`72U@f?b|2jv*HQ$r4u@WIL8NL~wU}y=dUUqrx8K zEYKj*#$3oP@O@$8ga{dd)y#&DhaI+U%elGnxxE?Bl-cY@Ryz1xFqk0zYno2{XL)h9 zQ^KhdHx}&U4F31{u!E_>VTXGxtgK-S-=Zp0-_|=xtXRj@an)hg2F9Ak-X6zK3Z)jY mE&9JE8hUyro?vEUW4N$2T`_dU+H|0+7(8A5T-G@yGywoDlu&a3 diff --git a/src/ScanEngine/Resources/icon_mod/icon_mod_sight.png b/src/ScanEngine/Resources/icon_mod/icon_mod_sight.png deleted file mode 100644 index e10b7b848cfd4d7c7760162a1eb5646639c77c59..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 281 zcmeAS@N?(olHy`uVBq!ia0vp^+#t-s1|(OmDOUqhjKx9jP7LeL$-D$|SkfJR9T^xl z_H+M9WCij$3p^r=85sBugD~Uq{1quc!F8T4jv*Ss$qr0!9CTQZL^-VCU+P#h!Geo{ za|LsujKFq=Lis6d9~~bR@EWwPbg;=k-DmqhQQ}B@*J0C!HB2)N^3S&EDzMJJm@$EE zf{lmlja*&s>1$ZD86JgtR2YZIJW-iowITi2GN1XM&!uyHYL-xp%Z|CCH zs5u{fsCM{nOz35XYYlFTcSx$5eB$={f7qadU-YbP0l+XkKECF9# diff --git a/src/ScanEngine/Resources/icon_mod/icon_mod_stock.png b/src/ScanEngine/Resources/icon_mod/icon_mod_stock.png deleted file mode 100644 index 2f8827789883d75cde7b0e46ccc8bc66dde091cb..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 294 zcmeAS@N?(olHy`uVBq!ia0vp^!a&T=!3HGRT)yZ4DaPU;cPEB*=VV?2IV|apzK#qG z8~eHcB(eheoCO|{#S9F5he4R}c>anMpx|y#7sn6_|6~cF2HB3K4H4WOUl(qX5|WSP zX>rVQtlGddA;m&qJ42!Tlyy#zl)D=ZJaj$y!x%PgO+DRb`#(|Q#QmN#?qbXt^6q@MBBbu&`Em z*`MQWjYSo_&5Uc>u9xTJJ(|YhC7^QTorDpczj4z z`+u`R2d`*zH1Fpgd4cnDjB3x-3$Sfm2-LqJJ%QoL>3!eM-TSY=ljMBVfFU9=)b}t% zT7u!t%0Eq)U@~Wd6<9SMw)0I$=4hE{bMOEIgZyr<*~e5D%>=ra!PC{xWt~$(698W6 BTNeNT diff --git a/src/ScanEngine/Resources/icon_search.png b/src/ScanEngine/Resources/icon_search.png deleted file mode 100644 index 363c40f06a286595533aac57bda70f9d68b17f84..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 771 zcmV+e1N{7nP)|%KokJbKb~ekLLOR z`5qJa!@>T8e?>|6T{oXirzby41gMy%S!0Zm zN4ebdOhvg+s5|ZUf{{wSPbL!CY%aG104uRT!!RZYA@U{&8s%c~ z^`LR(LZNy}>XH38hr32DvLZ z=iNiSU~CYDIFF)eR0uI~kKyxbt$7r+VuKOl4hh3Gjgac0>n=VimHMvZq3bRKKtAFe z!XT)}2Cu?!(?nd>G|gg+?KE8XVWm{s=*1GczK%jjF2sl6>S{AKND)<;vZ_%gZiCDfOwY4+ZNB?j0Q1mz~a+%qaCzra3z|Jv|g`P=-~`xulxbd63Br73&Mm z&CJ+u8x2n&z8*FT#Q`jQ>18+NdCaC0O~keYBS zWx_Epg?U^@_B!10WN5z9a3!qCM9h)(R>O`?2DX)qD|MMNSu7WFcSJYrXoLtzTdOR!u diff --git a/src/ScanEngine/Resources/icon_type/mod_generic.png b/src/ScanEngine/Resources/icon_type/mod_generic.png deleted file mode 100644 index f82c2cf22299bc9ad8c30fa869dfa10680018a09..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 225 zcmeAS@N?(olHy`uVBq!ia0vp^d_c^@!3HFQ8hUO4DaPU;cPEB*=VV?2IV|apzK#qG z8~eHcB(eheoCO|{#XwDmL74G){)!Z!V40_jV~9rZ(n}k;90DZT62&v^%o7q4Y(+S^ zQ%g+O^}H8N4XFRymVWW~(<7dj{&EC)>CJ3xGc)MfbW&?GkYThYWuj*lGjHzE3D@~A zdF3uxcy|873j04@_cJoqCo0M;oP5l|Ir@*(uKrj5EZ*IJxR0SvySzo1VZnwoQ@3pe Pd4j>y)z4*}Q$iB}YLZbc diff --git a/src/ScanEngine/Resources/icon_type/mod_vital.png b/src/ScanEngine/Resources/icon_type/mod_vital.png deleted file mode 100644 index 86dc8fbbb1ee1b7beef13ddcfd403f1682f9e0cf..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 200 zcmeAS@N?(olHy`uVBq!ia0vp^d_c^@!3HFQ8hUO4DaPU;cPEB*=VV?2IV|apzK#qG z8~eHcB(eheoCO|{#XwDmL74G){)!Z!V63N$V~9p@@{j!sl_fh?H|${P;O0n}AT{Au z%7kNH3iG&*>~*-~$ at tag `v4.0.1` -(`862b78002a59286aaed6d4d94dce87ce9a989462`). - -This project is referenced by `src/App` via `ProjectReference` so image-processing -changes land in the same PR as the app. Do not re-add the RatEye NuGet package. - -Test assets from upstream `RatEyeTest` are intentionally not included (large binaries). - -## License note - -The vendored `v4.0.1` tag did not contain a `LICENSE` file. The original author subsequently -published the RatEye terms in commit [`98f562f`](https://github.com/RatScanner/RatEye/commit/98f562f38b4a9c9d330ef700971d6adf5183595d) -("Add License"). Those terms permit copying, distribution, and derivative works subject to -their limitations and notice requirements. They are byte-for-byte identical to this -repository's root [`LICENSE`](../../LICENSE), which is included in published packages. - -This fork has modified the vendored sources after `v4.0.1`; this file and the repository -attribution are the prominent modification and provenance notices. Preserve them when -redistributing the scan engine. diff --git a/src/ScanEngine/Vector2.cs b/src/ScanEngine/Vector2.cs deleted file mode 100644 index 0f26671f..00000000 --- a/src/ScanEngine/Vector2.cs +++ /dev/null @@ -1,303 +0,0 @@ -using System; - -namespace RatEye -{ - /// - /// A 2D vector - /// - public class Vector2 - { - /// - /// The X value - /// - public int X; - - /// - /// The Y value - /// - public int Y; - - /// - /// Tuple to - /// - /// Tuple to convert - public Vector2((int, int) tuple) - : this(tuple.Item1, tuple.Item2) { } - - /// - /// to - /// - /// to convert - public Vector2(System.Drawing.Point point) - : this(point.X, point.Y) { } - - /// - /// to - /// - /// to convert - public Vector2(OpenCvSharp.Point point) - : this(point.X, point.Y) { } - - /// - /// to - /// - /// to convert - public Vector2(System.Drawing.Size size) - : this(size.Width, size.Height) { } - - /// - /// to - /// - /// to convert - public Vector2(OpenCvSharp.Size size) - : this(size.Width, size.Height) { } - - /// - /// Constructor for - /// - /// The X value - /// The Y value - public Vector2(int x, int y) - { - X = x; - Y = y; - } - - /// - /// Returns a new with X = 0 and Y = 0 - /// - public static Vector2 Zero => new(0, 0); - - /// - /// Returns a new with X = 1 and Y = 1 - /// - public static Vector2 One => new(1, 1); - - /// - /// to tuple - /// - /// to convert - public static implicit operator (int, int)(Vector2 vec) - { - return (vec.X, vec.Y); - } - - /// - /// to - /// - /// to convert - public static implicit operator System.Drawing.Point(Vector2 vec) - { - return new System.Drawing.Point(vec.X, vec.Y); - } - - /// - /// to - /// - /// to convert - public static implicit operator OpenCvSharp.Point(Vector2 vec) - { - return new OpenCvSharp.Point(vec.X, vec.Y); - } - - /// - /// to - /// - /// to convert - public static implicit operator System.Drawing.Size(Vector2 vec) - { - return new System.Drawing.Size(vec.X, vec.Y); - } - - /// - /// to - /// - /// to convert - public static implicit operator OpenCvSharp.Size(Vector2 vec) - { - return new OpenCvSharp.Size(vec.X, vec.Y); - } - - /// - /// Vector addition - /// - /// Vector A - /// Vector B - /// Vector A + Vector B - public static Vector2 operator +(Vector2 a, Vector2 b) - { - return new Vector2(a.X + b.X, a.Y + b.Y); - } - - /// - /// Vector addition with scalar - /// - /// Vector A - /// Scalar B - /// Vector A + Scalar B - public static Vector2 operator +(Vector2 a, int b) - { - return new Vector2(a.X + b, a.Y + b); - } - - /// - /// Vector subtraction - /// - /// Vector A - /// Vector B - /// Vector A - Vector B - public static Vector2 operator -(Vector2 a, Vector2 b) - { - return new Vector2(a.X - b.X, a.Y - b.Y); - } - - /// - /// Vector subtraction with scalar - /// - /// Vector A - /// Scalar B - /// Vector A - Scalar B - public static Vector2 operator -(Vector2 a, int b) - { - return new Vector2(a.X - b, a.Y - b); - } - - /// - /// Vector multiplication - /// - /// Vector A - /// Vector B - /// Vector A * Vector B - public static Vector2 operator *(Vector2 a, Vector2 b) - { - return new Vector2(a.X * b.X, a.Y * b.Y); - } - - /// - /// Vector multiplication with scalar - /// - /// Vector A - /// Scalar B - /// Vector A * Scalar B - public static Vector2 operator *(Vector2 a, int b) - { - return new Vector2(a.X * b, a.Y * b); - } - - /// - /// Vector multiplication with scalar - /// - /// Vector A - /// Scalar B - /// Vector A * Scalar B - public static Vector2 operator *(Vector2 a, float b) - { - return new Vector2((int)(a.X * b), (int)(a.Y * b)); - } - - /// - /// Vector division - /// - /// Vector A - /// Vector B - /// Vector A / Vector B - public static Vector2 operator /(Vector2 a, Vector2 b) - { - return new Vector2(a.X / b.X, a.Y / b.Y); - } - - /// - /// Vector division with scalar - /// - /// Vector A - /// Scalar B - /// Vector A / Scalar B - public static Vector2 operator /(Vector2 a, int b) - { - return new Vector2(a.X / b, a.Y / b); - } - - /// - /// Vector division with scalar - /// - /// Vector A - /// Scalar B - /// Vector A / Scalar B - public static Vector2 operator /(Vector2 a, float b) - { - return new Vector2((int)(a.X / b), (int)(a.Y / b)); - } - - /// - /// Vector equality - /// - /// Vector A - /// Vector B - /// True if vectors are equal - public static bool operator ==(Vector2 a, Vector2 b) - { - if (ReferenceEquals(a, b)) - return true; - if (a is null) - return false; - if (b is null) - return false; - return a.Equals(b); - } - - /// - /// Vector inequality - /// - /// Vector A - /// Vector B - /// True if vectors are not equal - public static bool operator !=(Vector2 a, Vector2 b) => !(a == b); - - /// - /// X * Y - /// - public int Area => X * Y; - - /// - /// This vector with x and y values swapped - /// - public Vector2 Flipped => new(Y, X); - - /// - /// Check vector equality - /// - /// Vector to compare - /// True if vectors are equal - public override bool Equals(object obj) - { - if ((obj == null) || GetType() != obj.GetType()) - return false; - var other = (Vector2)obj; - return X == other.X && Y == other.Y; - } - - /// - /// Get hash code of vector - /// - /// Hash code - public override int GetHashCode() - { - unchecked - { - var hash = (int)2166136261; - hash = (hash * 16777619) ^ X.GetHashCode(); - hash = (hash * 16777619) ^ Y.GetHashCode(); - return hash; - } - } - - /// - /// Get string representation of vector - /// - /// String representation - public override string ToString() - { - return $"({X}, {Y})"; - } - } -} diff --git a/tests/AGENTS.md b/tests/AGENTS.md index 61742b85..09c6e591 100644 --- a/tests/AGENTS.md +++ b/tests/AGENTS.md @@ -16,6 +16,7 @@ xUnit unit tests under `tests/RatScanner.Tests` that reference the App project. 6. Prefer realistic coverage of parsers, projections, presentation helpers, display config, and reliability guards over excessive mocking of the full scan path. 7. Assertions must make the regression clear (expected ids, keys, TTLs, status codes), not only “no exception”. 8. Implementation and this project file override stale docs; update this file when test policy changes. +9. RatEye engine internals, OpenCV processing, cache behavior, and replay benchmarks are tested in the RatEye submodule. Keep this project focused on App-owned capture geometry, integration, and presentation contracts. ## Prefer @@ -23,6 +24,7 @@ xUnit unit tests under `tests/RatScanner.Tests` that reference the App project. - Small focused facts with clear arrange/act/assert names matching current style. - When adding App APIs that are pure, add regression tests in the same PR. - Name files and types after the unit under test. +- Keep `ScanPipelineImageHarnessTests` here because it mirrors RatScanner capture/crop behavior; use RatEye replay manifests for engine-only fixture assertions. ## Validate diff --git a/tests/RatScanner.Tests/OpenCvPipelineTests.cs b/tests/RatScanner.Tests/OpenCvPipelineTests.cs deleted file mode 100644 index a02f4e24..00000000 --- a/tests/RatScanner.Tests/OpenCvPipelineTests.cs +++ /dev/null @@ -1,343 +0,0 @@ -#nullable enable - -using System; -using System.Collections.Generic; -using System.Drawing; -using System.IO; -using OpenCvSharp; -using OpenCvSharp.Extensions; -using RatEye; -using Xunit; - -namespace RatScanner.Tests; - -/// -/// Regression guards for native OpenCvSharp loading and core image ops used by ScanEngine. -/// Fixtures are synthetic bitmaps (not game assets) so they can ship in-repo without copyright risk. -/// -public class OpenCvPipelineTests -{ - [Fact] - public void Native_runtime_loads_and_supports_core_image_ops() - { - using Mat solid = new(48, 64, MatType.CV_8UC3, new Scalar(10, 20, 30)); - Assert.False(solid.Empty()); - Assert.Equal(64, solid.Width); - Assert.Equal(48, solid.Height); - Assert.Equal(MatType.CV_8UC3, solid.Type()); - - using Mat gray = solid.CvtColor(ColorConversionCodes.BGR2GRAY); - Assert.Equal(MatType.CV_8UC1, gray.Type()); - - using Mat resized = gray.Resize(new OpenCvSharp.Size(32, 24)); - Assert.Equal(32, resized.Width); - Assert.Equal(24, resized.Height); - - using Mat binary = resized.Threshold(0, 255, ThresholdTypes.Binary | ThresholdTypes.Otsu); - Assert.False(binary.Empty()); - } - - [Fact] - public void Bitmap_roundtrip_and_template_match_succeed() - { - // Solid black field with a unique gradient patch so correlation peaks at one location. - using Mat source = new(64, 64, MatType.CV_8UC3, new Scalar(0, 0, 0)); - for (int y = 0; y < 16; y++) - { - for (int x = 0; x < 16; x++) - source.Set(12 + y, 8 + x, new Vec3b((byte)(x * 8), (byte)(y * 8), (byte)((x + y) * 4))); - } - - using Mat template = source[new Rect(8, 12, 16, 16)].Clone(); - - using Bitmap roundTrip = BitmapConverter.ToBitmap(source); - using Mat fromBitmap = roundTrip.ToMat(); - Assert.False(fromBitmap.Empty()); - Assert.Equal(source.Width, fromBitmap.Width); - Assert.Equal(source.Height, fromBitmap.Height); - - using Mat result = source.MatchTemplate(template, TemplateMatchModes.SqDiffNormed); - result.MinMaxLoc(out double minVal, out _, out OpenCvSharp.Point minLoc, out _); - - Assert.True(minVal < 1e-6, $"Expected near-zero template difference, got {minVal:E3}"); - Assert.Equal(8, minLoc.X); - Assert.Equal(12, minLoc.Y); - } - - [Fact] - public void ImRead_writes_and_reads_temp_png() - { - string path = Path.Combine(Path.GetTempPath(), "RatScanner-ocv-" + Guid.NewGuid().ToString("N") + ".png"); - try - { - using (Mat mat = new(20, 20, MatType.CV_8UC3, new Scalar(40, 80, 120))) - { - Assert.True(Cv2.ImWrite(path, mat)); - } - - using Mat loaded = Cv2.ImRead(path, ImreadModes.Unchanged); - Assert.False(loaded.Empty()); - Assert.Equal(20, loaded.Width); - Assert.Equal(20, loaded.Height); - } - finally - { - if (File.Exists(path)) - File.Delete(path); - } - } - - [Fact] - public void Unsafe_byte_indexer_honors_non_contiguous_row_stride() - { - using Mat parent = new(8, 10, MatType.CV_8UC1, Scalar.Black); - parent.Set(4, 6, (byte)123); - using Mat submatrix = parent[new Rect(3, 2, 4, 5)]; - - Assert.False(submatrix.IsContinuous()); - Assert.Equal(MatType.CV_8UC1, submatrix.Type()); - - Mat.UnsafeIndexer indexer = submatrix.GetUnsafeGenericIndexer(); - Assert.Equal((byte)123, indexer[2, 3]); - - indexer[1, 2] = 77; - Assert.Equal((byte)77, parent.At(3, 5)); - } - - [Fact] - public void Highlighted_inventory_locates_and_crops_the_hovered_item() - { - using Bitmap screenshot = new(300, 200); - using (Graphics graphics = Graphics.FromImage(screenshot)) - { - graphics.Clear(System.Drawing.Color.Black); - using SolidBrush highlight = new(System.Drawing.Color.FromArgb(90, 90, 90)); - graphics.FillRectangle(highlight, 60, 40, 126, 63); - } - - Config config = CreateProcessingConfig(optimizeHighlighted: true); - using RatEyeEngine engine = new(config, RatStash.Database.FromItems([])); - using RatEye.Processing.Inventory inventory = engine.NewInventory(screenshot); - - RatEye.Processing.Icon? icon = inventory.LocateIcon(new Vector2(120, 70)); - - Assert.NotNull(icon); - Assert.Equal(new Vector2(53, 25), icon.Position); - Assert.Equal(new Vector2(142, 95), icon.Size); - } - - [Fact] - public void Normal_inventory_rejects_a_slot_scale_too_small_for_safe_edge_walking() - { - using Bitmap screenshot = new(32, 32); - Config config = CreateProcessingConfig(optimizeHighlighted: false); - config.ProcessingConfig.Scale = 0.01f; - using RatEyeEngine engine = new(config, RatStash.Database.FromItems([])); - using RatEye.Processing.Inventory inventory = engine.NewInventory(screenshot); - - InvalidOperationException exception = Assert.Throws(() => _ = inventory.Icons); - - Assert.Contains("at least two pixels", exception.Message, StringComparison.Ordinal); - } - - [Fact] - public void Blank_inspection_is_a_low_confidence_failure_without_ocr() - { - using Bitmap screenshot = new(320, 180); - Config config = CreateProcessingConfig(optimizeHighlighted: false); - using RatEyeEngine engine = new(config, RatStash.Database.FromItems([])); - - RatEye.Processing.Inspection inspection = engine.NewInspection(screenshot); - - Assert.False(inspection.ContainsMarker); - Assert.True(inspection.MarkerConfidence < config.ProcessingConfig.InspectionConfig.MarkerThreshold); - Assert.Null(inspection.Item); - Assert.Equal(0, inspection.ItemConfidence); - } - - [Theory] - [InlineData("Subject Search")] - [InlineData("subject search")] - [InlineData("SUBJECT SEARCH")] - [InlineData("Subject Searcn")] // minor OCR noise - [InlineData("Поиск предмета")] - [InlineData("Rechercher un objet")] - [InlineData("Buscar objeto")] - public void Inventory_subject_search_chrome_is_not_treated_as_item_title(string title) - { - Assert.True(RatEye.Processing.Inspection.IsUiChromeTitle(title)); - } - - [Theory] - [InlineData("Physical Bitcoin")] - [InlineData("Pack of sugar")] - [InlineData("Bolt-action sniper rifle Remington Model 700 7.62x51")] - [InlineData("Suchen")] // short DE token kept out of denylist; MinItemConfidence is the guard - [InlineData("")] - [InlineData(" ")] - public void Real_item_names_are_not_flagged_as_ui_chrome(string title) - { - Assert.False(RatEye.Processing.Inspection.IsUiChromeTitle(title)); - } - - [Fact] - public void Ui_chrome_title_similarity_scores_stay_below_min_item_confidence() - { - // Normed Levenshtein of short UI chrome vs a long catalog name is often - // nonzero; keep those scores below the configured acceptance threshold. - float threshold = CreateProcessingConfig( - optimizeHighlighted: false - ).ProcessingConfig.InspectionConfig.MinItemConfidence; - - Assert.True("Subject Search".NormedLevenshteinDistance("Physical Bitcoin") < threshold); - Assert.True("Subject Search".NormedLevenshteinDistance("Pack of sugar") < threshold); - } - - [Fact] - public void Inspection_reports_the_exact_missing_traineddata_file() - { - using Bitmap marker = CreateMarker(); - using Bitmap screenshot = new(120, 60); - using (Graphics graphics = Graphics.FromImage(screenshot)) - { - graphics.Clear(System.Drawing.Color.FromArgb(25, 27, 27)); - graphics.DrawImageUnscaled(marker, 10, 10); - } - - string missingDirectory = Path.Combine(Path.GetTempPath(), "RatEye-missing-" + Guid.NewGuid().ToString("N")); - Config config = CreateProcessingConfig(optimizeHighlighted: false); - config.PathConfig.TrainedData = missingDirectory; - config.ProcessingConfig.InspectionConfig.Marker.Dispose(); - config.ProcessingConfig.InspectionConfig.Marker = new Bitmap(marker); - config.ProcessingConfig.InspectionConfig.MarkerItemScale = 1; - config.ProcessingConfig.InspectionConfig.MarkerThreshold = 0.8f; - - RatStash.Item expected = new() - { - Id = "fixture", - Name = "Fixture item", - ShortName = "Fixture", - Width = 1, - Height = 1, - }; - using RatEyeEngine engine = new(config, RatStash.Database.FromItems([expected])); - RatEye.Processing.Inspection inspection = engine.NewInspection(screenshot); - Assert.True(inspection.ContainsMarker); - - FileNotFoundException exception = Assert.Throws(() => _ = inspection.Item); - Assert.Equal(Path.Combine(missingDirectory, "eng.traineddata"), exception.FileName); - } - - [Fact] - public void Static_icon_template_matching_identifies_an_exact_generated_fixture() - { - string root = Path.Combine(Path.GetTempPath(), "RatEye-template-test-" + Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(root); - try - { - string iconPath = Path.Combine(root, "fixture.png"); - using (Bitmap iconSource = new(64, 64)) - { - using Graphics graphics = Graphics.FromImage(iconSource); - graphics.Clear(System.Drawing.Color.Transparent); - using SolidBrush body = new(System.Drawing.Color.FromArgb(255, 35, 140, 210)); - graphics.FillRectangle(body, 8, 12, 41, 29); - using Mat iconMat = BitmapConverter.ToMat(iconSource); - Assert.True(Cv2.ImWrite(iconPath, iconMat)); - } - File.WriteAllText(Path.Combine(root, "broken.png"), "not an image"); - - RatStash.Item expected = new() - { - Id = "fixture", - Name = "Fixture item", - ShortName = "Fixture", - Width = 1, - Height = 1, - }; - RatStash.Item broken = new() - { - Id = "broken", - Name = "Broken fixture", - ShortName = "Broken", - Width = 1, - Height = 1, - }; - Config config = CreateProcessingConfig(optimizeHighlighted: false); - config.PathConfig.StaticIcons = root; - config.ProcessingConfig.IconConfig.UseStaticIcons = true; - config.ProcessingConfig.IconConfig.ScanRotatedIcons = false; - - using RatEyeEngine engine = new(config, RatStash.Database.FromItems([expected, broken])); - engine.Config.IconManager.EnsureStaticIconsLoaded(new Vector2(1, 1)); - KeyValuePair loaded = Assert.Single(engine.Config.IconManager.StaticIcons[new Vector2(1, 1)]); - Assert.EndsWith("fixture.png", loaded.Key, StringComparison.Ordinal); - Mat template = loaded.Value; - Bitmap exactScan = template.ToBitmap(); - using RatEye.Processing.Icon result = new( - exactScan, - Vector2.Zero, - new Vector2(template.Width, template.Height), - engine.Config - ); - - Assert.Equal(expected.Id, result.Item.Id); - Assert.True(result.DetectionConfidence > 0.9999f); - Assert.Equal(Vector2.Zero, result.ItemPosition); - Assert.False(result.Rotated); - } - finally - { - Directory.Delete(root, recursive: true); - } - } - - [Fact] - public void Missing_static_icon_data_degrades_without_blocking_engine_startup() - { - string missingDirectory = Path.Combine( - Path.GetTempPath(), - "RatEye-missing-icons-" + Guid.NewGuid().ToString("N") - ); - Config config = CreateProcessingConfig(optimizeHighlighted: true); - config.PathConfig.StaticIcons = missingDirectory; - config.ProcessingConfig.IconConfig.UseStaticIcons = true; - RatStash.Item item = new() - { - Id = "fixture", - Name = "Fixture item", - ShortName = "Fixture", - Width = 1, - Height = 1, - }; - - using RatEyeEngine engine = new(config, RatStash.Database.FromItems([item])); - engine.Config.IconManager.EnsureStaticIconsLoaded(new Vector2(1, 1)); - - Assert.Empty(engine.Config.IconManager.StaticIcons); - } - - private static Config CreateProcessingConfig(bool optimizeHighlighted) => - new() - { - ProcessingConfig = new Config.Processing - { - UseCache = false, - Scale = 1, - IconConfig = new Config.Processing.Icon { UseStaticIcons = false }, - InventoryConfig = new Config.Processing.Inventory { OptimizeHighlighted = optimizeHighlighted }, - }, - }; - - private static Bitmap CreateMarker() - { - Bitmap marker = new(9, 9); - using Graphics graphics = Graphics.FromImage(marker); - graphics.Clear(System.Drawing.Color.FromArgb(25, 27, 27)); - using Pen pen = new(System.Drawing.Color.White, 2); - graphics.DrawLine(pen, 1, 1, 7, 7); - graphics.DrawLine(pen, 7, 1, 1, 7); - marker.SetPixel(4, 1, System.Drawing.Color.Red); - return marker; - } -} diff --git a/tests/RatScanner.Tests/PresentationServicesTests.cs b/tests/RatScanner.Tests/PresentationServicesTests.cs index 425da6a1..589dc4ef 100644 --- a/tests/RatScanner.Tests/PresentationServicesTests.cs +++ b/tests/RatScanner.Tests/PresentationServicesTests.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using RatEye; using RatScanner.FetchModels.TarkovTracker; using RatScanner.Presentation; using RatScanner.Scan; @@ -222,155 +221,6 @@ public void Config_round_trips_values_larger_than_the_initial_native_buffer() } } -public class RatEyeIconManagerTests -{ - [Fact] - public void Static_icons_are_loaded_one_slot_size_at_a_time() - { - string root = System.IO.Path.Combine( - System.IO.Path.GetTempPath(), - "RatEye-icon-test-" + Guid.NewGuid().ToString("N") - ); - string icons = System.IO.Path.Combine(root, "icons"); - System.IO.Directory.CreateDirectory(icons); - - try - { - WriteIcon(System.IO.Path.Combine(icons, "one.png"), 64, 64); - WriteIcon(System.IO.Path.Combine(icons, "two.png"), 127, 64); - - RatEye.Config config = new() - { - PathConfig = new RatEye.Config.Path { StaticIcons = icons }, - ProcessingConfig = new RatEye.Config.Processing - { - UseCache = false, - IconConfig = new RatEye.Config.Processing.Icon { UseStaticIcons = true }, - }, - RatStashDB = RatStash.Database.FromItems([ - new() - { - Id = "one", - Name = "One", - ShortName = "One", - Width = 1, - Height = 1, - }, - new() - { - Id = "two", - Name = "Two", - ShortName = "Two", - Width = 2, - Height = 1, - }, - ]), - }; - - using RatEye.IconManager manager = new(config); - Assert.Empty(manager.StaticIcons); - - manager.EnsureStaticIconsLoaded(new RatEye.Vector2(1, 1)); - - Assert.Single(manager.StaticIcons); - Assert.Single(manager.StaticIcons[new RatEye.Vector2(1, 1)]); - Assert.DoesNotContain(new RatEye.Vector2(2, 1), manager.StaticIcons.Keys); - } - finally - { - System.IO.Directory.Delete(root, recursive: true); - } - } - - private static void WriteIcon(string path, int width, int height) - { - using System.Drawing.Bitmap bitmap = new(width, height); - bitmap.Save(path, System.Drawing.Imaging.ImageFormat.Png); - } -} - -public class RatEyeProcessingTests -{ - [Theory] - [InlineData("", "", 0)] - [InlineData("item", "", 0)] - [InlineData("", "item", 0)] - [InlineData("item", "item", 1)] - public void Normalized_similarity_handles_empty_input(string source, string target, float expected) => - Assert.Equal(expected, source.NormedLevenshteinDistance(target)); - - [Fact] - public void Crop_clamps_to_the_image_and_rejects_disjoint_regions() - { - using System.Drawing.Bitmap source = new(20, 20); - using System.Drawing.Bitmap cropped = source.Crop(-5, -5, 10, 10); - - Assert.Equal(5, cropped.Width); - Assert.Equal(5, cropped.Height); - Assert.Throws(() => source.Crop(30, 30, 5, 5)); - } - - [Fact] - public void Multi_inspection_suppresses_duplicate_peaks_and_preserves_confidence() - { - using System.Drawing.Bitmap marker = CreateMarker(); - using System.Drawing.Bitmap source = new(100, 60); - using (System.Drawing.Graphics graphics = System.Drawing.Graphics.FromImage(source)) - { - graphics.Clear(System.Drawing.Color.FromArgb(25, 27, 27)); - graphics.DrawImageUnscaled(marker, 10, 10); - graphics.DrawImageUnscaled(marker, 65, 35); - } - - RatEye.Config.Processing.Inspection inspectionConfig = new() { MarkerItemScale = 1, MarkerThreshold = 0.8f }; - inspectionConfig.Marker.Dispose(); - inspectionConfig.Marker = new System.Drawing.Bitmap(marker); - RatEye.Config config = new() - { - ProcessingConfig = new RatEye.Config.Processing { Scale = 1, InspectionConfig = inspectionConfig }, - }; - - using RatEye.RatEyeEngine engine = new(config, RatStash.Database.FromItems([])); - RatEye.Processing.MultiInspection result = engine.NewMultiInspection(source); - - Assert.Equal(2, result.Inspections.Count); - Assert.All(result.Inspections, inspection => Assert.True(inspection.MarkerConfidence > 0.99f)); - } - - [Fact] - public void Marker_peak_extraction_suppresses_adjacent_peaks_but_keeps_separated_matches() - { - using OpenCvSharp.Mat response = new(5, 15, OpenCvSharp.MatType.CV_32FC1, OpenCvSharp.Scalar.All(0)); - response.Set(2, 2, 0.99f); - response.Set(2, 3, 0.98f); - response.Set(2, 12, 0.97f); - - var matches = RatEye.Processing.MultiInspection.ExtractMarkerPeaks( - response, - new System.Drawing.Size(5, 5), - 0.8f - ); - - Assert.Equal(2, matches.Count); - Assert.Equal(new RatEye.Vector2(2, 2), matches[0].position); - Assert.Equal(0.99f, matches[0].confidence); - Assert.Equal(new RatEye.Vector2(12, 2), matches[1].position); - Assert.Equal(0.97f, matches[1].confidence); - } - - private static System.Drawing.Bitmap CreateMarker() - { - System.Drawing.Bitmap marker = new(9, 9); - using System.Drawing.Graphics graphics = System.Drawing.Graphics.FromImage(marker); - graphics.Clear(System.Drawing.Color.FromArgb(25, 27, 27)); - using System.Drawing.Pen pen = new(System.Drawing.Color.White, 2); - graphics.DrawLine(pen, 1, 1, 7, 7); - graphics.DrawLine(pen, 7, 1, 1, 7); - marker.SetPixel(4, 1, System.Drawing.Color.Red); - return marker; - } -} - public class TarkovDevApiTests { [Fact] diff --git a/tests/RatScanner.Tests/RatEyeCacheTests.cs b/tests/RatScanner.Tests/RatEyeCacheTests.cs deleted file mode 100644 index a753011b..00000000 --- a/tests/RatScanner.Tests/RatEyeCacheTests.cs +++ /dev/null @@ -1,123 +0,0 @@ -using System; -using System.Drawing; -using System.IO; -using OpenCvSharp; -using RatEye; -using RatStash; -using Xunit; - -namespace RatScanner.Tests; - -public class RatEyeCacheTests -{ - [Fact] - public void Corrupt_cached_icon_is_regenerated_atomically() - { - string root = Path.Combine(Path.GetTempPath(), "RatEye-cache-test-" + Guid.NewGuid().ToString("N")); - string iconsDirectory = Path.Combine(root, "icons"); - string cacheDirectory = Path.Combine(root, "cache"); - Directory.CreateDirectory(iconsDirectory); - Directory.CreateDirectory(cacheDirectory); - - Config config = CreateConfig(iconsDirectory); - try - { - WriteIcon(Path.Combine(iconsDirectory, "one.png")); - - using (IconManager manager = new(config, cacheDirectory)) - { - manager.EnsureStaticIconsLoaded(new Vector2(1, 1)); - Assert.Single(manager.StaticIcons[new Vector2(1, 1)]); - } - - string cachePath = Assert.Single(Directory.GetFiles(cacheDirectory, "*.bmp")); - File.WriteAllText(cachePath, "not a bitmap"); - - using (IconManager manager = new(config, cacheDirectory)) - { - manager.EnsureStaticIconsLoaded(new Vector2(1, 1)); - Assert.Single(manager.StaticIcons[new Vector2(1, 1)]); - } - - using Mat cachedIcon = Cv2.ImRead(cachePath, ImreadModes.Unchanged); - Assert.False(cachedIcon.Empty()); - Assert.Equal(MatType.CV_8UC3, cachedIcon.Type()); - Assert.Empty(Directory.GetFiles(cacheDirectory, "*.tmp.bmp")); - } - finally - { - config.ProcessingConfig.InspectionConfig.Marker.Dispose(); - Directory.Delete(root, recursive: true); - } - } - - [Fact] - public void Cache_pruning_removes_stale_and_oldest_oversize_entries() - { - string root = Path.Combine(Path.GetTempPath(), "RatEye-cache-prune-test-" + Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(root); - - try - { - DateTime now = new(2026, 7, 12, 12, 0, 0, DateTimeKind.Utc); - string stale = WriteCacheFile(root, "stale.bmp", 20, now - TimeSpan.FromDays(31)); - string abandonedTemporary = WriteCacheFile(root, "abandoned.tmp.bmp", 20, now - TimeSpan.FromDays(2)); - string oldest = WriteCacheFile(root, "oldest.bmp", 40, now - TimeSpan.FromHours(2)); - string newest = WriteCacheFile(root, "newest.bmp", 40, now - TimeSpan.FromHours(1)); - string unrelated = WriteCacheFile(root, "keep.txt", 20, now - TimeSpan.FromDays(90)); - - IconManager.PruneCache(root, now, TimeSpan.FromDays(30), maxBytes: 60, maxFiles: 10); - - Assert.False(File.Exists(stale)); - Assert.False(File.Exists(abandonedTemporary)); - Assert.False(File.Exists(oldest)); - Assert.True(File.Exists(newest)); - Assert.True(File.Exists(unrelated)); - } - finally - { - Directory.Delete(root, recursive: true); - } - } - - private static Config CreateConfig(string iconsDirectory) - { - Config config = new() - { - PathConfig = new Config.Path { StaticIcons = iconsDirectory }, - ProcessingConfig = new Config.Processing - { - UseCache = true, - IconConfig = new Config.Processing.Icon { UseStaticIcons = true }, - }, - }; - config.RatStashDB = Database.FromItems( - new Item[] - { - new() - { - Id = "one", - Name = "One", - ShortName = "One", - Width = 1, - Height = 1, - }, - } - ); - return config; - } - - private static void WriteIcon(string path) - { - using Bitmap bitmap = new(64, 64); - bitmap.Save(path, System.Drawing.Imaging.ImageFormat.Png); - } - - private static string WriteCacheFile(string root, string name, int length, DateTime lastWriteTimeUtc) - { - string path = Path.Combine(root, name); - File.WriteAllBytes(path, new byte[length]); - File.SetLastWriteTimeUtc(path, lastWriteTimeUtc); - return path; - } -} diff --git a/tests/RatScanner.Tests/ScanDiagnosticStoreTests.cs b/tests/RatScanner.Tests/ScanDiagnosticStoreTests.cs new file mode 100644 index 00000000..7fede0d1 --- /dev/null +++ b/tests/RatScanner.Tests/ScanDiagnosticStoreTests.cs @@ -0,0 +1,80 @@ +using System; +using System.Collections.Generic; +using System.Drawing; +using System.IO; +using RatScanner.Display; +using Xunit; + +namespace RatScanner.Tests; + +[Collection("RatConfig")] +public class ScanDiagnosticStoreTests +{ + [Fact] + public void Export_without_a_recorded_scan_reports_no_bundle() + { + using ScanDiagnosticStore store = new(); + + ScanDiagnosticExportResult result = store.Export(); + + Assert.False(result.Succeeded); + Assert.Null(result.Directory); + Assert.Null(result.Error); + } + + [Fact] + public void Export_writes_replayable_png_and_versioned_manifest_to_unique_directories() + { + string previousDebugPath = RatConfig.Paths.Debug; + string root = Path.Combine(Path.GetTempPath(), "RatScanner-diagnostics-" + Guid.NewGuid().ToString("N")); + RatConfig.Paths.Debug = root; + + try + { + using ScanDiagnosticStore store = new(); + using Bitmap capture = new(32, 24); + RatEye.Config config = new() + { + ProcessingConfig = new RatEye.Config.Processing + { + Scale = 1.25f, + UseCache = false, + InventoryConfig = new RatEye.Config.Processing.Inventory { OptimizeHighlighted = true }, + }, + }; + store.Record( + "inventory", + capture, + new RatEye.Vector2(100, 200), + new RatEye.Vector2(16, 12), + [new("item-id", "Fixture item", 0.95f)], + new Dictionary { ["inventory.grid_parse"] = 4.5 }, + config, + GameDisplayConfiguration.Empty, + "v4-test" + ); + + ScanDiagnosticExportResult result = store.Export(); + ScanDiagnosticExportResult repeatedResult = store.Export(); + + Assert.True(result.Succeeded, result.Error); + Assert.NotNull(result.Directory); + Assert.True(repeatedResult.Succeeded, repeatedResult.Error); + Assert.NotNull(repeatedResult.Directory); + Assert.NotEqual(result.Directory, repeatedResult.Directory); + Assert.True(File.Exists(Path.Combine(result.Directory, "capture.png"))); + string manifestPath = Path.Combine(result.Directory, "scan.ratdiag.json"); + Assert.True(File.Exists(manifestPath)); + string manifest = File.ReadAllText(manifestPath); + Assert.Contains("\"schemaVersion\": 1", manifest, StringComparison.Ordinal); + Assert.Contains("\"item-id\"", manifest, StringComparison.Ordinal); + Assert.Contains("\"inventory.grid_parse\": 4.5", manifest, StringComparison.Ordinal); + } + finally + { + RatConfig.Paths.Debug = previousDebugPath; + if (Directory.Exists(root)) + Directory.Delete(root, recursive: true); + } + } +} diff --git a/tests/RatScanner.Tests/ScanPipelineImageHarnessTests.cs b/tests/RatScanner.Tests/ScanPipelineImageHarnessTests.cs index 4262a95b..c352dbd2 100644 --- a/tests/RatScanner.Tests/ScanPipelineImageHarnessTests.cs +++ b/tests/RatScanner.Tests/ScanPipelineImageHarnessTests.cs @@ -181,12 +181,7 @@ public void IconScan_identifies_wd40_cell_in_junk_container() // the way LocateIconHighlighted pads a located highlight region. Rectangle cell = new(409, 119, 104, 104); using Bitmap crop = screenshot.Clone(cell, System.Drawing.Imaging.PixelFormat.Format24bppRgb); - using RatEye.Processing.Icon icon = new( - crop, - Vector2.Zero, - new Vector2(cell.Width, cell.Height), - engine.Config - ); + using RatEye.Processing.Icon icon = engine.NewIcon(crop, Vector2.Zero, new Vector2(cell.Width, cell.Height)); _output.WriteLine($"Item={icon.Item?.Name ?? ""} conf={icon.DetectionConfidence}"); Assert.NotNull(icon.Item); @@ -209,12 +204,7 @@ public void Gear_slot_garbage_matches_stay_below_the_acceptance_floor() // T30 backpack equipment slot on the gear screen, padded like a located highlight. Rectangle cell = new(864, 858, 195, 213); using Bitmap crop = screenshot.Clone(cell, System.Drawing.Imaging.PixelFormat.Format24bppRgb); - using RatEye.Processing.Icon icon = new( - crop, - Vector2.Zero, - new Vector2(cell.Width, cell.Height), - engine.Config - ); + using RatEye.Processing.Icon icon = engine.NewIcon(crop, Vector2.Zero, new Vector2(cell.Width, cell.Height)); _output.WriteLine($"Item={icon.Item?.Name ?? ""} conf={icon.DetectionConfidence}"); Assert.True( From 0a57bcbafecbd8eacb581d52159c55bc84409195 Mon Sep 17 00:00:00 2001 From: DysektAI Date: Mon, 27 Jul 2026 09:34:37 -0400 Subject: [PATCH 02/24] Update RatEye inventory recognition --- docs/agent-context/scan-engine.md | 2 ++ src/ScanEngine | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/agent-context/scan-engine.md b/docs/agent-context/scan-engine.md index 9943cd78..6c7b4c3e 100644 --- a/docs/agent-context/scan-engine.md +++ b/docs/agent-context/scan-engine.md @@ -8,6 +8,8 @@ The standalone RatEye submodule (`src/ScanEngine/`, project under `RatEye/`, ass - **Inspection / multi-inspection** — name-plate OCR path (Tesseract). - **Inventory + icon template matching** — icon scan path (OpenCvSharp), optional rotation. +- **Low-confidence icon verification** — exact short-name OCR when traineddata + is available; ambiguous or duplicate names never override the template. - **IconManager** — static icon library from `Data/icons` (+ optional dynamic cache config). The App owns capture, hotkeys, mapping to tarkov.dev catalog models, and UI. The engine owns image processing. diff --git a/src/ScanEngine b/src/ScanEngine index 4f72caee..c0c92945 160000 --- a/src/ScanEngine +++ b/src/ScanEngine @@ -1 +1 @@ -Subproject commit 4f72caee3fd98af7bf5853ebcbe7c065d0de01bb +Subproject commit c0c92945c4792ba3fbe2fa0c22812d0531f3eab9 From 0e6ee91f442588d40ae811e7d8e41543a7075634 Mon Sep 17 00:00:00 2001 From: DysektAI Date: Mon, 27 Jul 2026 22:29:09 -0400 Subject: [PATCH 03/24] docs: protect RatEye repository boundary --- AGENTS.md | 6 +++--- docs/agent-context/contribution-workflow.md | 2 ++ 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index d06e8dfd..a446d915 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # RatScanner — Agent Control Plane -**Authority:** implementation and project files override this document when they disagree. Keep this file and `docs/agent-context/` synchronized when architecture, commands, packages, workflows, or behavior change. +**Authority:** explicit maintainer decisions are authoritative for architecture and repository ownership, even when the current checkout or generated guidance still reflects an older or transitional layout. Implementation and project files are authoritative for current behavior. If those sources conflict, stop and surface the conflict instead of choosing one silently. Never close or supersede architecture PRs, delete source-bearing work, or reverse a repository boundary based only on inferred documentation precedence. Keep this file and `docs/agent-context/` synchronized when architecture, commands, packages, workflows, or behavior change. ## Product (one paragraph) @@ -11,7 +11,7 @@ Windows x64-only Escape from Tarkov external item scanner. WPF hosts MudBlazor U ## Non-negotiable constraints 1. **Windows x64 only** — the OpenCvSharp native runtime is x64-only. Do not design, build, test, or document x86, Linux/WSL, or macOS runs. -2. **Scan engine is standalone** — `src/ScanEngine/` is the `tarkovtracker-org/RatEye` submodule. Engine changes belong in RatEye and RatEye must never reference RatScanner. Never add a NuGet `PackageReference` for `RatEye`; App uses a source `ProjectReference`. +2. **Scan engine is standalone** — `src/ScanEngine/` is the `tarkovtracker-org/RatEye` submodule. Engine changes belong in RatEye and RatEye must never reference RatScanner. Never add a NuGet `PackageReference` for `RatEye`; App uses a source `ProjectReference`. A temporary vendored or in-tree checkout during migration is not authority to collapse the repositories or abandon the standalone boundary. 3. **Bulk catalog via json.tarkov.dev** — use `TarkovDevAPI` (rate limit, dedup, offline cache, backoff). Do not bypass with ad-hoc HTTP for items/tasks/hideout/crafts/barters. Do not reintroduce a GraphQL schema generator for bulk catalog. Slim maps GraphQL is intentional; keep maps off cold-start critical path. 4. **Product version only in** `src/App/RatScanner.csproj` ``. Independent 4.x line; do not mirror historical upstream 3.x tags. 5. **No secrets in git** — tokens live in user `config.cfg` (DPAPI-protected fields where used). @@ -130,4 +130,4 @@ Index and maintenance rules for the context set: [docs/agent-context/README.md]( ## Source-of-truth reminder -**Code, `.csproj`, scripts, and CI win over docs.** If you discover drift, fix the docs in the same change set when you touch the related system. +**Explicit maintainer architecture decisions win over stale or generated guidance. Code, `.csproj`, scripts, and CI define current behavior but may represent a transitional migration state.** If sources disagree, preserve active work, report the conflict, and obtain direction before changing repository ownership or PR state. Fix confirmed documentation drift in the same change set when you touch the related system. diff --git a/docs/agent-context/contribution-workflow.md b/docs/agent-context/contribution-workflow.md index c7eef10b..aeb00d4f 100644 --- a/docs/agent-context/contribution-workflow.md +++ b/docs/agent-context/contribution-workflow.md @@ -34,6 +34,8 @@ Build CI runs for PRs targeting `master` and pushes to `master`. Releases use a - PRs target **`tarkovtracker-org/RatScanner`**. - Bare `#NNN` issue refs resolve on the **fork**. Prefer full URLs for upstream history. - Do not push unless asked (agents); do not force-push shared branches without explicit instruction. +- Explicit maintainer decisions control architecture and repository ownership. Current files or generated guidance may describe a transitional state; surface conflicts instead of silently choosing a direction. +- Never close or supersede architecture PRs, remove their remote branches, or delete source-bearing worktrees solely because another branch currently implements a different layout. Preserve the work until the maintainer-directed path is reconciled. ### Commit quality and pre-merge validation From 02de3fd4b7e3d803523692ce6ec942deaf1e5eaf Mon Sep 17 00:00:00 2001 From: DysektAI Date: Mon, 27 Jul 2026 23:01:26 -0400 Subject: [PATCH 04/24] fix: address RatEye integration review --- .gitignore | 1 + scripts/check-agent-docs.ps1 | 76 ++++++++++++++++++- scripts/test-agent-docs.ps1 | 38 ++++++++++ src/App/RatScannerMain.cs | 14 +++- src/App/ScanDiagnosticStore.cs | 2 + src/ScanEngine | 2 +- .../ScanDiagnosticStoreTests.cs | 12 ++- 7 files changed, 138 insertions(+), 7 deletions(-) diff --git a/.gitignore b/.gitignore index f3d1edad..b6606d3a 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ RatScanner/[Dd]ata/* # publish folder publish/ +RatScanner.zip # Local smoke-test / agent watch captures (never commit) watch-verify-*.txt diff --git a/scripts/check-agent-docs.ps1 b/scripts/check-agent-docs.ps1 index a9c9ed8f..1699abc4 100644 --- a/scripts/check-agent-docs.ps1 +++ b/scripts/check-agent-docs.ps1 @@ -157,6 +157,72 @@ function Test-IsFloatingPackageVersion { return $false } +function Get-GitSubmoduleSections { + param([string]$Text) + + $sections = New-Object System.Collections.Generic.List[hashtable] + $current = $null + foreach ($line in [regex]::Split($Text, '\r?\n')) { + if ($line -match '^\s*\[submodule\s+"(?[^"]+)"\]\s*$') { + $current = @{ + Name = $Matches['name'] + Path = '' + Url = '' + } + $sections.Add($current) | Out-Null + continue + } + if ($null -eq $current -or $line -notmatch '^\s*(?path|url)\s*=\s*(?.*?)\s*$') { + continue + } + + $current[$Matches['key'].Substring(0, 1).ToUpperInvariant() + $Matches['key'].Substring(1)] = $Matches['value'] + } + return $sections +} + +function Test-CheckoutUsesRecursiveSubmodules { + param([string]$WorkflowText) + + $lines = [regex]::Split($WorkflowText, '\r?\n') + for ($index = 0; $index -lt $lines.Count; $index++) { + if ($lines[$index] -notmatch '^(?\s*)-\s+(name|uses)\s*:') { + continue + } + + $stepIndent = $Matches['indent'].Length + $stepEnd = $lines.Count + for ($candidate = $index + 1; $candidate -lt $lines.Count; $candidate++) { + if ($lines[$candidate] -match '^(?\s*)-\s+(name|uses)\s*:' -and $Matches['indent'].Length -eq $stepIndent) { + $stepEnd = $candidate + break + } + } + + $usesCheckout = $false + $withIndent = -1 + for ($candidate = $index; $candidate -lt $stepEnd; $candidate++) { + if ($lines[$candidate] -match '^\s*uses\s*:\s*actions/checkout@') { + $usesCheckout = $true + } + if ($lines[$candidate] -match '^(?\s*)with\s*:\s*$') { + $withIndent = $Matches['indent'].Length + continue + } + if ( + $usesCheckout -and + $withIndent -ge 0 -and + $lines[$candidate] -match '^(?\s*)submodules\s*:\s*recursive\s*$' -and + $Matches['indent'].Length -gt $withIndent + ) { + return $true + } + } + $index = $stepEnd - 1 + } + return $false +} + function Get-ItemVersion { param([System.Xml.XmlElement]$Item) @@ -372,10 +438,14 @@ foreach ($relative in $requiredFiles) { $gitmodulesPath = Join-Path $RepoRoot '.gitmodules' if (Test-Path -LiteralPath $gitmodulesPath) { $gitmodulesText = Get-Content -LiteralPath $gitmodulesPath -Raw - if ($gitmodulesText -notmatch '(?m)^\s*path\s*=\s*src/ScanEngine\s*$') { + $ratEyeSubmodules = @( + Get-GitSubmoduleSections -Text $gitmodulesText | + Where-Object { $_.Path.Replace('\', '/') -eq 'src/ScanEngine' } + ) + if ($ratEyeSubmodules.Count -ne 1) { Add-Failure '.gitmodules must map RatEye to src/ScanEngine' } - if ($gitmodulesText -notmatch '(?m)^\s*url\s*=\s*https://github\.com/tarkovtracker-org/RatEye\.git\s*$') { + elseif ($ratEyeSubmodules[0].Url -ne 'https://github.com/tarkovtracker-org/RatEye.git') { Add-Failure '.gitmodules must use https://github.com/tarkovtracker-org/RatEye.git' } } @@ -499,7 +569,7 @@ foreach ($branchDocument in @('AGENTS.md', 'CONTRIBUTING.md', 'README.md', 'docs $ciPath = Join-Path $RepoRoot '.github\workflows\build.yml' if (Test-Path -LiteralPath $ciPath) { $ciText = Get-Content -LiteralPath $ciPath -Raw - if ($ciText -notmatch '(?m)^\s*submodules:\s*recursive\s*$') { + if (-not (Test-CheckoutUsesRecursiveSubmodules -WorkflowText $ciText)) { Add-Failure 'CI checkout must initialize RatEye with submodules: recursive' } diff --git a/scripts/test-agent-docs.ps1 b/scripts/test-agent-docs.ps1 index a3d0e605..89e2510c 100644 --- a/scripts/test-agent-docs.ps1 +++ b/scripts/test-agent-docs.ps1 @@ -177,6 +177,24 @@ try { Restore-FixtureFile -RelativePath '.gitmodules' } + [System.IO.File]::WriteAllText( + $gitmodulesPath, + @" +[submodule "RatEye"] + path = src/ScanEngine + url = https://example.invalid/RatEye.git +[submodule "Decoy"] + path = src/Decoy + url = https://github.com/tarkovtracker-org/RatEye.git +"@ + ) + try { + Invoke-IntegrityCheck -ShouldPass $false -ExpectedText '.gitmodules must use https://github.com/tarkovtracker-org/RatEye.git' -Scenario 'RatEye path and URL must belong to the same submodule stanza' + } + finally { + Restore-FixtureFile -RelativePath '.gitmodules' + } + [xml]$appProject = Get-Content -LiteralPath $appProjectPath -Raw foreach ($reference in @($appProject.SelectNodes("//*[local-name()='ProjectReference']"))) { [void]$reference.ParentNode.RemoveChild($reference) @@ -224,6 +242,26 @@ try { Restore-FixtureFile -RelativePath '.github\workflows\build.yml' } + $workflow = Get-Content -LiteralPath $workflowPath -Raw + $nonCheckoutSubmodules = [regex]::Replace( + $workflow, + '(?m)^\s*submodules:\s*recursive\r?\n', + '' + ) + $nonCheckoutSubmodules = [regex]::Replace( + $nonCheckoutSubmodules, + '(?m)(^\s*dotnet-version:\s*10\.0\.x\s*$)', + "`$1`r`n submodules: recursive", + 1 + ) + [System.IO.File]::WriteAllText($workflowPath, $nonCheckoutSubmodules) + try { + Invoke-IntegrityCheck -ShouldPass $false -ExpectedText 'CI checkout must initialize RatEye with submodules: recursive' -Scenario 'recursive submodules on a non-checkout action do not satisfy checkout' + } + finally { + Restore-FixtureFile -RelativePath '.github\workflows\build.yml' + } + $workflow = Get-Content -LiteralPath $workflowPath -Raw $wrongPushWorkflow = [regex]::Replace( $workflow, diff --git a/src/App/RatScannerMain.cs b/src/App/RatScannerMain.cs index 41d89e59..b9828af0 100644 --- a/src/App/RatScannerMain.cs +++ b/src/App/RatScannerMain.cs @@ -10,6 +10,7 @@ using System.Windows; using System.Windows.Forms; using RatEye; +using RatScanner.Display; using RatScanner.Scan; using RatStash; using GameMode = RatScanner.TarkovDev.GameMode; @@ -536,7 +537,8 @@ internal void NameScanScreen(object? _ = null) Logger.LogDebug("Name scanning screen"); Rectangle bounds = RatConfig.GameDisplayConfiguration.CaptureBounds; - if (bounds.Width <= 0 || bounds.Height <= 0) + bool usedFallbackBounds = bounds.Width <= 0 || bounds.Height <= 0; + if (usedFallbackBounds) { Vector2 mousePosition = UserActivityHelper.GetMousePosition(); Screen? screen = @@ -547,6 +549,14 @@ internal void NameScanScreen(object? _ = null) return; bounds = screen.Bounds; } + double displayScale = RatConfig.GameDisplayConfiguration.DisplayScale; + if (usedFallbackBounds) + displayScale = WindowsGameDisplayService.GetDpiScale(bounds).Scale; + GameDisplayConfiguration diagnosticDisplay = RatConfig.GameDisplayConfiguration with + { + CaptureBounds = bounds, + DisplayScale = displayScale, + }; Vector2 position = new(bounds.X, bounds.Y); using Bitmap screenshot = GetScreenshot(position, bounds.Size); @@ -571,7 +581,7 @@ internal void NameScanScreen(object? _ = null) detections, stageMilliseconds, RatEyeEngine.Config, - RatConfig.GameDisplayConfiguration, + diagnosticDisplay, RatConfig.VersionDisplay ); diff --git a/src/App/ScanDiagnosticStore.cs b/src/App/ScanDiagnosticStore.cs index a4610b52..92098235 100644 --- a/src/App/ScanDiagnosticStore.cs +++ b/src/App/ScanDiagnosticStore.cs @@ -51,6 +51,8 @@ string applicationVersion OptimizeHighlighted = ratEyeConfig.ProcessingConfig.InventoryConfig.OptimizeHighlighted, UseStaticIcons = ratEyeConfig.ProcessingConfig.IconConfig.UseStaticIcons, ScanRotatedIcons = ratEyeConfig.ProcessingConfig.IconConfig.ScanRotatedIcons, + MarkerThreshold = ratEyeConfig.ProcessingConfig.InspectionConfig.MarkerThreshold, + MinItemConfidence = ratEyeConfig.ProcessingConfig.InspectionConfig.MinItemConfidence, }, Context = new ScanReplayContext { diff --git a/src/ScanEngine b/src/ScanEngine index c0c92945..24e0af71 160000 --- a/src/ScanEngine +++ b/src/ScanEngine @@ -1 +1 @@ -Subproject commit c0c92945c4792ba3fbe2fa0c22812d0531f3eab9 +Subproject commit 24e0af719e2f691ccba075109f5ec24e8759d75e diff --git a/tests/RatScanner.Tests/ScanDiagnosticStoreTests.cs b/tests/RatScanner.Tests/ScanDiagnosticStoreTests.cs index 7fede0d1..421b9074 100644 --- a/tests/RatScanner.Tests/ScanDiagnosticStoreTests.cs +++ b/tests/RatScanner.Tests/ScanDiagnosticStoreTests.cs @@ -42,6 +42,11 @@ public void Export_writes_replayable_png_and_versioned_manifest_to_unique_direct InventoryConfig = new RatEye.Config.Processing.Inventory { OptimizeHighlighted = true }, }, }; + GameDisplayConfiguration display = GameDisplayConfiguration.Empty with + { + CaptureBounds = new Rectangle(100, 200, 32, 24), + DisplayScale = 1.5, + }; store.Record( "inventory", capture, @@ -50,7 +55,7 @@ public void Export_writes_replayable_png_and_versioned_manifest_to_unique_direct [new("item-id", "Fixture item", 0.95f)], new Dictionary { ["inventory.grid_parse"] = 4.5 }, config, - GameDisplayConfiguration.Empty, + display, "v4-test" ); @@ -69,6 +74,11 @@ public void Export_writes_replayable_png_and_versioned_manifest_to_unique_direct Assert.Contains("\"schemaVersion\": 1", manifest, StringComparison.Ordinal); Assert.Contains("\"item-id\"", manifest, StringComparison.Ordinal); Assert.Contains("\"inventory.grid_parse\": 4.5", manifest, StringComparison.Ordinal); + Assert.Contains("\"displayX\": 100", manifest, StringComparison.Ordinal); + Assert.Contains("\"displayY\": 200", manifest, StringComparison.Ordinal); + Assert.Contains("\"displayWidth\": 32", manifest, StringComparison.Ordinal); + Assert.Contains("\"displayHeight\": 24", manifest, StringComparison.Ordinal); + Assert.Contains("\"dpiScale\": 1.5", manifest, StringComparison.Ordinal); } finally { From 11ee69a9619887b8a53f9b1b007fe3fe3aded6c7 Mon Sep 17 00:00:00 2001 From: DysektAI Date: Mon, 27 Jul 2026 23:13:27 -0400 Subject: [PATCH 05/24] fix: preserve scan diagnostic fidelity --- src/App/Pages/App/Settings/SettingsAdvanced.razor | 6 +++--- src/App/ScanDiagnosticStore.cs | 10 ++-------- tests/RatScanner.Tests/ScanDiagnosticStoreTests.cs | 8 +++++++- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/App/Pages/App/Settings/SettingsAdvanced.razor b/src/App/Pages/App/Settings/SettingsAdvanced.razor index c418a62b..c32442cf 100644 --- a/src/App/Pages/App/Settings/SettingsAdvanced.razor +++ b/src/App/Pages/App/Settings/SettingsAdvanced.razor @@ -109,7 +109,7 @@ @Localizer["ExportScanDiagnostics"] @@ -187,9 +187,9 @@ await PersistDisplayAsync(); } - private void ExportScanDiagnostics() + private async Task ExportScanDiagnosticsAsync() { - ScanDiagnosticExportResult result = RatScannerMain.Instance.ExportLastScanDiagnostics(); + ScanDiagnosticExportResult result = await Task.Run(RatScannerMain.Instance.ExportLastScanDiagnostics); if (result.Succeeded) { Snackbar.Add(string.Format(Localizer["ScanDiagnosticsExported"], result.Directory), Severity.Success); diff --git a/src/App/ScanDiagnosticStore.cs b/src/App/ScanDiagnosticStore.cs index 92098235..bbce1c46 100644 --- a/src/App/ScanDiagnosticStore.cs +++ b/src/App/ScanDiagnosticStore.cs @@ -72,14 +72,8 @@ string applicationVersion }, Observed = new ScanReplayObservedResult { - ItemIds = detections - .Where(detection => !string.IsNullOrWhiteSpace(detection.ItemId)) - .Select(detection => detection.ItemId!) - .ToList(), - ItemNames = detections - .Where(detection => !string.IsNullOrWhiteSpace(detection.ItemName)) - .Select(detection => detection.ItemName!) - .ToList(), + ItemIds = detections.Select(detection => detection.ItemId ?? string.Empty).ToList(), + ItemNames = detections.Select(detection => detection.ItemName ?? string.Empty).ToList(), Confidences = detections.Select(detection => detection.Confidence).ToList(), StageMilliseconds = new Dictionary(stageMilliseconds), }, diff --git a/tests/RatScanner.Tests/ScanDiagnosticStoreTests.cs b/tests/RatScanner.Tests/ScanDiagnosticStoreTests.cs index 421b9074..3e622592 100644 --- a/tests/RatScanner.Tests/ScanDiagnosticStoreTests.cs +++ b/tests/RatScanner.Tests/ScanDiagnosticStoreTests.cs @@ -2,6 +2,8 @@ using System.Collections.Generic; using System.Drawing; using System.IO; +using Newtonsoft.Json; +using RatEye.Diagnostics; using RatScanner.Display; using Xunit; @@ -52,7 +54,7 @@ public void Export_writes_replayable_png_and_versioned_manifest_to_unique_direct capture, new RatEye.Vector2(100, 200), new RatEye.Vector2(16, 12), - [new("item-id", "Fixture item", 0.95f)], + [new("item-id", "Fixture item", 0.95f), new(null, null, 0.25f)], new Dictionary { ["inventory.grid_parse"] = 4.5 }, config, display, @@ -71,6 +73,7 @@ public void Export_writes_replayable_png_and_versioned_manifest_to_unique_direct string manifestPath = Path.Combine(result.Directory, "scan.ratdiag.json"); Assert.True(File.Exists(manifestPath)); string manifest = File.ReadAllText(manifestPath); + ScanReplayManifest exportedManifest = JsonConvert.DeserializeObject(manifest)!; Assert.Contains("\"schemaVersion\": 1", manifest, StringComparison.Ordinal); Assert.Contains("\"item-id\"", manifest, StringComparison.Ordinal); Assert.Contains("\"inventory.grid_parse\": 4.5", manifest, StringComparison.Ordinal); @@ -79,6 +82,9 @@ public void Export_writes_replayable_png_and_versioned_manifest_to_unique_direct Assert.Contains("\"displayWidth\": 32", manifest, StringComparison.Ordinal); Assert.Contains("\"displayHeight\": 24", manifest, StringComparison.Ordinal); Assert.Contains("\"dpiScale\": 1.5", manifest, StringComparison.Ordinal); + Assert.Equal(new[] { "item-id", string.Empty }, exportedManifest.Observed.ItemIds); + Assert.Equal(new[] { "Fixture item", string.Empty }, exportedManifest.Observed.ItemNames); + Assert.Equal(new[] { 0.95f, 0.25f }, exportedManifest.Observed.Confidences); } finally { From f4e0772946ef08e78d1b7a62d8e6cea2cc8e06b5 Mon Sep 17 00:00:00 2001 From: DysektAI Date: Mon, 27 Jul 2026 23:22:53 -0400 Subject: [PATCH 06/24] chore: advance RatEye review fixes --- src/ScanEngine | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ScanEngine b/src/ScanEngine index 24e0af71..3dabaae6 160000 --- a/src/ScanEngine +++ b/src/ScanEngine @@ -1 +1 @@ -Subproject commit 24e0af719e2f691ccba075109f5ec24e8759d75e +Subproject commit 3dabaae661af6aafad585d3ddf56293a3313cfc1 From 81fd7159d0d42ddad6b9e495d84a000e23b18b3d Mon Sep 17 00:00:00 2001 From: DysektAI Date: Mon, 27 Jul 2026 23:33:22 -0400 Subject: [PATCH 07/24] fix: close production review gaps --- docs/agent-context/README.md | 2 +- scripts/check-agent-docs.ps1 | 45 ++++++++++++++----- scripts/test-agent-docs.ps1 | 36 +++++++++++++++ src/App/AGENTS.md | 2 +- .../Pages/App/Settings/SettingsAdvanced.razor | 31 +++++++++---- src/App/i18n/es.json | 2 +- src/ScanEngine | 2 +- tests/AGENTS.md | 2 +- .../ScanDiagnosticStoreTests.cs | 2 +- 9 files changed, 99 insertions(+), 25 deletions(-) diff --git a/docs/agent-context/README.md b/docs/agent-context/README.md index 8d9c7d0a..d87ddc17 100644 --- a/docs/agent-context/README.md +++ b/docs/agent-context/README.md @@ -11,7 +11,7 @@ Root `AGENTS.md` is the **control plane**: short universal rules, default comman | **Mandatory instructions** | Root `AGENTS.md`, nested `**/AGENTS.md` | Always or path-scoped rules that must be followed | | **Explanatory context** | Files in this directory | How systems work, where code lives, what to verify | -Context docs are not a second source of product truth. If prose disagrees with code, scripts, `.csproj`, or CI, **implementation wins** — then update the prose. +Context docs are not a second source of product truth. Explicit maintainer decisions control architecture and repository ownership, while code, scripts, `.csproj`, and CI describe current behavior and may reflect a transitional state. If those sources conflict, stop and surface the conflict instead of choosing one silently; once resolved, update the stale source. ## Files and when to read them diff --git a/scripts/check-agent-docs.ps1 b/scripts/check-agent-docs.ps1 index 1699abc4..7bc094ec 100644 --- a/scripts/check-agent-docs.ps1 +++ b/scripts/check-agent-docs.ps1 @@ -172,6 +172,10 @@ function Get-GitSubmoduleSections { $sections.Add($current) | Out-Null continue } + if ($line -match '^\s*\[.+\]\s*$') { + $current = $null + continue + } if ($null -eq $current -or $line -notmatch '^\s*(?path|url)\s*=\s*(?.*?)\s*$') { continue } @@ -200,22 +204,43 @@ function Test-CheckoutUsesRecursiveSubmodules { } $usesCheckout = $false - $withIndent = -1 for ($candidate = $index; $candidate -lt $stepEnd; $candidate++) { if ($lines[$candidate] -match '^\s*uses\s*:\s*actions/checkout@') { $usesCheckout = $true + break } + } + if (-not $usesCheckout) { + $index = $stepEnd - 1 + continue + } + + for ($candidate = $index; $candidate -lt $stepEnd; $candidate++) { if ($lines[$candidate] -match '^(?\s*)with\s*:\s*$') { $withIndent = $Matches['indent'].Length - continue - } - if ( - $usesCheckout -and - $withIndent -ge 0 -and - $lines[$candidate] -match '^(?\s*)submodules\s*:\s*recursive\s*$' -and - $Matches['indent'].Length -gt $withIndent - ) { - return $true + $directEntryIndent = -1 + for ($entry = $candidate + 1; $entry -lt $stepEnd; $entry++) { + if ($lines[$entry] -match '^\s*(#.*)?$') { + continue + } + if ($lines[$entry] -notmatch '^(?\s*)') { + break + } + + $entryIndent = $Matches['indent'].Length + if ($entryIndent -le $withIndent) { + break + } + if ($directEntryIndent -lt 0) { + $directEntryIndent = $entryIndent + } + if ( + $entryIndent -eq $directEntryIndent -and + $lines[$entry] -match '^\s*submodules\s*:\s*recursive\s*$' + ) { + return $true + } + } } } $index = $stepEnd - 1 diff --git a/scripts/test-agent-docs.ps1 b/scripts/test-agent-docs.ps1 index 89e2510c..a95e34e4 100644 --- a/scripts/test-agent-docs.ps1 +++ b/scripts/test-agent-docs.ps1 @@ -177,6 +177,22 @@ try { Restore-FixtureFile -RelativePath '.gitmodules' } + [System.IO.File]::WriteAllText( + $gitmodulesPath, + @" +[submodule "RatEye"] + path = src/ScanEngine +[core] + url = https://github.com/tarkovtracker-org/RatEye.git +"@ + ) + try { + Invoke-IntegrityCheck -ShouldPass $false -ExpectedText '.gitmodules must use https://github.com/tarkovtracker-org/RatEye.git' -Scenario 'non-submodule section cannot supply the RatEye URL' + } + finally { + Restore-FixtureFile -RelativePath '.gitmodules' + } + [System.IO.File]::WriteAllText( $gitmodulesPath, @" @@ -242,6 +258,26 @@ try { Restore-FixtureFile -RelativePath '.github\workflows\build.yml' } + $workflow = Get-Content -LiteralPath $workflowPath -Raw + $nestedSubmodules = [regex]::Replace( + $workflow, + '(?m)^\s*submodules:\s*recursive\r?\n', + '' + ) + $nestedSubmodules = [regex]::Replace( + $nestedSubmodules, + '(?m)(^\s*persist-credentials:\s*false\s*$)', + "`$1`r`n sparse-checkout: |`r`n submodules: recursive", + 1 + ) + [System.IO.File]::WriteAllText($workflowPath, $nestedSubmodules) + try { + Invoke-IntegrityCheck -ShouldPass $false -ExpectedText 'CI checkout must initialize RatEye with submodules: recursive' -Scenario 'nested YAML text cannot impersonate a direct checkout input' + } + finally { + Restore-FixtureFile -RelativePath '.github\workflows\build.yml' + } + $workflow = Get-Content -LiteralPath $workflowPath -Raw $nonCheckoutSubmodules = [regex]::Replace( $workflow, diff --git a/src/App/AGENTS.md b/src/App/AGENTS.md index 6b5c124c..1f281d32 100644 --- a/src/App/AGENTS.md +++ b/src/App/AGENTS.md @@ -18,7 +18,7 @@ WPF host, Blazor WebView UI, configuration, tarkov.dev/TarkovTracker clients, sc 8. **Data assets:** `Data/**` is downloaded (gitignored). Do not commit icons/OCR dumps. Keep `Watch=false` on Data content items. 9. Dispose / single-instance lifecycle: honor existing `DisposeInstance` paths on exit; do not create unbounded WebView/service leaks. 10. Implicit usings are disabled — keep explicit `using` directives. -11. Implementation overrides this file; update it when App-scoped rules change. +11. Root authority rules apply: explicit maintainer architecture and repository-ownership decisions outrank transitional implementation or generated guidance. If sources conflict, stop and surface the conflict instead of choosing silently; update this file when App-scoped rules change. ## Prefer diff --git a/src/App/Pages/App/Settings/SettingsAdvanced.razor b/src/App/Pages/App/Settings/SettingsAdvanced.razor index c32442cf..5e6d83c4 100644 --- a/src/App/Pages/App/Settings/SettingsAdvanced.razor +++ b/src/App/Pages/App/Settings/SettingsAdvanced.razor @@ -110,6 +110,7 @@ Size="Size.Small" StartIcon="@Icons.Material.Filled.FileDownload" OnClick="ExportScanDiagnosticsAsync" + Disabled="_exportingScanDiagnostics" Class="mt-3"> @Localizer["ExportScanDiagnostics"] @@ -137,6 +138,7 @@ private bool _savingDisplay; private int _displaySaveOperations; + private bool _exportingScanDiagnostics; protected override async Task OnInitializedAsync() { @@ -189,17 +191,28 @@ private async Task ExportScanDiagnosticsAsync() { - ScanDiagnosticExportResult result = await Task.Run(RatScannerMain.Instance.ExportLastScanDiagnostics); - if (result.Succeeded) - { - Snackbar.Add(string.Format(Localizer["ScanDiagnosticsExported"], result.Directory), Severity.Success); + if (_exportingScanDiagnostics) return; - } - string message = result.Error is null - ? Localizer["NoScanDiagnostics"] - : string.Format(Localizer["ScanDiagnosticsExportFailed"], result.Error); - Snackbar.Add(message, Severity.Error); + _exportingScanDiagnostics = true; + try + { + ScanDiagnosticExportResult result = await Task.Run(RatScannerMain.Instance.ExportLastScanDiagnostics); + if (result.Succeeded) + { + Snackbar.Add(string.Format(Localizer["ScanDiagnosticsExported"], result.Directory), Severity.Success); + return; + } + + string message = result.Error is null + ? Localizer["NoScanDiagnostics"] + : string.Format(Localizer["ScanDiagnosticsExportFailed"], result.Error); + Snackbar.Add(message, Severity.Error); + } + finally + { + _exportingScanDiagnostics = false; + } } private void PropertyChangeHandler(object? sender, EventArgs e) => _ = InvokeAsync(StateHasChanged); diff --git a/src/App/i18n/es.json b/src/App/i18n/es.json index 27150302..3da28eb8 100644 --- a/src/App/i18n/es.json +++ b/src/App/i18n/es.json @@ -236,7 +236,7 @@ "DiagnosticsSection": "Diagnósticos", "DiagnosticsDescription": "Registro técnico para solución de problemas. Mantenga esto desactivado durante el uso normal.", "ExportScanDiagnostics": "Exportar diagnósticos del escaneo", - "ExportScanDiagnosticsDescription": "Guarda la captura, el resultado, la configuración y los tiempos de la etapa del escaneo más reciente para reproducirlos localmente. Revise la captura antes de compartirla.", + "ExportScanDiagnosticsDescription": "Guarda la captura, el resultado, la configuración y los tiempos de las etapas del escaneo más reciente para reproducirlos localmente. Revise la captura antes de compartirla.", "ScanDiagnosticsExported": "Diagnósticos del escaneo guardados en {0}.", "NoScanDiagnostics": "Realice un escaneo de nombre o icono antes de exportar diagnósticos.", "ScanDiagnosticsExportFailed": "RatScanner no pudo exportar los diagnósticos del escaneo: {0}", diff --git a/src/ScanEngine b/src/ScanEngine index 3dabaae6..da2d9e99 160000 --- a/src/ScanEngine +++ b/src/ScanEngine @@ -1 +1 @@ -Subproject commit 3dabaae661af6aafad585d3ddf56293a3313cfc1 +Subproject commit da2d9e99c89b7768e8b8e2d2a8691f7fcb615d9e diff --git a/tests/AGENTS.md b/tests/AGENTS.md index 09c6e591..7b918fa9 100644 --- a/tests/AGENTS.md +++ b/tests/AGENTS.md @@ -15,7 +15,7 @@ xUnit unit tests under `tests/RatScanner.Tests` that reference the App project. 5. Unit tests do **not** replace manual UI or scan verification; do not claim they do. 6. Prefer realistic coverage of parsers, projections, presentation helpers, display config, and reliability guards over excessive mocking of the full scan path. 7. Assertions must make the regression clear (expected ids, keys, TTLs, status codes), not only “no exception”. -8. Implementation and this project file override stale docs; update this file when test policy changes. +8. Root authority rules apply: explicit maintainer architecture and repository-ownership decisions outrank transitional implementation or generated guidance. If sources conflict, stop and surface the conflict instead of choosing silently; update this file when test policy changes. 9. RatEye engine internals, OpenCV processing, cache behavior, and replay benchmarks are tested in the RatEye submodule. Keep this project focused on App-owned capture geometry, integration, and presentation contracts. ## Prefer diff --git a/tests/RatScanner.Tests/ScanDiagnosticStoreTests.cs b/tests/RatScanner.Tests/ScanDiagnosticStoreTests.cs index 3e622592..29ee1af3 100644 --- a/tests/RatScanner.Tests/ScanDiagnosticStoreTests.cs +++ b/tests/RatScanner.Tests/ScanDiagnosticStoreTests.cs @@ -9,7 +9,7 @@ namespace RatScanner.Tests; -[Collection("RatConfig")] +[Collection(RatConfigCollection.Name)] public class ScanDiagnosticStoreTests { [Fact] From a4e75a07e65e3fef815b6c388e16490976fa6fb8 Mon Sep 17 00:00:00 2001 From: DysektAI Date: Mon, 27 Jul 2026 23:45:41 -0400 Subject: [PATCH 08/24] chore: advance RatEye production review fixes --- src/ScanEngine | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ScanEngine b/src/ScanEngine index da2d9e99..0b7e1f3d 160000 --- a/src/ScanEngine +++ b/src/ScanEngine @@ -1 +1 @@ -Subproject commit da2d9e99c89b7768e8b8e2d2a8691f7fcb615d9e +Subproject commit 0b7e1f3d4cc0a617b28663b3f047b3deb5831fce From a625d83265f53d33a11b8de82376a40b00b9ad3c Mon Sep 17 00:00:00 2001 From: DysektAI Date: Mon, 27 Jul 2026 23:52:25 -0400 Subject: [PATCH 09/24] chore: advance RatEye compatibility fixes --- src/ScanEngine | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ScanEngine b/src/ScanEngine index 0b7e1f3d..897c2091 160000 --- a/src/ScanEngine +++ b/src/ScanEngine @@ -1 +1 @@ -Subproject commit 0b7e1f3d4cc0a617b28663b3f047b3deb5831fce +Subproject commit 897c20914ce25947d1a948db3a5a826805cdba2f From 1410a9728fc089a37250c30e51936d5518f88fee Mon Sep 17 00:00:00 2001 From: DysektAI Date: Mon, 27 Jul 2026 23:55:12 -0400 Subject: [PATCH 10/24] chore: advance RatEye recovery hardening --- src/ScanEngine | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ScanEngine b/src/ScanEngine index 897c2091..f4abc6e2 160000 --- a/src/ScanEngine +++ b/src/ScanEngine @@ -1 +1 @@ -Subproject commit 897c20914ce25947d1a948db3a5a826805cdba2f +Subproject commit f4abc6e2afe42091d7c9c5953b2f52f16cf4a348 From 29d6be962c0ad260efcf48df3c7b4c429200aa89 Mon Sep 17 00:00:00 2001 From: DysektAI Date: Tue, 28 Jul 2026 00:03:30 -0400 Subject: [PATCH 11/24] chore: advance RatEye template refresh fix --- src/ScanEngine | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ScanEngine b/src/ScanEngine index f4abc6e2..29e46d80 160000 --- a/src/ScanEngine +++ b/src/ScanEngine @@ -1 +1 @@ -Subproject commit f4abc6e2afe42091d7c9c5953b2f52f16cf4a348 +Subproject commit 29e46d80e280bb996274e7ad326b9a2402eb69a7 From e81865f8a5fb14f4bce42e56821d25f0aca6e78b Mon Sep 17 00:00:00 2001 From: DysektAI Date: Tue, 28 Jul 2026 00:22:42 -0400 Subject: [PATCH 12/24] chore: advance atomic RatEye icon refresh --- src/ScanEngine | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ScanEngine b/src/ScanEngine index 29e46d80..4a4ce508 160000 --- a/src/ScanEngine +++ b/src/ScanEngine @@ -1 +1 @@ -Subproject commit 29e46d80e280bb996274e7ad326b9a2402eb69a7 +Subproject commit 4a4ce50897532734aefddd8913f46bceeaaaadb2 From 5ea239efbc858f34a658fba75020280c7a977c49 Mon Sep 17 00:00:00 2001 From: DysektAI Date: Tue, 28 Jul 2026 00:31:39 -0400 Subject: [PATCH 13/24] chore: advance reviewed RatEye scan fixes --- src/ScanEngine | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ScanEngine b/src/ScanEngine index 4a4ce508..5c8ab9e4 160000 --- a/src/ScanEngine +++ b/src/ScanEngine @@ -1 +1 @@ -Subproject commit 4a4ce50897532734aefddd8913f46bceeaaaadb2 +Subproject commit 5c8ab9e4d67d088af0ecd40ab77610174bbeb2c2 From 9d4ed68a7772cf29b48833987cec9ea95e1428e8 Mon Sep 17 00:00:00 2001 From: DysektAI Date: Tue, 28 Jul 2026 00:36:26 -0400 Subject: [PATCH 14/24] chore: advance atomic RatEye correlation fix --- src/ScanEngine | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ScanEngine b/src/ScanEngine index 5c8ab9e4..8148cb53 160000 --- a/src/ScanEngine +++ b/src/ScanEngine @@ -1 +1 @@ -Subproject commit 5c8ab9e4d67d088af0ecd40ab77610174bbeb2c2 +Subproject commit 8148cb538b0bd13ef6ae7248da6ffe6c7a464101 From 3a51ed0be88dde7196c946cb25eb15ab7266375e Mon Sep 17 00:00:00 2001 From: DysektAI Date: Tue, 28 Jul 2026 00:48:28 -0400 Subject: [PATCH 15/24] chore: advance captured-byte RatEye cache fix --- src/ScanEngine | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ScanEngine b/src/ScanEngine index 8148cb53..7f5836ed 160000 --- a/src/ScanEngine +++ b/src/ScanEngine @@ -1 +1 @@ -Subproject commit 8148cb538b0bd13ef6ae7248da6ffe6c7a464101 +Subproject commit 7f5836ed465c95e3b778ef30f4c2fbdbb645a181 From 4a2980c09db8472a13a66d3e83c18668a4f37ed2 Mon Sep 17 00:00:00 2001 From: DysektAI Date: Tue, 28 Jul 2026 00:57:20 -0400 Subject: [PATCH 16/24] chore: advance strict RatEye cleanup reporting --- src/ScanEngine | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ScanEngine b/src/ScanEngine index 7f5836ed..16df65ae 160000 --- a/src/ScanEngine +++ b/src/ScanEngine @@ -1 +1 @@ -Subproject commit 7f5836ed465c95e3b778ef30f4c2fbdbb645a181 +Subproject commit 16df65ae1df4f63a7c23c657ba45a1e3ab5c16e6 From f4af87740c727a86424092007e3f6004e9acab90 Mon Sep 17 00:00:00 2001 From: DysektAI Date: Tue, 28 Jul 2026 01:27:53 -0400 Subject: [PATCH 17/24] fix: advance RatEye snapshot refresh --- src/ScanEngine | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ScanEngine b/src/ScanEngine index 16df65ae..0634c66a 160000 --- a/src/ScanEngine +++ b/src/ScanEngine @@ -1 +1 @@ -Subproject commit 16df65ae1df4f63a7c23c657ba45a1e3ab5c16e6 +Subproject commit 0634c66a996f3d636e4ae65e93429bb517e8e9bc From 51eec4e8d92aa1e547b90b45f7e90c775dc073ec Mon Sep 17 00:00:00 2001 From: DysektAI Date: Tue, 28 Jul 2026 01:35:31 -0400 Subject: [PATCH 18/24] test: harden diagnostic manifest assertions --- tests/RatScanner.Tests/ScanDiagnosticStoreTests.cs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/tests/RatScanner.Tests/ScanDiagnosticStoreTests.cs b/tests/RatScanner.Tests/ScanDiagnosticStoreTests.cs index 29ee1af3..41605f0c 100644 --- a/tests/RatScanner.Tests/ScanDiagnosticStoreTests.cs +++ b/tests/RatScanner.Tests/ScanDiagnosticStoreTests.cs @@ -73,7 +73,8 @@ public void Export_writes_replayable_png_and_versioned_manifest_to_unique_direct string manifestPath = Path.Combine(result.Directory, "scan.ratdiag.json"); Assert.True(File.Exists(manifestPath)); string manifest = File.ReadAllText(manifestPath); - ScanReplayManifest exportedManifest = JsonConvert.DeserializeObject(manifest)!; + var exportedManifest = JsonConvert.DeserializeObject(manifest); + Assert.NotNull(exportedManifest); Assert.Contains("\"schemaVersion\": 1", manifest, StringComparison.Ordinal); Assert.Contains("\"item-id\"", manifest, StringComparison.Ordinal); Assert.Contains("\"inventory.grid_parse\": 4.5", manifest, StringComparison.Ordinal); @@ -84,7 +85,11 @@ public void Export_writes_replayable_png_and_versioned_manifest_to_unique_direct Assert.Contains("\"dpiScale\": 1.5", manifest, StringComparison.Ordinal); Assert.Equal(new[] { "item-id", string.Empty }, exportedManifest.Observed.ItemIds); Assert.Equal(new[] { "Fixture item", string.Empty }, exportedManifest.Observed.ItemNames); - Assert.Equal(new[] { 0.95f, 0.25f }, exportedManifest.Observed.Confidences); + Assert.Collection( + exportedManifest.Observed.Confidences, + confidence => Assert.Equal(0.95f, confidence, precision: 4), + confidence => Assert.Equal(0.25f, confidence, precision: 4) + ); } finally { From 8a4ce405e38073037e46fb8f8f6c218d1e2c755b Mon Sep 17 00:00:00 2001 From: DysektAI Date: Tue, 28 Jul 2026 01:48:39 -0400 Subject: [PATCH 19/24] fix: harden final review validation --- scripts/check-agent-docs.ps1 | 5 ++-- scripts/test-agent-docs.ps1 | 20 ++++++++++++++++ src/ScanEngine | 2 +- .../ScanDiagnosticStoreTests.cs | 24 ++++++++++++------- 4 files changed, 40 insertions(+), 11 deletions(-) diff --git a/scripts/check-agent-docs.ps1 b/scripts/check-agent-docs.ps1 index 7bc094ec..47b0f4ba 100644 --- a/scripts/check-agent-docs.ps1 +++ b/scripts/check-agent-docs.ps1 @@ -189,15 +189,16 @@ function Test-CheckoutUsesRecursiveSubmodules { param([string]$WorkflowText) $lines = [regex]::Split($WorkflowText, '\r?\n') + $stepStarterPattern = '^(?\s*)-\s+[^#\s][^:]*\s*:' for ($index = 0; $index -lt $lines.Count; $index++) { - if ($lines[$index] -notmatch '^(?\s*)-\s+(name|uses)\s*:') { + if ($lines[$index] -notmatch $stepStarterPattern) { continue } $stepIndent = $Matches['indent'].Length $stepEnd = $lines.Count for ($candidate = $index + 1; $candidate -lt $lines.Count; $candidate++) { - if ($lines[$candidate] -match '^(?\s*)-\s+(name|uses)\s*:' -and $Matches['indent'].Length -eq $stepIndent) { + if ($lines[$candidate] -match $stepStarterPattern -and $Matches['indent'].Length -eq $stepIndent) { $stepEnd = $candidate break } diff --git a/scripts/test-agent-docs.ps1 b/scripts/test-agent-docs.ps1 index a95e34e4..81a5e7da 100644 --- a/scripts/test-agent-docs.ps1 +++ b/scripts/test-agent-docs.ps1 @@ -298,6 +298,26 @@ try { Restore-FixtureFile -RelativePath '.github\workflows\build.yml' } + $workflow = Get-Content -LiteralPath $workflowPath -Raw + $conditionalStepSubmodules = [regex]::Replace( + $workflow, + '(?m)^\s*submodules:\s*recursive\r?\n', + '' + ) + $conditionalStepSubmodules = [regex]::Replace( + $conditionalStepSubmodules, + '(?m)(^\s*persist-credentials:\s*false\s*$)', + "`$1`r`n - if: `${{ always() }}`r`n uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1`r`n with:`r`n submodules: recursive", + 1 + ) + [System.IO.File]::WriteAllText($workflowPath, $conditionalStepSubmodules) + try { + Invoke-IntegrityCheck -ShouldPass $false -ExpectedText 'CI checkout must initialize RatEye with submodules: recursive' -Scenario 'conditional non-checkout step cannot supply recursive submodules for checkout' + } + finally { + Restore-FixtureFile -RelativePath '.github\workflows\build.yml' + } + $workflow = Get-Content -LiteralPath $workflowPath -Raw $wrongPushWorkflow = [regex]::Replace( $workflow, diff --git a/src/ScanEngine b/src/ScanEngine index 0634c66a..f6a5f540 160000 --- a/src/ScanEngine +++ b/src/ScanEngine @@ -1 +1 @@ -Subproject commit 0634c66a996f3d636e4ae65e93429bb517e8e9bc +Subproject commit f6a5f540082a2c12c5aefc01e5f8cef2d8682f0a diff --git a/tests/RatScanner.Tests/ScanDiagnosticStoreTests.cs b/tests/RatScanner.Tests/ScanDiagnosticStoreTests.cs index 41605f0c..948eca83 100644 --- a/tests/RatScanner.Tests/ScanDiagnosticStoreTests.cs +++ b/tests/RatScanner.Tests/ScanDiagnosticStoreTests.cs @@ -3,6 +3,7 @@ using System.Drawing; using System.IO; using Newtonsoft.Json; +using Newtonsoft.Json.Linq; using RatEye.Diagnostics; using RatScanner.Display; using Xunit; @@ -75,14 +76,21 @@ public void Export_writes_replayable_png_and_versioned_manifest_to_unique_direct string manifest = File.ReadAllText(manifestPath); var exportedManifest = JsonConvert.DeserializeObject(manifest); Assert.NotNull(exportedManifest); - Assert.Contains("\"schemaVersion\": 1", manifest, StringComparison.Ordinal); - Assert.Contains("\"item-id\"", manifest, StringComparison.Ordinal); - Assert.Contains("\"inventory.grid_parse\": 4.5", manifest, StringComparison.Ordinal); - Assert.Contains("\"displayX\": 100", manifest, StringComparison.Ordinal); - Assert.Contains("\"displayY\": 200", manifest, StringComparison.Ordinal); - Assert.Contains("\"displayWidth\": 32", manifest, StringComparison.Ordinal); - Assert.Contains("\"displayHeight\": 24", manifest, StringComparison.Ordinal); - Assert.Contains("\"dpiScale\": 1.5", manifest, StringComparison.Ordinal); + JObject manifestJson = JObject.Parse(manifest); + var observedJson = manifestJson["observed"] as JObject; + var stageMillisecondsJson = observedJson?["stageMilliseconds"] as JObject; + var contextJson = manifestJson["context"] as JObject; + Assert.NotNull(observedJson); + Assert.NotNull(stageMillisecondsJson); + Assert.NotNull(contextJson); + Assert.Equal(1, manifestJson.Value("schemaVersion")); + Assert.Equal("item-id", observedJson["itemIds"]?[0]?.Value()); + Assert.Equal(4.5, stageMillisecondsJson.Value("inventory.grid_parse")); + Assert.Equal(100, contextJson.Value("displayX")); + Assert.Equal(200, contextJson.Value("displayY")); + Assert.Equal(32, contextJson.Value("displayWidth")); + Assert.Equal(24, contextJson.Value("displayHeight")); + Assert.Equal(1.5, contextJson.Value("dpiScale")); Assert.Equal(new[] { "item-id", string.Empty }, exportedManifest.Observed.ItemIds); Assert.Equal(new[] { "Fixture item", string.Empty }, exportedManifest.Observed.ItemNames); Assert.Collection( From 676ef7cb4f753452fd436f1846dbce0809638257 Mon Sep 17 00:00:00 2001 From: DysektAI Date: Tue, 28 Jul 2026 02:16:57 -0400 Subject: [PATCH 20/24] fix: address final submodule review findings --- scripts/check-agent-docs.ps1 | 110 +++++++++++++++++++++++------------ scripts/test-agent-docs.ps1 | 26 +++++++++ src/ScanEngine | 2 +- 3 files changed, 100 insertions(+), 38 deletions(-) diff --git a/scripts/check-agent-docs.ps1 b/scripts/check-agent-docs.ps1 index 47b0f4ba..f6f1f4ad 100644 --- a/scripts/check-agent-docs.ps1 +++ b/scripts/check-agent-docs.ps1 @@ -190,61 +190,97 @@ function Test-CheckoutUsesRecursiveSubmodules { $lines = [regex]::Split($WorkflowText, '\r?\n') $stepStarterPattern = '^(?\s*)-\s+[^#\s][^:]*\s*:' - for ($index = 0; $index -lt $lines.Count; $index++) { - if ($lines[$index] -notmatch $stepStarterPattern) { + for ($stepsIndex = 0; $stepsIndex -lt $lines.Count; $stepsIndex++) { + if ($lines[$stepsIndex] -notmatch '^(?[ ]*)steps\s*:\s*(?:#.*)?$') { continue } - $stepIndent = $Matches['indent'].Length - $stepEnd = $lines.Count - for ($candidate = $index + 1; $candidate -lt $lines.Count; $candidate++) { - if ($lines[$candidate] -match $stepStarterPattern -and $Matches['indent'].Length -eq $stepIndent) { - $stepEnd = $candidate + $stepsIndent = $Matches['indent'].Length + $stepsEnd = $lines.Count + for ($candidate = $stepsIndex + 1; $candidate -lt $lines.Count; $candidate++) { + if ($lines[$candidate] -match '^\s*(?:#.*)?$') { + continue + } + $candidateIndent = ([regex]::Match($lines[$candidate], '^[ ]*')).Value.Length + if ($candidateIndent -le $stepsIndent) { + $stepsEnd = $candidate break } } - $usesCheckout = $false - for ($candidate = $index; $candidate -lt $stepEnd; $candidate++) { - if ($lines[$candidate] -match '^\s*uses\s*:\s*actions/checkout@') { - $usesCheckout = $true - break + $stepIndent = -1 + for ($index = $stepsIndex + 1; $index -lt $stepsEnd; $index++) { + if ($lines[$index] -notmatch $stepStarterPattern) { + continue + } + + $candidateStepIndent = $Matches['indent'].Length + if ($stepIndent -lt 0) { + $stepIndent = $candidateStepIndent + } + if ($candidateStepIndent -ne $stepIndent) { + continue + } + + $stepEnd = $stepsEnd + for ($candidate = $index + 1; $candidate -lt $stepsEnd; $candidate++) { + if ( + $lines[$candidate] -match $stepStarterPattern -and + $Matches['indent'].Length -eq $stepIndent + ) { + $stepEnd = $candidate + break + } } - } - if (-not $usesCheckout) { - $index = $stepEnd - 1 - continue - } - for ($candidate = $index; $candidate -lt $stepEnd; $candidate++) { - if ($lines[$candidate] -match '^(?\s*)with\s*:\s*$') { - $withIndent = $Matches['indent'].Length - $directEntryIndent = -1 - for ($entry = $candidate + 1; $entry -lt $stepEnd; $entry++) { - if ($lines[$entry] -match '^\s*(#.*)?$') { + $propertyIndent = $stepIndent + 2 + $usesCheckout = $lines[$index] -match '^[ ]*-\s+uses\s*:\s*actions/checkout@' + if (-not $usesCheckout) { + for ($candidate = $index + 1; $candidate -lt $stepEnd; $candidate++) { + if ($lines[$candidate] -notmatch '^(?[ ]*)uses\s*:\s*actions/checkout@') { continue } - if ($lines[$entry] -notmatch '^(?\s*)') { + if ($Matches['indent'].Length -eq $propertyIndent) { + $usesCheckout = $true break } + } + } + if (-not $usesCheckout) { + $index = $stepEnd - 1 + continue + } - $entryIndent = $Matches['indent'].Length - if ($entryIndent -le $withIndent) { - break - } - if ($directEntryIndent -lt 0) { - $directEntryIndent = $entryIndent - } - if ( - $entryIndent -eq $directEntryIndent -and - $lines[$entry] -match '^\s*submodules\s*:\s*recursive\s*$' - ) { - return $true + for ($candidate = $index + 1; $candidate -lt $stepEnd; $candidate++) { + if ( + $lines[$candidate] -match '^(?[ ]*)with\s*:\s*$' -and + $Matches['indent'].Length -eq $propertyIndent + ) { + $withIndent = $Matches['indent'].Length + $directEntryIndent = -1 + for ($entry = $candidate + 1; $entry -lt $stepEnd; $entry++) { + if ($lines[$entry] -match '^\s*(#.*)?$') { + continue + } + $entryIndent = ([regex]::Match($lines[$entry], '^[ ]*')).Value.Length + if ($entryIndent -le $withIndent) { + break + } + if ($directEntryIndent -lt 0) { + $directEntryIndent = $entryIndent + } + if ( + $entryIndent -eq $directEntryIndent -and + $lines[$entry] -match '^\s*submodules\s*:\s*recursive\s*$' + ) { + return $true + } } } } + $index = $stepEnd - 1 } - $index = $stepEnd - 1 + $stepsIndex = $stepsEnd - 1 } return $false } diff --git a/scripts/test-agent-docs.ps1 b/scripts/test-agent-docs.ps1 index 81a5e7da..7ae8217f 100644 --- a/scripts/test-agent-docs.ps1 +++ b/scripts/test-agent-docs.ps1 @@ -298,6 +298,32 @@ try { Restore-FixtureFile -RelativePath '.github\workflows\build.yml' } + $workflow = Get-Content -LiteralPath $workflowPath -Raw + $matrixListSubmodules = [regex]::Replace( + $workflow, + '(?m)^\s*submodules:\s*recursive\r?\n', + '' + ) + $matrixListSubmodules = [regex]::Replace( + $matrixListSubmodules, + '(?m)(^ runs-on:\s*windows-latest\s*$)', + "`$1`r`n strategy:`r`n matrix:`r`n include:`r`n - os: windows-latest", + 1 + ) + $matrixListSubmodules = [regex]::Replace( + $matrixListSubmodules, + '(?m)(^\s*dotnet-version:\s*10\.0\.x\s*$)', + "`$1`r`n submodules: recursive", + 1 + ) + [System.IO.File]::WriteAllText($workflowPath, $matrixListSubmodules) + try { + Invoke-IntegrityCheck -ShouldPass $false -ExpectedText 'CI checkout must initialize RatEye with submodules: recursive' -Scenario 'matrix list entries cannot merge checkout with a later action' + } + finally { + Restore-FixtureFile -RelativePath '.github\workflows\build.yml' + } + $workflow = Get-Content -LiteralPath $workflowPath -Raw $conditionalStepSubmodules = [regex]::Replace( $workflow, diff --git a/src/ScanEngine b/src/ScanEngine index f6a5f540..cd5843ee 160000 --- a/src/ScanEngine +++ b/src/ScanEngine @@ -1 +1 @@ -Subproject commit f6a5f540082a2c12c5aefc01e5f8cef2d8682f0a +Subproject commit cd5843ee187f48ef51c3eab5ae3d5e52216ca165 From 78c2d09e80244fc4df6e2586b607364573f15a17 Mon Sep 17 00:00:00 2001 From: DysektAI Date: Tue, 28 Jul 2026 02:31:10 -0400 Subject: [PATCH 21/24] fix: harden checkout integrity parsing --- scripts/check-agent-docs.ps1 | 74 ++++++++++++++++++++++++++++++++++-- scripts/test-agent-docs.ps1 | 62 +++++++++++++++++++++++++----- 2 files changed, 123 insertions(+), 13 deletions(-) diff --git a/scripts/check-agent-docs.ps1 b/scripts/check-agent-docs.ps1 index f6f1f4ad..3b830ba1 100644 --- a/scripts/check-agent-docs.ps1 +++ b/scripts/check-agent-docs.ps1 @@ -189,15 +189,47 @@ function Test-CheckoutUsesRecursiveSubmodules { param([string]$WorkflowText) $lines = [regex]::Split($WorkflowText, '\r?\n') + $blockScalarLines = [bool[]]::new($lines.Count) + for ($header = 0; $header -lt $lines.Count; $header++) { + if ($blockScalarLines[$header]) { + continue + } + if ( + $lines[$header] -notmatch + '^(?[ ]*)(?:-\s+)?[^#].*:\s*[|>](?:[1-9][+-]?|[+-][1-9]?)?\s*(?:#.*)?$' + ) { + continue + } + + $headerIndent = $Matches['indent'].Length + for ($content = $header + 1; $content -lt $lines.Count; $content++) { + if ($lines[$content] -match '^\s*$') { + $blockScalarLines[$content] = $true + continue + } + $contentIndent = ([regex]::Match($lines[$content], '^[ ]*')).Value.Length + if ($contentIndent -le $headerIndent) { + break + } + $blockScalarLines[$content] = $true + } + } + $stepStarterPattern = '^(?\s*)-\s+[^#\s][^:]*\s*:' for ($stepsIndex = 0; $stepsIndex -lt $lines.Count; $stepsIndex++) { - if ($lines[$stepsIndex] -notmatch '^(?[ ]*)steps\s*:\s*(?:#.*)?$') { + if ( + $blockScalarLines[$stepsIndex] -or + $lines[$stepsIndex] -notmatch '^(?[ ]*)steps\s*:\s*(?:#.*)?$' + ) { continue } $stepsIndent = $Matches['indent'].Length $stepsEnd = $lines.Count for ($candidate = $stepsIndex + 1; $candidate -lt $lines.Count; $candidate++) { + if ($blockScalarLines[$candidate]) { + continue + } if ($lines[$candidate] -match '^\s*(?:#.*)?$') { continue } @@ -210,7 +242,7 @@ function Test-CheckoutUsesRecursiveSubmodules { $stepIndent = -1 for ($index = $stepsIndex + 1; $index -lt $stepsEnd; $index++) { - if ($lines[$index] -notmatch $stepStarterPattern) { + if ($blockScalarLines[$index] -or $lines[$index] -notmatch $stepStarterPattern) { continue } @@ -225,6 +257,7 @@ function Test-CheckoutUsesRecursiveSubmodules { $stepEnd = $stepsEnd for ($candidate = $index + 1; $candidate -lt $stepsEnd; $candidate++) { if ( + -not $blockScalarLines[$candidate] -and $lines[$candidate] -match $stepStarterPattern -and $Matches['indent'].Length -eq $stepIndent ) { @@ -237,6 +270,9 @@ function Test-CheckoutUsesRecursiveSubmodules { $usesCheckout = $lines[$index] -match '^[ ]*-\s+uses\s*:\s*actions/checkout@' if (-not $usesCheckout) { for ($candidate = $index + 1; $candidate -lt $stepEnd; $candidate++) { + if ($blockScalarLines[$candidate]) { + continue + } if ($lines[$candidate] -notmatch '^(?[ ]*)uses\s*:\s*actions/checkout@') { continue } @@ -251,7 +287,35 @@ function Test-CheckoutUsesRecursiveSubmodules { continue } + $checkoutCondition = $null + if ($lines[$index] -match '^[ ]*-\s+if\s*:\s*(?.*?)\s*$') { + $checkoutCondition = $Matches['condition'] + } + for ($candidate = $index + 1; $candidate -lt $stepEnd; $candidate++) { + if ($blockScalarLines[$candidate]) { + continue + } + if ( + $lines[$candidate] -match '^(?[ ]*)if\s*:\s*(?.*?)\s*$' -and + $Matches['indent'].Length -eq $propertyIndent + ) { + $checkoutCondition = $Matches['condition'] + break + } + } + if ( + $null -ne $checkoutCondition -and + $checkoutCondition -notmatch + '(?i)^(?:true|\$\{\{\s*true\s*\}\})(?:\s+#.*)?\s*$' + ) { + $index = $stepEnd - 1 + continue + } + for ($candidate = $index + 1; $candidate -lt $stepEnd; $candidate++) { + if ($blockScalarLines[$candidate]) { + continue + } if ( $lines[$candidate] -match '^(?[ ]*)with\s*:\s*$' -and $Matches['indent'].Length -eq $propertyIndent @@ -259,6 +323,9 @@ function Test-CheckoutUsesRecursiveSubmodules { $withIndent = $Matches['indent'].Length $directEntryIndent = -1 for ($entry = $candidate + 1; $entry -lt $stepEnd; $entry++) { + if ($blockScalarLines[$entry]) { + continue + } if ($lines[$entry] -match '^\s*(#.*)?$') { continue } @@ -271,7 +338,8 @@ function Test-CheckoutUsesRecursiveSubmodules { } if ( $entryIndent -eq $directEntryIndent -and - $lines[$entry] -match '^\s*submodules\s*:\s*recursive\s*$' + $lines[$entry] -match + '^\s*submodules\s*:\s*recursive(?:\s+#.*)?\s*$' ) { return $true } diff --git a/scripts/test-agent-docs.ps1 b/scripts/test-agent-docs.ps1 index 7ae8217f..0ad922e2 100644 --- a/scripts/test-agent-docs.ps1 +++ b/scripts/test-agent-docs.ps1 @@ -264,9 +264,8 @@ try { '(?m)^\s*submodules:\s*recursive\r?\n', '' ) - $nestedSubmodules = [regex]::Replace( + $nestedSubmodules = ([regex]'(?m)(^\s*persist-credentials:\s*false\s*$)').Replace( $nestedSubmodules, - '(?m)(^\s*persist-credentials:\s*false\s*$)', "`$1`r`n sparse-checkout: |`r`n submodules: recursive", 1 ) @@ -284,9 +283,8 @@ try { '(?m)^\s*submodules:\s*recursive\r?\n', '' ) - $nonCheckoutSubmodules = [regex]::Replace( + $nonCheckoutSubmodules = ([regex]'(?m)(^\s*dotnet-version:\s*10\.0\.x\s*$)').Replace( $nonCheckoutSubmodules, - '(?m)(^\s*dotnet-version:\s*10\.0\.x\s*$)', "`$1`r`n submodules: recursive", 1 ) @@ -304,15 +302,13 @@ try { '(?m)^\s*submodules:\s*recursive\r?\n', '' ) - $matrixListSubmodules = [regex]::Replace( + $matrixListSubmodules = ([regex]'(?m)(^ runs-on:\s*windows-latest\s*$)').Replace( $matrixListSubmodules, - '(?m)(^ runs-on:\s*windows-latest\s*$)', "`$1`r`n strategy:`r`n matrix:`r`n include:`r`n - os: windows-latest", 1 ) - $matrixListSubmodules = [regex]::Replace( + $matrixListSubmodules = ([regex]'(?m)(^\s*dotnet-version:\s*10\.0\.x\s*$)').Replace( $matrixListSubmodules, - '(?m)(^\s*dotnet-version:\s*10\.0\.x\s*$)', "`$1`r`n submodules: recursive", 1 ) @@ -330,9 +326,8 @@ try { '(?m)^\s*submodules:\s*recursive\r?\n', '' ) - $conditionalStepSubmodules = [regex]::Replace( + $conditionalStepSubmodules = ([regex]'(?m)(^\s*persist-credentials:\s*false\s*$)').Replace( $conditionalStepSubmodules, - '(?m)(^\s*persist-credentials:\s*false\s*$)', "`$1`r`n - if: `${{ always() }}`r`n uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1`r`n with:`r`n submodules: recursive", 1 ) @@ -344,6 +339,53 @@ try { Restore-FixtureFile -RelativePath '.github\workflows\build.yml' } + $workflow = Get-Content -LiteralPath $workflowPath -Raw + $conditionalCheckout = ([regex]'(?m)(^ - name:\s*Checkout\s*$)').Replace( + $workflow, + "`$1`r`n if: false", + 1 + ) + [System.IO.File]::WriteAllText($workflowPath, $conditionalCheckout) + try { + Invoke-IntegrityCheck -ShouldPass $false -ExpectedText 'CI checkout must initialize RatEye with submodules: recursive' -Scenario 'a statically false checkout does not guarantee submodule initialization' + } + finally { + Restore-FixtureFile -RelativePath '.github\workflows\build.yml' + } + + $workflow = Get-Content -LiteralPath $workflowPath -Raw + $blockScalarCheckout = [regex]::Replace( + $workflow, + '(?m)^\s*submodules:\s*recursive\r?\n', + '' + ) + $blockScalarCheckout = ([regex]'(?m)^jobs:\s*$').Replace( + $blockScalarCheckout, + "env:`r`n EXAMPLE_WORKFLOW: |`r`n steps:`r`n - uses: actions/checkout@example`r`n with:`r`n submodules: recursive`r`njobs:", + 1 + ) + [System.IO.File]::WriteAllText($workflowPath, $blockScalarCheckout) + try { + Invoke-IntegrityCheck -ShouldPass $false -ExpectedText 'CI checkout must initialize RatEye with submodules: recursive' -Scenario 'workflow text inside a block scalar cannot impersonate checkout' + } + finally { + Restore-FixtureFile -RelativePath '.github\workflows\build.yml' + } + + $workflow = Get-Content -LiteralPath $workflowPath -Raw + $commentedSubmodules = ([regex]'(?m)(^\s*submodules:\s*recursive)\s*$').Replace( + $workflow, + '$1 # initialize RatEye', + 1 + ) + [System.IO.File]::WriteAllText($workflowPath, $commentedSubmodules) + try { + Invoke-IntegrityCheck -ShouldPass $true -ExpectedText 'All documentation integrity checks passed.' -Scenario 'recursive submodules allow an inline YAML comment' + } + finally { + Restore-FixtureFile -RelativePath '.github\workflows\build.yml' + } + $workflow = Get-Content -LiteralPath $workflowPath -Raw $wrongPushWorkflow = [regex]::Replace( $workflow, From cda68f150b2886628210ff6dafcaa927a0565408 Mon Sep 17 00:00:00 2001 From: DysektAI Date: Tue, 28 Jul 2026 02:39:10 -0400 Subject: [PATCH 22/24] chore: advance RatEye constructor cleanup --- src/ScanEngine | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ScanEngine b/src/ScanEngine index cd5843ee..20ad8ea0 160000 --- a/src/ScanEngine +++ b/src/ScanEngine @@ -1 +1 @@ -Subproject commit cd5843ee187f48ef51c3eab5ae3d5e52216ca165 +Subproject commit 20ad8ea03e68e4a4a57bcb3c249f90c91301ae10 From a1e40aeadbea3b4c4bacd7513289f1f3905d0156 Mon Sep 17 00:00:00 2001 From: DysektAI Date: Tue, 28 Jul 2026 02:45:15 -0400 Subject: [PATCH 23/24] chore: advance RatEye CI stabilization --- src/ScanEngine | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ScanEngine b/src/ScanEngine index 20ad8ea0..55cbcbfd 160000 --- a/src/ScanEngine +++ b/src/ScanEngine @@ -1 +1 @@ -Subproject commit 20ad8ea03e68e4a4a57bcb3c249f90c91301ae10 +Subproject commit 55cbcbfd999abb08d3ea8be15f8502f2693ba9a3 From 14fe1fc9dea95c23c882af9b303006a64540ff18 Mon Sep 17 00:00:00 2001 From: DysektAI Date: Tue, 28 Jul 2026 02:48:51 -0400 Subject: [PATCH 24/24] chore: advance RatEye disposal hardening --- src/ScanEngine | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ScanEngine b/src/ScanEngine index 55cbcbfd..18c91419 160000 --- a/src/ScanEngine +++ b/src/ScanEngine @@ -1 +1 @@ -Subproject commit 55cbcbfd999abb08d3ea8be15f8502f2693ba9a3 +Subproject commit 18c914193835fcccd76b1963aeb1debff574239d