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/.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/.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..a446d915 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,17 +1,17 @@ # 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) -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`. 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). @@ -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,9 +125,9 @@ 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 -**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/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..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 @@ -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..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 @@ -64,10 +66,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..6c7b4c3e 100644 --- a/docs/agent-context/scan-engine.md +++ b/docs/agent-context/scan-engine.md @@ -1,13 +1,15 @@ # 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. +- **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. @@ -56,7 +58,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 +66,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 +98,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..3b830ba1 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 @@ -157,6 +157,202 @@ 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 ($line -match '^\s*\[.+\]\s*$') { + $current = $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') + $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 ( + $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 + } + $candidateIndent = ([regex]::Match($lines[$candidate], '^[ ]*')).Value.Length + if ($candidateIndent -le $stepsIndent) { + $stepsEnd = $candidate + break + } + } + + $stepIndent = -1 + for ($index = $stepsIndex + 1; $index -lt $stepsEnd; $index++) { + if ($blockScalarLines[$index] -or $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 ( + -not $blockScalarLines[$candidate] -and + $lines[$candidate] -match $stepStarterPattern -and + $Matches['indent'].Length -eq $stepIndent + ) { + $stepEnd = $candidate + break + } + } + + $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 ($blockScalarLines[$candidate]) { + continue + } + if ($lines[$candidate] -notmatch '^(?[ ]*)uses\s*:\s*actions/checkout@') { + continue + } + if ($Matches['indent'].Length -eq $propertyIndent) { + $usesCheckout = $true + break + } + } + } + if (-not $usesCheckout) { + $index = $stepEnd - 1 + 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 + ) { + $withIndent = $Matches['indent'].Length + $directEntryIndent = -1 + for ($entry = $candidate + 1; $entry -lt $stepEnd; $entry++) { + if ($blockScalarLines[$entry]) { + continue + } + 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+#.*)?\s*$' + ) { + return $true + } + } + } + } + $index = $stepEnd - 1 + } + $stepsIndex = $stepsEnd - 1 + } + return $false +} + function Get-ItemVersion { param([System.Xml.XmlElement]$Item) @@ -325,10 +521,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 +539,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 +565,21 @@ 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 + $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' + } + elseif ($ratEyeSubmodules[0].Url -ne 'https://github.com/tarkovtracker-org/RatEye.git') { + 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 +631,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 +664,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 +685,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 (-not (Test-CheckoutUsesRecursiveSubmodules -WorkflowText $ciText)) { + 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..0ad922e2 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,59 @@ 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' + } + + [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, + @" +[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) } $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 +246,146 @@ 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 + $nestedSubmodules = [regex]::Replace( + $workflow, + '(?m)^\s*submodules:\s*recursive\r?\n', + '' + ) + $nestedSubmodules = ([regex]'(?m)(^\s*persist-credentials:\s*false\s*$)').Replace( + $nestedSubmodules, + "`$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, + '(?m)^\s*submodules:\s*recursive\r?\n', + '' + ) + $nonCheckoutSubmodules = ([regex]'(?m)(^\s*dotnet-version:\s*10\.0\.x\s*$)').Replace( + $nonCheckoutSubmodules, + "`$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 + $matrixListSubmodules = [regex]::Replace( + $workflow, + '(?m)^\s*submodules:\s*recursive\r?\n', + '' + ) + $matrixListSubmodules = ([regex]'(?m)(^ runs-on:\s*windows-latest\s*$)').Replace( + $matrixListSubmodules, + "`$1`r`n strategy:`r`n matrix:`r`n include:`r`n - os: windows-latest", + 1 + ) + $matrixListSubmodules = ([regex]'(?m)(^\s*dotnet-version:\s*10\.0\.x\s*$)').Replace( + $matrixListSubmodules, + "`$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, + '(?m)^\s*submodules:\s*recursive\r?\n', + '' + ) + $conditionalStepSubmodules = ([regex]'(?m)(^\s*persist-credentials:\s*false\s*$)').Replace( + $conditionalStepSubmodules, + "`$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 + $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, diff --git a/src/App/AGENTS.md b/src/App/AGENTS.md index b12a8a49..1f281d32 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`. @@ -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 7241e76d..5e6d83c4 100644 --- a/src/App/Pages/App/Settings/SettingsAdvanced.razor +++ b/src/App/Pages/App/Settings/SettingsAdvanced.razor @@ -106,6 +106,15 @@ ValueChanged="@(_ => StateHasChanged())" Label="@Localizer["LogDebugInfo"]" Persist="SettingsVM.SetLogDebugAsync" /> + + @Localizer["ExportScanDiagnostics"] + +

@Localizer["ExportScanDiagnosticsDescription"]

@@ -129,6 +138,7 @@ private bool _savingDisplay; private int _displaySaveOperations; + private bool _exportingScanDiagnostics; protected override async Task OnInitializedAsync() { @@ -179,6 +189,32 @@ await PersistDisplayAsync(); } + private async Task ExportScanDiagnosticsAsync() + { + if (_exportingScanDiagnostics) + return; + + _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); 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..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; @@ -63,6 +64,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 +485,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})" @@ -523,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 = @@ -534,17 +549,46 @@ 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); // 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, + diagnosticDisplay, + 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 +637,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 +687,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 +828,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..bbce1c46 --- /dev/null +++ b/src/App/ScanDiagnosticStore.cs @@ -0,0 +1,159 @@ +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, + MarkerThreshold = ratEyeConfig.ProcessingConfig.InspectionConfig.MarkerThreshold, + MinItemConfidence = ratEyeConfig.ProcessingConfig.InspectionConfig.MinItemConfidence, + }, + 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.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), + }, + }; + 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..3da28eb8 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 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}", "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..18c91419 --- /dev/null +++ b/src/ScanEngine @@ -0,0 +1 @@ +Subproject commit 18c914193835fcccd76b1963aeb1debff574239d 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 a1fc8097..00000000 Binary files a/src/ScanEngine/RatLogo.png and /dev/null differ diff --git a/src/ScanEngine/Resources/cell_full_border.png b/src/ScanEngine/Resources/cell_full_border.png deleted file mode 100644 index c253a1b3..00000000 Binary files a/src/ScanEngine/Resources/cell_full_border.png and /dev/null differ diff --git a/src/ScanEngine/Resources/icon_mod/icon_mod_aux.png b/src/ScanEngine/Resources/icon_mod/icon_mod_aux.png deleted file mode 100644 index 85ad2eb6..00000000 Binary files a/src/ScanEngine/Resources/icon_mod/icon_mod_aux.png and /dev/null differ 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 eddc05fa..00000000 Binary files a/src/ScanEngine/Resources/icon_mod/icon_mod_barrel.png and /dev/null differ 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 18072824..00000000 Binary files a/src/ScanEngine/Resources/icon_mod/icon_mod_bipod.png and /dev/null differ 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 d7c168a0..00000000 Binary files a/src/ScanEngine/Resources/icon_mod/icon_mod_charge.png and /dev/null differ diff --git a/src/ScanEngine/Resources/icon_mod/icon_mod_flashlight.png b/src/ScanEngine/Resources/icon_mod/icon_mod_flashlight.png deleted file mode 100644 index 7707e8c3..00000000 Binary files a/src/ScanEngine/Resources/icon_mod/icon_mod_flashlight.png and /dev/null differ diff --git a/src/ScanEngine/Resources/icon_mod/icon_mod_gasblock.png b/src/ScanEngine/Resources/icon_mod/icon_mod_gasblock.png deleted file mode 100644 index 57ea4c4d..00000000 Binary files a/src/ScanEngine/Resources/icon_mod/icon_mod_gasblock.png and /dev/null differ 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 ec687f85..00000000 Binary files a/src/ScanEngine/Resources/icon_mod/icon_mod_handguard.png and /dev/null differ diff --git a/src/ScanEngine/Resources/icon_mod/icon_mod_ironsight.png b/src/ScanEngine/Resources/icon_mod/icon_mod_ironsight.png deleted file mode 100644 index 61e306aa..00000000 Binary files a/src/ScanEngine/Resources/icon_mod/icon_mod_ironsight.png and /dev/null differ 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 8d260b9c..00000000 Binary files a/src/ScanEngine/Resources/icon_mod/icon_mod_launcher.png and /dev/null differ 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 7707e8c3..00000000 Binary files a/src/ScanEngine/Resources/icon_mod/icon_mod_lightlaser.png and /dev/null differ diff --git a/src/ScanEngine/Resources/icon_mod/icon_mod_magazine.png b/src/ScanEngine/Resources/icon_mod/icon_mod_magazine.png deleted file mode 100644 index 56f5f278..00000000 Binary files a/src/ScanEngine/Resources/icon_mod/icon_mod_magazine.png and /dev/null differ 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 94d49e72..00000000 Binary files a/src/ScanEngine/Resources/icon_mod/icon_mod_mount.png and /dev/null differ diff --git a/src/ScanEngine/Resources/icon_mod/icon_mod_muzzle.png b/src/ScanEngine/Resources/icon_mod/icon_mod_muzzle.png deleted file mode 100644 index 0fcb390c..00000000 Binary files a/src/ScanEngine/Resources/icon_mod/icon_mod_muzzle.png and /dev/null differ 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 7c0b511a..00000000 Binary files a/src/ScanEngine/Resources/icon_mod/icon_mod_pistol_grip.png and /dev/null differ 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 e1faf9df..00000000 Binary files a/src/ScanEngine/Resources/icon_mod/icon_mod_railcovers.png and /dev/null differ 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 c4a602bd..00000000 Binary files a/src/ScanEngine/Resources/icon_mod/icon_mod_receiver.png and /dev/null differ 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 e10b7b84..00000000 Binary files a/src/ScanEngine/Resources/icon_mod/icon_mod_sight.png and /dev/null differ 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 2f882778..00000000 Binary files a/src/ScanEngine/Resources/icon_mod/icon_mod_stock.png and /dev/null differ diff --git a/src/ScanEngine/Resources/icon_mod/icon_mod_tactical.png b/src/ScanEngine/Resources/icon_mod/icon_mod_tactical.png deleted file mode 100644 index 0c8acf7f..00000000 Binary files a/src/ScanEngine/Resources/icon_mod/icon_mod_tactical.png and /dev/null differ diff --git a/src/ScanEngine/Resources/icon_search.png b/src/ScanEngine/Resources/icon_search.png deleted file mode 100644 index 363c40f0..00000000 Binary files a/src/ScanEngine/Resources/icon_search.png and /dev/null differ diff --git a/src/ScanEngine/Resources/icon_type/mod_gear.png b/src/ScanEngine/Resources/icon_type/mod_gear.png deleted file mode 100644 index 939d958d..00000000 Binary files a/src/ScanEngine/Resources/icon_type/mod_gear.png and /dev/null differ 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 f82c2cf2..00000000 Binary files a/src/ScanEngine/Resources/icon_type/mod_generic.png and /dev/null differ 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 86dc8fbb..00000000 Binary files a/src/ScanEngine/Resources/icon_type/mod_vital.png and /dev/null differ diff --git a/src/ScanEngine/VENDOR.md b/src/ScanEngine/VENDOR.md deleted file mode 100644 index de14eb4a..00000000 --- a/src/ScanEngine/VENDOR.md +++ /dev/null @@ -1,24 +0,0 @@ -# Vendored scan engine (historical RatEye) - -Folder: `src/ScanEngine/` -Assembly / namespaces: still **`RatEye`** (unchanged for now to avoid a large API rename). - -Sources vendored from 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..7b918fa9 100644 --- a/tests/AGENTS.md +++ b/tests/AGENTS.md @@ -15,7 +15,8 @@ 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 @@ -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..948eca83 --- /dev/null +++ b/tests/RatScanner.Tests/ScanDiagnosticStoreTests.cs @@ -0,0 +1,109 @@ +using System; +using System.Collections.Generic; +using System.Drawing; +using System.IO; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using RatEye.Diagnostics; +using RatScanner.Display; +using Xunit; + +namespace RatScanner.Tests; + +[Collection(RatConfigCollection.Name)] +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 }, + }, + }; + GameDisplayConfiguration display = GameDisplayConfiguration.Empty with + { + CaptureBounds = new Rectangle(100, 200, 32, 24), + DisplayScale = 1.5, + }; + store.Record( + "inventory", + capture, + new RatEye.Vector2(100, 200), + new RatEye.Vector2(16, 12), + [new("item-id", "Fixture item", 0.95f), new(null, null, 0.25f)], + new Dictionary { ["inventory.grid_parse"] = 4.5 }, + config, + display, + "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); + var exportedManifest = JsonConvert.DeserializeObject(manifest); + Assert.NotNull(exportedManifest); + 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( + exportedManifest.Observed.Confidences, + confidence => Assert.Equal(0.95f, confidence, precision: 4), + confidence => Assert.Equal(0.25f, confidence, precision: 4) + ); + } + 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(