diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 0000000..b4575a4
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,27 @@
+root = true
+
+[*]
+charset = utf-8
+end_of_line = lf
+insert_final_newline = true
+trim_trailing_whitespace = true
+
+[*.{cs,axaml,csproj,props,targets}]
+indent_style = space
+indent_size = 2
+
+[*.cs]
+indent_size = 4
+dotnet_sort_system_directives_first = true
+dotnet_style_qualification_for_event = false:suggestion
+dotnet_style_qualification_for_field = false:suggestion
+dotnet_style_qualification_for_method = false:suggestion
+dotnet_style_qualification_for_property = false:suggestion
+csharp_style_namespace_declarations = file_scoped:suggestion
+csharp_style_var_elsewhere = true:suggestion
+csharp_style_var_for_built_in_types = true:suggestion
+csharp_style_var_when_type_is_apparent = true:suggestion
+
+[*.md]
+trim_trailing_whitespace = false
+
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
new file mode 100644
index 0000000..b03fe62
--- /dev/null
+++ b/.github/workflows/build.yml
@@ -0,0 +1,59 @@
+name: build
+
+on:
+ push:
+ branches: [ main ]
+ pull_request:
+ branches: [ main ]
+
+jobs:
+ build-test-publish:
+ runs-on: ubuntu-24.04
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup .NET
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: 8.0.x
+
+ - name: Restore
+ run: dotnet restore Ehwrj.sln
+
+ - name: Format check
+ run: dotnet format Ehwrj.sln --no-restore --verify-no-changes --verbosity minimal
+
+ - name: Build
+ run: dotnet build Ehwrj.sln -c Release --no-restore
+
+ - name: Test
+ run: dotnet run --project tests/Ehwrj.Tests/Ehwrj.Tests.csproj -c Release --no-build
+
+ - name: Safety scan
+ run: scripts/verify-safety.sh
+
+ - name: Check local API stub
+ run: dotnet run --project tools/Ehwrj.Tools.LocalApiStub/Ehwrj.Tools.LocalApiStub.csproj -c Release --no-build -- --help
+
+ - name: Check capture tool
+ run: dotnet run --project tools/Ehwrj.Tools.Capture/Ehwrj.Tools.Capture.csproj -c Release --no-build -- --help
+
+ - name: Publish Windows x64
+ run: >
+ dotnet publish src/Ehwrj.App/Ehwrj.App.csproj
+ -c Release
+ -r win-x64
+ --self-contained true
+ -p:PublishSingleFile=true
+ -p:PublishDir=artifacts/win-x64/
+
+ - name: Package Windows x64
+ run: scripts/package-win-x64.sh --publish-dir artifacts/win-x64 --zip artifacts/ehwrj-win-x64.zip
+
+ - name: Upload artifact
+ uses: actions/upload-artifact@v4
+ with:
+ name: ehwrj-win-x64
+ path: artifacts/ehwrj-win-x64.zip
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..cc6eac2
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,13 @@
+bin/
+obj/
+.vs/
+.vscode/
+*.user
+*.suo
+*.nupkg
+publish/
+captures/
+artifacts/*
+!artifacts/ehwrj-win-x64.zip
+!artifacts/ehwrj-win-x64/
+!artifacts/ehwrj-win-x64/**
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..4d6bdc9
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,58 @@
+# Contributing
+
+Thanks for improving Ehwrj.
+
+## Ground Rules
+
+- Keep the project limited to the benign War Thunder local map companion scope.
+- Do not add clipboard listeners, startup persistence, PE/ZIP mutation, credential collection, or external network reporting.
+- Keep local API traffic restricted to loopback addresses.
+- Put reusable parsing, projection, and tracking logic in `Ehwrj.Core`.
+- Keep Avalonia UI, overlay windows, and platform interop in `Ehwrj.App`.
+- Add or update tests when parser, coordinate, or tracking behavior changes.
+
+## Local Workflow
+
+On Ubuntu, bootstrap dependencies and run the full verification/publish pipeline:
+
+```bash
+scripts/bootstrap-ubuntu.sh
+```
+
+For an already prepared environment:
+
+```bash
+dotnet restore Ehwrj.sln
+dotnet format Ehwrj.sln --no-restore --verify-no-changes --verbosity minimal
+dotnet build Ehwrj.sln -c Release
+dotnet run --project tests/Ehwrj.Tests/Ehwrj.Tests.csproj -c Release --no-build
+scripts/verify-safety.sh
+```
+
+To develop the UI without War Thunder:
+
+```bash
+scripts/run-local-api-stub.sh
+```
+
+To capture and replay a real local API session:
+
+```bash
+scripts/capture-local-api.sh captures/my-session
+scripts/validate-capture.sh captures/my-session
+scripts/run-local-api-stub.sh 8111 captures/my-session
+```
+
+To publish a Windows x64 build from Linux:
+
+```bash
+scripts/publish-win-x64.sh
+```
+
+## Review Checklist
+
+- Build succeeds with zero warnings.
+- Tests pass.
+- New network code is justified and loopback-only unless explicitly documented.
+- UI controls remain dense, readable, and focused on operating the map/overlay.
+- Security boundaries in `SECURITY.md` are preserved.
diff --git a/Directory.Build.props b/Directory.Build.props
new file mode 100644
index 0000000..8ca5292
--- /dev/null
+++ b/Directory.Build.props
@@ -0,0 +1,13 @@
+
+
+ latest
+ enable
+ enable
+ latest
+ true
+ true
+ true
+ true
+ true
+
+
diff --git a/Ehwrj.sln b/Ehwrj.sln
new file mode 100644
index 0000000..fcd2d57
--- /dev/null
+++ b/Ehwrj.sln
@@ -0,0 +1,59 @@
+
+Microsoft Visual Studio Solution File, Format Version 12.00
+# Visual Studio Version 17
+VisualStudioVersion = 17.0.31903.59
+MinimumVisualStudioVersion = 10.0.40219.1
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{AFC3199F-8F50-446D-A8A1-A501A07FD238}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Ehwrj.App", "src\Ehwrj.App\Ehwrj.App.csproj", "{E48273EA-304E-47AE-8206-9EFFB2BC00AE}"
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{46338909-A80A-49F7-A274-6B62A2E37FCC}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Ehwrj.Tests", "tests\Ehwrj.Tests\Ehwrj.Tests.csproj", "{B253C8DF-A67D-494E-8805-BD1F02231672}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Ehwrj.Core", "src\Ehwrj.Core\Ehwrj.Core.csproj", "{DCD75174-7AB0-4FC6-BD08-AB9F5E40F2C2}"
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tools", "tools", "{669545BC-3FD6-4044-92E8-7B4142525C62}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Ehwrj.Tools.LocalApiStub", "tools\Ehwrj.Tools.LocalApiStub\Ehwrj.Tools.LocalApiStub.csproj", "{4CD90023-EA88-4E9F-AF88-17DC3202E961}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Ehwrj.Tools.Capture", "tools\Ehwrj.Tools.Capture\Ehwrj.Tools.Capture.csproj", "{A59D73D0-BEC0-4DDB-875E-70C3EA9C8808}"
+EndProject
+Global
+ GlobalSection(SolutionConfigurationPlatforms) = preSolution
+ Debug|Any CPU = Debug|Any CPU
+ Release|Any CPU = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(SolutionProperties) = preSolution
+ HideSolutionNode = FALSE
+ EndGlobalSection
+ GlobalSection(ProjectConfigurationPlatforms) = postSolution
+ {E48273EA-304E-47AE-8206-9EFFB2BC00AE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {E48273EA-304E-47AE-8206-9EFFB2BC00AE}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {E48273EA-304E-47AE-8206-9EFFB2BC00AE}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {E48273EA-304E-47AE-8206-9EFFB2BC00AE}.Release|Any CPU.Build.0 = Release|Any CPU
+ {B253C8DF-A67D-494E-8805-BD1F02231672}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {B253C8DF-A67D-494E-8805-BD1F02231672}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {B253C8DF-A67D-494E-8805-BD1F02231672}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {B253C8DF-A67D-494E-8805-BD1F02231672}.Release|Any CPU.Build.0 = Release|Any CPU
+ {DCD75174-7AB0-4FC6-BD08-AB9F5E40F2C2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {DCD75174-7AB0-4FC6-BD08-AB9F5E40F2C2}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {DCD75174-7AB0-4FC6-BD08-AB9F5E40F2C2}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {DCD75174-7AB0-4FC6-BD08-AB9F5E40F2C2}.Release|Any CPU.Build.0 = Release|Any CPU
+ {4CD90023-EA88-4E9F-AF88-17DC3202E961}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {4CD90023-EA88-4E9F-AF88-17DC3202E961}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {4CD90023-EA88-4E9F-AF88-17DC3202E961}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {4CD90023-EA88-4E9F-AF88-17DC3202E961}.Release|Any CPU.Build.0 = Release|Any CPU
+ {A59D73D0-BEC0-4DDB-875E-70C3EA9C8808}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {A59D73D0-BEC0-4DDB-875E-70C3EA9C8808}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {A59D73D0-BEC0-4DDB-875E-70C3EA9C8808}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {A59D73D0-BEC0-4DDB-875E-70C3EA9C8808}.Release|Any CPU.Build.0 = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(NestedProjects) = preSolution
+ {E48273EA-304E-47AE-8206-9EFFB2BC00AE} = {AFC3199F-8F50-446D-A8A1-A501A07FD238}
+ {B253C8DF-A67D-494E-8805-BD1F02231672} = {46338909-A80A-49F7-A274-6B62A2E37FCC}
+ {DCD75174-7AB0-4FC6-BD08-AB9F5E40F2C2} = {AFC3199F-8F50-446D-A8A1-A501A07FD238}
+ {4CD90023-EA88-4E9F-AF88-17DC3202E961} = {669545BC-3FD6-4044-92E8-7B4142525C62}
+ {A59D73D0-BEC0-4DDB-875E-70C3EA9C8808} = {669545BC-3FD6-4044-92E8-7B4142525C62}
+ EndGlobalSection
+EndGlobal
diff --git a/LICENSE b/LICENSE
index 7a3094a..08f2ed7 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,11 +1,22 @@
-DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
-Version 2, December 2004
+MIT License
-Copyright (C) 2004 Sam Hocevar
+Copyright (c) 2026 Ehwrj contributors
-Everyone is permitted to copy and distribute verbatim or modified copies of this license document, and changing it is allowed as long as the name is changed.
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
-DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
-TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
- 0. You just DO WHAT THE FUCK YOU WANT TO.
diff --git a/README.md b/README.md
index aa18493..e75a110 100644
--- a/README.md
+++ b/README.md
@@ -1,3 +1,206 @@
-# ehwrj
+# Ehwrj
-썬평ㅋㅋ
\ No newline at end of file
+Ehwrj is a clean-room War Thunder live map companion for Windows.
+
+It reads War Thunder's local map service at `http://127.0.0.1:8111`, renders a desktop map view, and can show a click-through always-on-top overlay. The code is newly written and intentionally excludes the malicious behavior found in the analyzed sample.
+
+## Analyzed Malware File
+
+This repository does not contain the original malicious sample. The notes below summarize the static analysis used to separate the benign WT Live Map behavior from the malicious loader/clipper behavior.
+
+Analysis date: 2026-06-02
+Analysis method: static analysis only; the sample was not executed.
+
+| File | SHA-256 | Assessment |
+| --- | --- | --- |
+| `WTLiveMap.exe` | `4dbbc21d9c2ed70dde046a2f737ee4173da807d43d5b3a6acfe447864ed6d643` | Native x64 Win32/C++ executable containing RCDATA 101 and 102. |
+| `WTLiveMap.zip` | `429e07a27e7896f75a901a1df3cd6560b43de33986920f1f22013ef155541b4c` | ZIP package containing the same suspicious executable. |
+| `resource_101.bin` | `00732b0bbc1740ebe88745424c88ab840b381475837e5e02e937a29e66df3909` | Benign WT Live Map UI/WebView-style resource. |
+| `resource_102.bin` | `03547c81562a065bcb08defa638866c8c1c79343d78b8df7a8a7e0cb827242d7` | Resource-less loader/clipper variant with the same malicious capability class as the outer EXE. |
+
+Key findings:
+
+- RCDATA 101 matches the non-virus WT Live Map behavior: it references `Warthunder LiveMap`, `map_info.json`, `map_obj.json`, `map.img`, `aces.exe`, WebView messaging, and loopback access to `127.0.0.1:8111`.
+- RCDATA 102 is not byte-identical to the outer `WTLiveMap.exe`, but it shares the suspicious import profile, hidden string structure, Windows Update disguise strings, clipboard API strings, and cryptocurrency address replacement logic.
+- The malicious path monitors clipboard text and replaces BTC, ETH, SOL, TRX, XRP, DOGE, LTC, and BCH wallet addresses with attacker-controlled addresses.
+- Persistence is disguised as Windows Update. The analyzed code builds paths similar to `%TEMP%\WindowsUpdateModule\SystemUpdateService.exe` and creates a startup shortcut named `WindowsSystemUpdate.lnk`.
+- `SHGetSpecialFolderPathW(CSIDL_STARTUP)`, Shell Link COM use, `Global\WinSysUpdateMutexV11`, and resource update APIs were observed in the malicious loader path.
+- A ZIP repackaging path uses hidden PowerShell command construction, extracts a ZIP, locates an inner EXE, reinvokes itself with `--merge-env`, injects RCDATA 101/102, and recreates the ZIP.
+- No external C2 server, IP, domain, or upload path was confirmed in static analysis. The confirmed monetization path is clipboard-based cryptocurrency address replacement.
+
+## What It Implements
+
+- Local War Thunder API polling:
+ - `/map_info.json`
+ - `/map_obj.json`
+ - `/map.img`
+- Optional local telemetry/message polling when available:
+ - `/state`
+ - `/hudmsg`
+ - `/gamechat`
+- Main map preview with aircraft/object markers
+- Transparent click-through overlay
+- Configurable overlay size, position, zoom, minimap, Mach labels, and spot radar
+- Overlay controls for aircraft scale, minimum Mach filters, radar spread, vertical scale/offset, arrow size, opacity, label colors, font sizes, distance, Mach, and closure speed labels
+- English/Korean UI language selection
+- Settings persisted under `%LOCALAPPDATA%\Ehwrj\settings.json`
+- Windows build from Linux using .NET 8 and Avalonia targeting `win-x64`
+
+## Explicitly Not Implemented
+
+The analyzed binary contained malicious behavior. Ehwrj does not implement:
+
+- Clipboard monitoring or cryptocurrency address replacement
+- Windows Update disguise or startup persistence
+- ZIP/EXE infection or resource injection
+- Hidden mutex-based malware lifecycle control
+- Any external C2, exfiltration, or remote upload path
+
+## Build
+
+From Ubuntu:
+
+```bash
+cd ehwrj
+scripts/bootstrap-ubuntu.sh
+```
+
+The bootstrap script installs missing Ubuntu packages for .NET 8 and the safety scanner, then runs restore, build, tests, the safety scan, tool smoke checks, and Windows x64 publish.
+
+If the dependencies are already installed:
+
+```bash
+dotnet restore ehwrj/Ehwrj.sln
+dotnet build ehwrj/Ehwrj.sln -c Release
+dotnet run --project ehwrj/tests/Ehwrj.Tests/Ehwrj.Tests.csproj -c Release
+dotnet publish ehwrj/src/Ehwrj.App/Ehwrj.App.csproj -c Release -r win-x64 --self-contained true \
+ -p:PublishSingleFile=true
+```
+
+Or from inside the `ehwrj` folder:
+
+```bash
+scripts/publish-win-x64.sh
+```
+
+The output EXE will be under:
+
+```text
+ehwrj/src/Ehwrj.App/bin/Release/net8.0/win-x64/publish/
+```
+
+The portable release ZIP is written to:
+
+```text
+ehwrj/artifacts/ehwrj-win-x64.zip
+```
+
+## Runtime
+
+Run War Thunder first, then start Ehwrj on Windows. The app expects the game to expose the local map API on `127.0.0.1:8111`.
+
+The current coordinate and speed estimates are intentionally conservative because War Thunder's local API shape can vary by mode and vehicle. Use real `map_info.json` and `map_obj.json` captures to tune projection and spot radar math for a specific game mode.
+
+## Local API Stub
+
+For UI development without War Thunder, run the deterministic local API stub:
+
+```bash
+cd ehwrj
+scripts/run-local-api-stub.sh
+```
+
+It serves:
+
+- `http://127.0.0.1:8111/map_info.json`
+- `http://127.0.0.1:8111/map_obj.json`
+- `http://127.0.0.1:8111/map.img`
+- `http://127.0.0.1:8111/state`
+- `http://127.0.0.1:8111/hudmsg`
+- `http://127.0.0.1:8111/gamechat`
+
+Use another port for endpoint testing:
+
+```bash
+scripts/run-local-api-stub.sh 18111
+```
+
+## Capturing Real Local API Data
+
+When War Thunder is running on Windows, capture the local API into a fixture directory:
+
+```bash
+cd ehwrj
+scripts/capture-local-api.sh captures/my-session
+```
+
+The capture tool saves:
+
+- `map_info.json`
+- `map_obj.json`
+- `map.img`
+- `state.json` when `/state` is available
+- `hudmsg.json` when `/hudmsg` is available
+- `gamechat.json` when `/gamechat` is available
+- `capture-report.txt` with parser coverage, raw object field frequency, unknown object samples, replay readiness, and tuning warnings
+
+Validate an existing capture:
+
+```bash
+scripts/validate-capture.sh captures/my-session
+```
+
+Use the report to tune real game-mode support. The most useful sections are `Object field coverage`, `Unknown object samples`, and `State field names`; they show which raw War Thunder fields are present and where object classification or telemetry parsing needs adjustment.
+
+Replay a capture through the local API stub:
+
+```bash
+scripts/run-local-api-stub.sh 8111 captures/my-session
+```
+
+## New Code Structure
+
+```text
+Directory.Build.props
+ shared compiler, analyzer, and Windows-targeting settings
+docs/
+ architecture and feature parity notes
+scripts/
+ Ubuntu bootstrap, safety scan, capture, publish, and packaging helpers
+src/Ehwrj.App/
+ Avalonia desktop UI and overlay host
+src/Ehwrj.App/Models/
+ UI settings, localization text, endpoint health, and render snapshots
+src/Ehwrj.App/Services/
+ polling adapter and settings store
+src/Ehwrj.App/Rendering/
+ main map and overlay drawing surfaces
+src/Ehwrj.App/ViewModels/
+ app state and UI commands
+src/Ehwrj.App/Infrastructure/
+ minimal Win32 interop for click-through overlay styles
+src/Ehwrj.Core/
+ loopback API parsing, map models, telemetry models, tracking, and projection logic
+src/Ehwrj.Core/Models/
+ War Thunder map object, map info, flight state, battle message, and motion tracker types
+src/Ehwrj.Core/Services/
+ local WT API client, process probe, loopback guard, and capture fixture analyzer
+src/Ehwrj.Core/Geometry/
+ viewport, projected point, and coordinate projection math
+tools/Ehwrj.Tools.LocalApiStub/
+ deterministic local map API server and capture replay helper
+tools/Ehwrj.Tools.Capture/
+ local API fixture capture and validation tool
+tests/Ehwrj.Tests/
+ lightweight parser, projection, localization, settings, and safety checks
+artifacts/ehwrj-win-x64/
+ committed portable Windows x64 build output, including Ehwrj.exe and checksums
+```
+
+## CI
+
+The repository includes a GitHub Actions workflow that restores, checks formatting, builds with warnings as errors, runs the lightweight tests, publishes a Windows x64 artifact, and uploads it as `ehwrj-win-x64`.
+
+## Feature Parity
+
+See [docs/feature-matrix.md](docs/feature-matrix.md) for the benign WT Live Map feature parity status and remaining data gaps.
diff --git a/SECURITY.md b/SECURITY.md
new file mode 100644
index 0000000..3af5748
--- /dev/null
+++ b/SECURITY.md
@@ -0,0 +1,23 @@
+# Security Scope
+
+Ehwrj is a clean-room replacement for the benign War Thunder live map behavior observed in the analyzed sample.
+
+Allowed behavior:
+
+- Connect to `127.0.0.1:8111` only
+- Read local War Thunder map endpoints
+- Store user settings in `%LOCALAPPDATA%\Ehwrj`
+- Create an optional visible overlay window controlled by the user
+
+Disallowed behavior:
+
+- Clipboard listeners
+- Cryptocurrency wallet matching or replacement
+- Startup persistence
+- Windows Update impersonation
+- ZIP, PE, or resource modification
+- Hidden external network communication
+- Credential, cookie, wallet, browser, or messenger file collection
+
+Issues or pull requests that add disallowed behavior should be rejected.
+
diff --git a/artifacts/ehwrj-win-x64.zip b/artifacts/ehwrj-win-x64.zip
new file mode 100644
index 0000000..63408d4
Binary files /dev/null and b/artifacts/ehwrj-win-x64.zip differ
diff --git a/artifacts/ehwrj-win-x64/Ehwrj.exe b/artifacts/ehwrj-win-x64/Ehwrj.exe
new file mode 100755
index 0000000..a27efac
Binary files /dev/null and b/artifacts/ehwrj-win-x64/Ehwrj.exe differ
diff --git a/artifacts/ehwrj-win-x64/LICENSE b/artifacts/ehwrj-win-x64/LICENSE
new file mode 100644
index 0000000..08f2ed7
--- /dev/null
+++ b/artifacts/ehwrj-win-x64/LICENSE
@@ -0,0 +1,22 @@
+MIT License
+
+Copyright (c) 2026 Ehwrj contributors
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
diff --git a/artifacts/ehwrj-win-x64/README.md b/artifacts/ehwrj-win-x64/README.md
new file mode 100644
index 0000000..e75a110
--- /dev/null
+++ b/artifacts/ehwrj-win-x64/README.md
@@ -0,0 +1,206 @@
+# Ehwrj
+
+Ehwrj is a clean-room War Thunder live map companion for Windows.
+
+It reads War Thunder's local map service at `http://127.0.0.1:8111`, renders a desktop map view, and can show a click-through always-on-top overlay. The code is newly written and intentionally excludes the malicious behavior found in the analyzed sample.
+
+## Analyzed Malware File
+
+This repository does not contain the original malicious sample. The notes below summarize the static analysis used to separate the benign WT Live Map behavior from the malicious loader/clipper behavior.
+
+Analysis date: 2026-06-02
+Analysis method: static analysis only; the sample was not executed.
+
+| File | SHA-256 | Assessment |
+| --- | --- | --- |
+| `WTLiveMap.exe` | `4dbbc21d9c2ed70dde046a2f737ee4173da807d43d5b3a6acfe447864ed6d643` | Native x64 Win32/C++ executable containing RCDATA 101 and 102. |
+| `WTLiveMap.zip` | `429e07a27e7896f75a901a1df3cd6560b43de33986920f1f22013ef155541b4c` | ZIP package containing the same suspicious executable. |
+| `resource_101.bin` | `00732b0bbc1740ebe88745424c88ab840b381475837e5e02e937a29e66df3909` | Benign WT Live Map UI/WebView-style resource. |
+| `resource_102.bin` | `03547c81562a065bcb08defa638866c8c1c79343d78b8df7a8a7e0cb827242d7` | Resource-less loader/clipper variant with the same malicious capability class as the outer EXE. |
+
+Key findings:
+
+- RCDATA 101 matches the non-virus WT Live Map behavior: it references `Warthunder LiveMap`, `map_info.json`, `map_obj.json`, `map.img`, `aces.exe`, WebView messaging, and loopback access to `127.0.0.1:8111`.
+- RCDATA 102 is not byte-identical to the outer `WTLiveMap.exe`, but it shares the suspicious import profile, hidden string structure, Windows Update disguise strings, clipboard API strings, and cryptocurrency address replacement logic.
+- The malicious path monitors clipboard text and replaces BTC, ETH, SOL, TRX, XRP, DOGE, LTC, and BCH wallet addresses with attacker-controlled addresses.
+- Persistence is disguised as Windows Update. The analyzed code builds paths similar to `%TEMP%\WindowsUpdateModule\SystemUpdateService.exe` and creates a startup shortcut named `WindowsSystemUpdate.lnk`.
+- `SHGetSpecialFolderPathW(CSIDL_STARTUP)`, Shell Link COM use, `Global\WinSysUpdateMutexV11`, and resource update APIs were observed in the malicious loader path.
+- A ZIP repackaging path uses hidden PowerShell command construction, extracts a ZIP, locates an inner EXE, reinvokes itself with `--merge-env`, injects RCDATA 101/102, and recreates the ZIP.
+- No external C2 server, IP, domain, or upload path was confirmed in static analysis. The confirmed monetization path is clipboard-based cryptocurrency address replacement.
+
+## What It Implements
+
+- Local War Thunder API polling:
+ - `/map_info.json`
+ - `/map_obj.json`
+ - `/map.img`
+- Optional local telemetry/message polling when available:
+ - `/state`
+ - `/hudmsg`
+ - `/gamechat`
+- Main map preview with aircraft/object markers
+- Transparent click-through overlay
+- Configurable overlay size, position, zoom, minimap, Mach labels, and spot radar
+- Overlay controls for aircraft scale, minimum Mach filters, radar spread, vertical scale/offset, arrow size, opacity, label colors, font sizes, distance, Mach, and closure speed labels
+- English/Korean UI language selection
+- Settings persisted under `%LOCALAPPDATA%\Ehwrj\settings.json`
+- Windows build from Linux using .NET 8 and Avalonia targeting `win-x64`
+
+## Explicitly Not Implemented
+
+The analyzed binary contained malicious behavior. Ehwrj does not implement:
+
+- Clipboard monitoring or cryptocurrency address replacement
+- Windows Update disguise or startup persistence
+- ZIP/EXE infection or resource injection
+- Hidden mutex-based malware lifecycle control
+- Any external C2, exfiltration, or remote upload path
+
+## Build
+
+From Ubuntu:
+
+```bash
+cd ehwrj
+scripts/bootstrap-ubuntu.sh
+```
+
+The bootstrap script installs missing Ubuntu packages for .NET 8 and the safety scanner, then runs restore, build, tests, the safety scan, tool smoke checks, and Windows x64 publish.
+
+If the dependencies are already installed:
+
+```bash
+dotnet restore ehwrj/Ehwrj.sln
+dotnet build ehwrj/Ehwrj.sln -c Release
+dotnet run --project ehwrj/tests/Ehwrj.Tests/Ehwrj.Tests.csproj -c Release
+dotnet publish ehwrj/src/Ehwrj.App/Ehwrj.App.csproj -c Release -r win-x64 --self-contained true \
+ -p:PublishSingleFile=true
+```
+
+Or from inside the `ehwrj` folder:
+
+```bash
+scripts/publish-win-x64.sh
+```
+
+The output EXE will be under:
+
+```text
+ehwrj/src/Ehwrj.App/bin/Release/net8.0/win-x64/publish/
+```
+
+The portable release ZIP is written to:
+
+```text
+ehwrj/artifacts/ehwrj-win-x64.zip
+```
+
+## Runtime
+
+Run War Thunder first, then start Ehwrj on Windows. The app expects the game to expose the local map API on `127.0.0.1:8111`.
+
+The current coordinate and speed estimates are intentionally conservative because War Thunder's local API shape can vary by mode and vehicle. Use real `map_info.json` and `map_obj.json` captures to tune projection and spot radar math for a specific game mode.
+
+## Local API Stub
+
+For UI development without War Thunder, run the deterministic local API stub:
+
+```bash
+cd ehwrj
+scripts/run-local-api-stub.sh
+```
+
+It serves:
+
+- `http://127.0.0.1:8111/map_info.json`
+- `http://127.0.0.1:8111/map_obj.json`
+- `http://127.0.0.1:8111/map.img`
+- `http://127.0.0.1:8111/state`
+- `http://127.0.0.1:8111/hudmsg`
+- `http://127.0.0.1:8111/gamechat`
+
+Use another port for endpoint testing:
+
+```bash
+scripts/run-local-api-stub.sh 18111
+```
+
+## Capturing Real Local API Data
+
+When War Thunder is running on Windows, capture the local API into a fixture directory:
+
+```bash
+cd ehwrj
+scripts/capture-local-api.sh captures/my-session
+```
+
+The capture tool saves:
+
+- `map_info.json`
+- `map_obj.json`
+- `map.img`
+- `state.json` when `/state` is available
+- `hudmsg.json` when `/hudmsg` is available
+- `gamechat.json` when `/gamechat` is available
+- `capture-report.txt` with parser coverage, raw object field frequency, unknown object samples, replay readiness, and tuning warnings
+
+Validate an existing capture:
+
+```bash
+scripts/validate-capture.sh captures/my-session
+```
+
+Use the report to tune real game-mode support. The most useful sections are `Object field coverage`, `Unknown object samples`, and `State field names`; they show which raw War Thunder fields are present and where object classification or telemetry parsing needs adjustment.
+
+Replay a capture through the local API stub:
+
+```bash
+scripts/run-local-api-stub.sh 8111 captures/my-session
+```
+
+## New Code Structure
+
+```text
+Directory.Build.props
+ shared compiler, analyzer, and Windows-targeting settings
+docs/
+ architecture and feature parity notes
+scripts/
+ Ubuntu bootstrap, safety scan, capture, publish, and packaging helpers
+src/Ehwrj.App/
+ Avalonia desktop UI and overlay host
+src/Ehwrj.App/Models/
+ UI settings, localization text, endpoint health, and render snapshots
+src/Ehwrj.App/Services/
+ polling adapter and settings store
+src/Ehwrj.App/Rendering/
+ main map and overlay drawing surfaces
+src/Ehwrj.App/ViewModels/
+ app state and UI commands
+src/Ehwrj.App/Infrastructure/
+ minimal Win32 interop for click-through overlay styles
+src/Ehwrj.Core/
+ loopback API parsing, map models, telemetry models, tracking, and projection logic
+src/Ehwrj.Core/Models/
+ War Thunder map object, map info, flight state, battle message, and motion tracker types
+src/Ehwrj.Core/Services/
+ local WT API client, process probe, loopback guard, and capture fixture analyzer
+src/Ehwrj.Core/Geometry/
+ viewport, projected point, and coordinate projection math
+tools/Ehwrj.Tools.LocalApiStub/
+ deterministic local map API server and capture replay helper
+tools/Ehwrj.Tools.Capture/
+ local API fixture capture and validation tool
+tests/Ehwrj.Tests/
+ lightweight parser, projection, localization, settings, and safety checks
+artifacts/ehwrj-win-x64/
+ committed portable Windows x64 build output, including Ehwrj.exe and checksums
+```
+
+## CI
+
+The repository includes a GitHub Actions workflow that restores, checks formatting, builds with warnings as errors, runs the lightweight tests, publishes a Windows x64 artifact, and uploads it as `ehwrj-win-x64`.
+
+## Feature Parity
+
+See [docs/feature-matrix.md](docs/feature-matrix.md) for the benign WT Live Map feature parity status and remaining data gaps.
diff --git a/artifacts/ehwrj-win-x64/RUNNING.txt b/artifacts/ehwrj-win-x64/RUNNING.txt
new file mode 100644
index 0000000..5a3a3f3
--- /dev/null
+++ b/artifacts/ehwrj-win-x64/RUNNING.txt
@@ -0,0 +1,13 @@
+Ehwrj portable Windows x64 build
+
+1. Start War Thunder.
+2. Confirm the local map is available at http://127.0.0.1:8111/map_info.json.
+3. Run Ehwrj.exe.
+4. Use "Show overlay" in the left panel to enable the click-through overlay.
+
+Network scope:
+- Ehwrj only reads the loopback War Thunder local API.
+- It does not contact external hosts.
+
+Settings:
+- Saved under %LOCALAPPDATA%\Ehwrj\settings.json.
diff --git a/artifacts/ehwrj-win-x64/SECURITY.md b/artifacts/ehwrj-win-x64/SECURITY.md
new file mode 100644
index 0000000..3af5748
--- /dev/null
+++ b/artifacts/ehwrj-win-x64/SECURITY.md
@@ -0,0 +1,23 @@
+# Security Scope
+
+Ehwrj is a clean-room replacement for the benign War Thunder live map behavior observed in the analyzed sample.
+
+Allowed behavior:
+
+- Connect to `127.0.0.1:8111` only
+- Read local War Thunder map endpoints
+- Store user settings in `%LOCALAPPDATA%\Ehwrj`
+- Create an optional visible overlay window controlled by the user
+
+Disallowed behavior:
+
+- Clipboard listeners
+- Cryptocurrency wallet matching or replacement
+- Startup persistence
+- Windows Update impersonation
+- ZIP, PE, or resource modification
+- Hidden external network communication
+- Credential, cookie, wallet, browser, or messenger file collection
+
+Issues or pull requests that add disallowed behavior should be rejected.
+
diff --git a/artifacts/ehwrj-win-x64/SHA256SUMS.txt b/artifacts/ehwrj-win-x64/SHA256SUMS.txt
new file mode 100644
index 0000000..113483e
--- /dev/null
+++ b/artifacts/ehwrj-win-x64/SHA256SUMS.txt
@@ -0,0 +1,8 @@
+684aaf2276f934d7aae842da81adfe46b954764e9828d5bbe9242b00cd1f5168 Ehwrj.exe
+4c1705d38ec895d4f3830165f1b061ec389da913f17d471ec97fcbe3e6cec012 LICENSE
+d71dd3bed70b3c90aa04bed6c8f47caf47888566b79e48a50e7743a6ae35f031 README.md
+866de6ec207750697e6a321ed1b8ba52ba04bdf9080c80ff929c885c9107ad27 RUNNING.txt
+266a2a8f242f274530085cca86ebeb8c11706ca73ce03684ee3b6ba61ef5e274 SECURITY.md
+9b203e40323b49dad29546a52b8b67d200bba8ff4cab9709a79cede23ba847d4 av_libglesv2.dll
+eb76238c9e8e41d44b5a5b18167c4c5b39ca5db4277af5dbe92d730f0fc14a7d libHarfBuzzSharp.dll
+9a0d95e8caaa852c70d085af6a40a744242172ad9ea3fd6bc7599875a8a1dbcd libSkiaSharp.dll
diff --git a/artifacts/ehwrj-win-x64/av_libglesv2.dll b/artifacts/ehwrj-win-x64/av_libglesv2.dll
new file mode 100755
index 0000000..487d711
Binary files /dev/null and b/artifacts/ehwrj-win-x64/av_libglesv2.dll differ
diff --git a/artifacts/ehwrj-win-x64/libHarfBuzzSharp.dll b/artifacts/ehwrj-win-x64/libHarfBuzzSharp.dll
new file mode 100755
index 0000000..2bb6849
Binary files /dev/null and b/artifacts/ehwrj-win-x64/libHarfBuzzSharp.dll differ
diff --git a/artifacts/ehwrj-win-x64/libSkiaSharp.dll b/artifacts/ehwrj-win-x64/libSkiaSharp.dll
new file mode 100755
index 0000000..3f8c6f2
Binary files /dev/null and b/artifacts/ehwrj-win-x64/libSkiaSharp.dll differ
diff --git a/docs/architecture.md b/docs/architecture.md
new file mode 100644
index 0000000..c758169
--- /dev/null
+++ b/docs/architecture.md
@@ -0,0 +1,84 @@
+# Architecture
+
+Ehwrj is split into small modules so that local API access, state derivation, rendering, and platform integration stay separate.
+
+## Data Flow
+
+```text
+War Thunder local API
+ -> Ehwrj.Core.Services.WarThunderClient
+ -> Ehwrj.App.Services.LiveMapService
+ -> Ehwrj.App.Models.LiveSnapshot
+ -> MainWindow / OverlayWindow
+ -> MapCanvas / OverlayCanvas
+```
+
+## Modules
+
+### Ehwrj.Core
+
+- `WarThunderClient` is the only module allowed to perform network requests.
+- Its base address is fixed to `http://127.0.0.1:8111/`.
+- `ProcessProbe` checks whether `aces.exe` is present.
+- `MapInfo` parses `/map_info.json`.
+- `MapObject` parses `/map_obj.json`.
+- `FlightState` parses optional `/state` telemetry such as `TAS, km/h`, `IAS, km/h`, `Vy, m/s`, and `H, m`.
+- `BattleMessage` normalizes optional `/hudmsg` and `/gamechat` messages.
+- `MapObjectKind` classifies player, ally, squad, enemy, objective, and unknown objects.
+- `ObjectTracker` estimates motion from consecutive object positions.
+- `CoordinateProjector` converts map coordinates into viewport coordinates.
+
+`Ehwrj.Core` targets plain `net8.0` and has no UI dependency.
+
+Parser tolerance is intentionally concentrated in `Ehwrj.Core`:
+
+- numeric JSON strings are parsed with invariant culture and comma decimal fallback
+- map bounds can come from scalar min/max fields, `map_min`/`map_max` arrays, `bounds`, or `grid_size`
+- object position can come from scalar `x`/`y` style fields or `pos`/`position` style arrays
+- object direction can come from scalar `dx`/`dy` style fields or `dir`/`direction`/`velocity` style arrays
+
+### Ehwrj.App
+
+- `LiveMapService` owns the polling loop and converts endpoint responses into immutable UI snapshots.
+- `SettingsStore` persists user settings under `%LOCALAPPDATA%\Ehwrj`.
+- `OverlaySettings` holds user-adjustable overlay controls.
+- `UiText` and `LanguageOption` provide the clean-room English/Korean UI language layer.
+- `LiveSnapshot` is the single render input for both the main map and overlay.
+- `MainViewModel` coordinates commands and live state for the Avalonia UI.
+
+### Rendering
+
+- `MapCanvas` draws the main map preview, including player-relative rotation of the map plane and projected tactical objects.
+- `OverlayCanvas` draws the transparent overlay surface.
+- Rendering code receives a `LiveSnapshot` and `OverlaySettings`; it does not perform I/O.
+- Overlay setting changes invalidate the drawing surface directly, so UI sliders and text settings are reflected without restarting the polling loop.
+
+### Overlay Controls
+
+The overlay settings mirror the benign controls identified in the analyzed live map resource:
+
+- minimap visibility, aircraft scale, and Mach threshold
+- spot radar visibility, detection range, spread, vertical scale, and vertical offset
+- marker opacity, arrow scale, arrow outline, and colors
+- distance, Mach, and closure speed label toggles
+- per-label font size, outline width, color, and opacity
+
+### Platform Integration
+
+- `Win32.MakeOverlayClickThrough` is the only Windows interop hook.
+- It applies click-through and no-activate extended styles to the optional overlay window.
+- It is guarded with `OperatingSystem.IsWindows()`.
+
+### Developer Tooling
+
+- `Ehwrj.Tools.LocalApiStub` serves deterministic `/map_info.json`, `/map_obj.json`, and `/map.img` responses on loopback.
+- It also serves optional `/state`, `/hudmsg`, and `/gamechat` responses for player info and battle log development.
+- The stub lets contributors exercise the UI without running War Thunder.
+- The generated map image is produced in memory and the moving aircraft data is deterministic.
+- `Ehwrj.Tools.Capture` captures real local API responses into fixture directories.
+- It writes `capture-report.txt` so captured fixtures can be checked for parser coverage, raw object field frequency, unknown object samples, replay readiness, and tuning gaps.
+- `LocalApiStub --fixture-dir` replays captured fixtures before falling back to generated responses.
+
+## Safety Rules
+
+The project intentionally has no module for clipboard monitoring, startup persistence, ZIP mutation, PE resource updates, or external network communication. New features should preserve these boundaries.
diff --git a/docs/feature-matrix.md b/docs/feature-matrix.md
new file mode 100644
index 0000000..33c6a08
--- /dev/null
+++ b/docs/feature-matrix.md
@@ -0,0 +1,31 @@
+# Feature Matrix
+
+This matrix tracks the benign functionality identified from the analyzed WT Live Map resource and the corresponding clean-room Ehwrj implementation.
+
+| Feature | Ehwrj status | Notes |
+| --- | --- | --- |
+| Local War Thunder map polling | Implemented | Reads `map_info.json`, `map_obj.json`, and `map.img` from loopback. |
+| Map image preview | Implemented | Drawn by `MapCanvas`. |
+| Aircraft/object markers | Implemented | Uses `MapObjectKind` classification for player, ally, squad, enemy, and objective colors. |
+| Ally/enemy aircraft counters | Implemented | Derived from parsed object kind and aircraft flags. |
+| Player info panel | Implemented | Shows detected player name, map position, Mach, and heading when available. |
+| Flight telemetry | Implemented | Optional `/state` support for TAS/IAS, altitude, vertical speed, Mach, and climb angle. |
+| Battle timer | Implemented | Session-local timer starts on first live snapshot. |
+| Battle log panel | Implemented | Reads optional `/hudmsg` and `/gamechat` when available, and also logs local snapshot events. |
+| Labels | Implemented | Toggle-controlled map labels. |
+| Aircraft Mach labels | Implemented | Uses tracked motion estimates or state-derived values when present in parsed data. |
+| Follow player | Implemented | Main map zoom/pan centers the detected player. |
+| Rotate with player | Implemented | The map bitmap, grid, markers, range rings, and delivery tracker rotate around the view center using detected player heading. |
+| Range rings | Implemented | Drawn around detected player. |
+| Delivery tracker | Implemented | Draws projected approach lines from friendly/player aircraft to enemy objective targets when heading alignment passes the configured threshold. |
+| Transparent overlay | Implemented | Avalonia transparent window with Windows click-through interop. |
+| Overlay minimap | Implemented | Includes aircraft scale and minimum Mach filtering. |
+| Overlay spot radar | Implemented | Includes range, spread, vertical scale/offset, opacity, arrow size/outline/color, and distance/Mach/closure labels. |
+| Settings persistence | Implemented | Stored in `%LOCALAPPDATA%\Ehwrj\settings.json`. |
+| Local API capture/replay | Implemented | `Ehwrj.Tools.Capture` records fixtures and writes a validation report; `LocalApiStub --fixture-dir` replays them. |
+| Language selection | Implemented | English and Korean UI text can be selected from the main control panel and persists in settings. |
+| WebView2 host | Intentionally not used | Avalonia was chosen so Ubuntu ARM64 can build and publish Windows x64 artifacts without WindowsDesktop SDK support. |
+
+## Known Data Gaps
+
+Real War Thunder `map_obj.json`, `map_info.json`, `/state`, `/hudmsg`, and `/gamechat` captures are still needed to tune distance scale, object classification edge cases, and mode-specific fields. The local API stub covers development and UI testing but is not a replacement for game-mode validation.
diff --git a/scripts/bootstrap-ubuntu.sh b/scripts/bootstrap-ubuntu.sh
new file mode 100755
index 0000000..e037866
--- /dev/null
+++ b/scripts/bootstrap-ubuntu.sh
@@ -0,0 +1,116 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+APT_UPDATED=0
+
+usage() {
+ cat <<'EOF'
+Usage:
+ scripts/bootstrap-ubuntu.sh [--no-install]
+
+Installs the Ubuntu packages needed to build Ehwrj, then runs the full
+Release verification and Windows x64 publish pipeline.
+
+Options:
+ --no-install Skip apt installs and only run the verification/publish step.
+EOF
+}
+
+NO_INSTALL=0
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --no-install)
+ NO_INSTALL=1
+ shift
+ ;;
+ -h|--help)
+ usage
+ exit 0
+ ;;
+ *)
+ echo "error: unknown argument: $1" >&2
+ usage >&2
+ exit 2
+ ;;
+ esac
+done
+
+if [[ -r /etc/os-release ]]; then
+ # shellcheck disable=SC1091
+ . /etc/os-release
+else
+ echo "error: /etc/os-release not found; this script expects Ubuntu." >&2
+ exit 1
+fi
+
+if [[ "${ID:-}" != "ubuntu" ]]; then
+ echo "error: this bootstrap script expects Ubuntu, got '${ID:-unknown}'." >&2
+ exit 1
+fi
+
+run_as_root() {
+ if [[ "$(id -u)" -eq 0 ]]; then
+ "$@"
+ return
+ fi
+
+ if ! command -v sudo >/dev/null 2>&1; then
+ echo "error: sudo is required when not running as root." >&2
+ exit 1
+ fi
+
+ sudo "$@"
+}
+
+apt_install() {
+ if [[ "$NO_INSTALL" -eq 1 ]]; then
+ return
+ fi
+
+ if [[ "$APT_UPDATED" -eq 0 ]]; then
+ run_as_root apt-get update
+ APT_UPDATED=1
+ fi
+
+ run_as_root apt-get install -y "$@"
+}
+
+has_dotnet_8_sdk() {
+ command -v dotnet >/dev/null 2>&1 && dotnet --list-sdks | awk '{print $1}' | grep -q '^8\.'
+}
+
+missing=()
+
+if ! has_dotnet_8_sdk; then
+ missing+=(dotnet-sdk-8.0)
+fi
+
+if ! command -v rg >/dev/null 2>&1; then
+ missing+=(ripgrep)
+fi
+
+if [[ "${#missing[@]}" -gt 0 ]]; then
+ if [[ "$NO_INSTALL" -eq 1 ]]; then
+ echo "error: missing required tools: ${missing[*]}" >&2
+ exit 1
+ fi
+
+ apt_install "${missing[@]}"
+fi
+
+if ! has_dotnet_8_sdk; then
+ echo "error: dotnet 8 SDK is still unavailable after package installation." >&2
+ exit 1
+fi
+
+if ! command -v rg >/dev/null 2>&1; then
+ echo "error: ripgrep is still unavailable after package installation." >&2
+ exit 1
+fi
+
+"$ROOT_DIR/scripts/publish-win-x64.sh"
+
+echo
+echo "Published Windows build:"
+echo " $ROOT_DIR/src/Ehwrj.App/bin/Release/net8.0/win-x64/publish/Ehwrj.exe"
diff --git a/scripts/capture-local-api.sh b/scripts/capture-local-api.sh
new file mode 100755
index 0000000..a5b2c5a
--- /dev/null
+++ b/scripts/capture-local-api.sh
@@ -0,0 +1,12 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+OUT_DIR="${1:-}"
+
+if [[ -n "$OUT_DIR" ]]; then
+ dotnet run --project "$ROOT_DIR/tools/Ehwrj.Tools.Capture/Ehwrj.Tools.Capture.csproj" -c Release -- --out "$OUT_DIR"
+else
+ dotnet run --project "$ROOT_DIR/tools/Ehwrj.Tools.Capture/Ehwrj.Tools.Capture.csproj" -c Release
+fi
+
diff --git a/scripts/package-win-x64.sh b/scripts/package-win-x64.sh
new file mode 100755
index 0000000..c79e807
--- /dev/null
+++ b/scripts/package-win-x64.sh
@@ -0,0 +1,80 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+PUBLISH_DIR="$ROOT_DIR/src/Ehwrj.App/bin/Release/net8.0/win-x64/publish"
+ARTIFACT_DIR="$ROOT_DIR/artifacts"
+PACKAGE_DIR="$ARTIFACT_DIR/ehwrj-win-x64"
+ZIP_PATH="$ARTIFACT_DIR/ehwrj-win-x64.zip"
+
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --publish-dir)
+ PUBLISH_DIR="$2"
+ shift 2
+ ;;
+ --zip)
+ ZIP_PATH="$2"
+ ARTIFACT_DIR="$(dirname "$ZIP_PATH")"
+ PACKAGE_DIR="$ARTIFACT_DIR/ehwrj-win-x64"
+ shift 2
+ ;;
+ -h|--help)
+ cat <<'EOF'
+Usage:
+ scripts/package-win-x64.sh [--publish-dir path] [--zip path]
+
+Creates a portable Windows x64 ZIP containing Ehwrj.exe, native DLLs,
+README, SECURITY, LICENSE, RUNNING.txt, and SHA256SUMS.txt.
+EOF
+ exit 0
+ ;;
+ *)
+ echo "error: unknown argument: $1" >&2
+ exit 2
+ ;;
+ esac
+done
+
+if [[ ! -x "$PUBLISH_DIR/Ehwrj.exe" ]]; then
+ echo "error: missing published Ehwrj.exe; run scripts/publish-win-x64.sh first." >&2
+ exit 1
+fi
+
+rm -rf "$PACKAGE_DIR"
+mkdir -p "$PACKAGE_DIR"
+
+cp "$PUBLISH_DIR/Ehwrj.exe" "$PACKAGE_DIR/"
+cp "$PUBLISH_DIR"/*.dll "$PACKAGE_DIR/"
+cp "$ROOT_DIR/README.md" "$PACKAGE_DIR/README.md"
+cp "$ROOT_DIR/SECURITY.md" "$PACKAGE_DIR/SECURITY.md"
+cp "$ROOT_DIR/LICENSE" "$PACKAGE_DIR/LICENSE"
+
+cat > "$PACKAGE_DIR/RUNNING.txt" <<'EOF'
+Ehwrj portable Windows x64 build
+
+1. Start War Thunder.
+2. Confirm the local map is available at http://127.0.0.1:8111/map_info.json.
+3. Run Ehwrj.exe.
+4. Use "Show overlay" in the left panel to enable the click-through overlay.
+
+Network scope:
+- Ehwrj only reads the loopback War Thunder local API.
+- It does not contact external hosts.
+
+Settings:
+- Saved under %LOCALAPPDATA%\Ehwrj\settings.json.
+EOF
+
+(
+ cd "$PACKAGE_DIR"
+ sha256sum * > SHA256SUMS.txt
+)
+
+rm -f "$ZIP_PATH"
+(
+ cd "$ARTIFACT_DIR"
+ zip -qr "$(basename "$ZIP_PATH")" "$(basename "$PACKAGE_DIR")"
+)
+
+echo "$ZIP_PATH"
diff --git a/scripts/publish-win-x64.sh b/scripts/publish-win-x64.sh
new file mode 100755
index 0000000..e9ab49d
--- /dev/null
+++ b/scripts/publish-win-x64.sh
@@ -0,0 +1,18 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+
+dotnet restore "$ROOT_DIR/Ehwrj.sln"
+dotnet format "$ROOT_DIR/Ehwrj.sln" --no-restore --verify-no-changes --verbosity minimal
+dotnet build "$ROOT_DIR/Ehwrj.sln" -c Release --no-restore
+dotnet run --project "$ROOT_DIR/tests/Ehwrj.Tests/Ehwrj.Tests.csproj" -c Release --no-build
+"$ROOT_DIR/scripts/verify-safety.sh"
+dotnet run --project "$ROOT_DIR/tools/Ehwrj.Tools.LocalApiStub/Ehwrj.Tools.LocalApiStub.csproj" -c Release --no-build -- --help >/dev/null
+dotnet run --project "$ROOT_DIR/tools/Ehwrj.Tools.Capture/Ehwrj.Tools.Capture.csproj" -c Release --no-build -- --help >/dev/null
+dotnet publish "$ROOT_DIR/src/Ehwrj.App/Ehwrj.App.csproj" \
+ -c Release \
+ -r win-x64 \
+ --self-contained true \
+ -p:PublishSingleFile=true
+"$ROOT_DIR/scripts/package-win-x64.sh" >/dev/null
diff --git a/scripts/run-local-api-stub.sh b/scripts/run-local-api-stub.sh
new file mode 100755
index 0000000..5b5cf74
--- /dev/null
+++ b/scripts/run-local-api-stub.sh
@@ -0,0 +1,12 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+PORT="${1:-8111}"
+FIXTURE_DIR="${2:-}"
+
+if [[ -n "$FIXTURE_DIR" ]]; then
+ dotnet run --project "$ROOT_DIR/tools/Ehwrj.Tools.LocalApiStub/Ehwrj.Tools.LocalApiStub.csproj" -c Release -- --port "$PORT" --fixture-dir "$FIXTURE_DIR"
+else
+ dotnet run --project "$ROOT_DIR/tools/Ehwrj.Tools.LocalApiStub/Ehwrj.Tools.LocalApiStub.csproj" -c Release -- --port "$PORT"
+fi
diff --git a/scripts/validate-capture.sh b/scripts/validate-capture.sh
new file mode 100755
index 0000000..a07ca63
--- /dev/null
+++ b/scripts/validate-capture.sh
@@ -0,0 +1,7 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+CAPTURE_DIR="${1:-captures/smoke}"
+
+dotnet run --project "$ROOT_DIR/tools/Ehwrj.Tools.Capture/Ehwrj.Tools.Capture.csproj" -c Release -- --validate "$CAPTURE_DIR"
diff --git a/scripts/verify-safety.sh b/scripts/verify-safety.sh
new file mode 100755
index 0000000..04c7f29
--- /dev/null
+++ b/scripts/verify-safety.sh
@@ -0,0 +1,33 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+SEARCH_ROOTS=(
+ "$ROOT_DIR/src"
+ "$ROOT_DIR/tests"
+ "$ROOT_DIR/tools"
+ "$ROOT_DIR/scripts"
+)
+URL_SEARCH_ROOTS=(
+ "$ROOT_DIR/src"
+ "$ROOT_DIR/tools"
+ "$ROOT_DIR/scripts"
+)
+RG_COMMON=(--glob '!verify-safety.sh' --glob '!**/bin/**' --glob '!**/obj/**')
+
+DISALLOWED_PATTERN='SetClipboard|OpenClipboard|GetClipboardData|AddClipboardFormatListener|UpdateResource|BeginUpdateResource|EndUpdateResource|SHGetSpecialFolderPath|CreateMutex|WindowsUpdate|zip_work|TARGET_PATH|--merge-env|CryptUnprotectData|Login Data|wallet\.dat'
+
+if rg "${RG_COMMON[@]}" -n "$DISALLOWED_PATTERN" "${SEARCH_ROOTS[@]}"; then
+ echo "error: disallowed malware-adjacent capability found" >&2
+ exit 1
+fi
+
+URL_PATTERN='https?://[^"[:space:]]+'
+if rg "${RG_COMMON[@]}" --glob '!*.axaml' -n "$URL_PATTERN" "${URL_SEARCH_ROOTS[@]}" \
+ | rg -v 'http://(127\.0\.0\.1|localhost)(:[0-9]+)?(/[^"[:space:]]*)?' \
+ | rg -v 'http://\{'; then
+ echo "error: non-loopback URL literal found" >&2
+ exit 1
+fi
+
+echo "Safety scan passed."
diff --git a/src/Ehwrj.App/App.axaml b/src/Ehwrj.App/App.axaml
new file mode 100644
index 0000000..5af5e04
--- /dev/null
+++ b/src/Ehwrj.App/App.axaml
@@ -0,0 +1,34 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Ehwrj.App/App.axaml.cs b/src/Ehwrj.App/App.axaml.cs
new file mode 100644
index 0000000..2e9ca54
--- /dev/null
+++ b/src/Ehwrj.App/App.axaml.cs
@@ -0,0 +1,24 @@
+using Avalonia;
+using Avalonia.Controls.ApplicationLifetimes;
+using Avalonia.Markup.Xaml;
+
+namespace Ehwrj.App;
+
+public partial class App : Application
+{
+ public override void Initialize()
+ {
+ AvaloniaXamlLoader.Load(this);
+ }
+
+ public override void OnFrameworkInitializationCompleted()
+ {
+ if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
+ {
+ desktop.MainWindow = new MainWindow();
+ }
+
+ base.OnFrameworkInitializationCompleted();
+ }
+}
+
diff --git a/src/Ehwrj.App/Ehwrj.App.csproj b/src/Ehwrj.App/Ehwrj.App.csproj
new file mode 100644
index 0000000..107e704
--- /dev/null
+++ b/src/Ehwrj.App/Ehwrj.App.csproj
@@ -0,0 +1,20 @@
+
+
+ WinExe
+ net8.0
+ Ehwrj.App
+ Ehwrj
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Ehwrj.App/Infrastructure/Win32.cs b/src/Ehwrj.App/Infrastructure/Win32.cs
new file mode 100644
index 0000000..d714a91
--- /dev/null
+++ b/src/Ehwrj.App/Infrastructure/Win32.cs
@@ -0,0 +1,30 @@
+using System.Runtime.InteropServices;
+
+namespace Ehwrj.App.Infrastructure;
+
+internal static class Win32
+{
+ private const int GwlExStyle = -20;
+ private const int WsExTransparent = 0x00000020;
+ private const int WsExToolWindow = 0x00000080;
+ private const int WsExLayered = 0x00080000;
+ private const int WsExNoActivate = 0x08000000;
+
+ public static void MakeOverlayClickThrough(IntPtr hwnd)
+ {
+ if (!OperatingSystem.IsWindows())
+ {
+ return;
+ }
+
+ var style = GetWindowLongPtr(hwnd, GwlExStyle);
+ style |= WsExTransparent | WsExToolWindow | WsExLayered | WsExNoActivate;
+ _ = SetWindowLongPtr(hwnd, GwlExStyle, style);
+ }
+
+ [DllImport("user32.dll", EntryPoint = "GetWindowLongPtrW", SetLastError = true)]
+ private static extern IntPtr GetWindowLongPtr(IntPtr hWnd, int nIndex);
+
+ [DllImport("user32.dll", EntryPoint = "SetWindowLongPtrW", SetLastError = true)]
+ private static extern IntPtr SetWindowLongPtr(IntPtr hWnd, int nIndex, IntPtr dwNewLong);
+}
diff --git a/src/Ehwrj.App/MainWindow.axaml b/src/Ehwrj.App/MainWindow.axaml
new file mode 100644
index 0000000..e99e0b0
--- /dev/null
+++ b/src/Ehwrj.App/MainWindow.axaml
@@ -0,0 +1,227 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Ehwrj.App/MainWindow.axaml.cs b/src/Ehwrj.App/MainWindow.axaml.cs
new file mode 100644
index 0000000..005163f
--- /dev/null
+++ b/src/Ehwrj.App/MainWindow.axaml.cs
@@ -0,0 +1,74 @@
+using System.ComponentModel;
+using Avalonia.Controls;
+using Avalonia.Interactivity;
+using Ehwrj.App.Services;
+using Ehwrj.App.ViewModels;
+using Ehwrj.Core.Services;
+
+namespace Ehwrj.App;
+
+public partial class MainWindow : Window
+{
+ private readonly MainViewModel _viewModel;
+ private OverlayWindow? _overlayWindow;
+
+ public MainWindow()
+ {
+ InitializeComponent();
+
+ var settingsStore = new SettingsStore();
+ var appSettings = settingsStore.Load();
+ var client = new WarThunderClient();
+ var processProbe = new ProcessProbe();
+ var service = new LiveMapService(client, processProbe);
+
+ _viewModel = new MainViewModel(service, settingsStore, appSettings);
+ _viewModel.PropertyChanged += OnViewModelPropertyChanged;
+ _viewModel.Settings.Overlay.PropertyChanged += OnOverlaySettingsChanged;
+ DataContext = _viewModel;
+
+ Opened += OnOpened;
+ Closing += OnClosing;
+ }
+
+ private void OnOpened(object? sender, EventArgs e)
+ {
+ _viewModel.StartCommand.Execute(null);
+ }
+
+ private void OnViewModelPropertyChanged(object? sender, PropertyChangedEventArgs e)
+ {
+ if (e.PropertyName is nameof(MainViewModel.IsOverlayEnabled))
+ {
+ UpdateOverlayVisibility();
+ }
+ }
+
+ private void OnOverlaySettingsChanged(object? sender, PropertyChangedEventArgs e)
+ {
+ _overlayWindow?.ApplyBounds();
+ }
+
+ private void UpdateOverlayVisibility()
+ {
+ if (_viewModel.IsOverlayEnabled)
+ {
+ _overlayWindow ??= new OverlayWindow(_viewModel.Settings.Overlay)
+ {
+ DataContext = _viewModel
+ };
+ _overlayWindow.Show();
+ _overlayWindow.ApplyBounds();
+ }
+ else
+ {
+ _overlayWindow?.Hide();
+ }
+ }
+
+ private void OnClosing(object? sender, WindowClosingEventArgs e)
+ {
+ _overlayWindow?.Close();
+ _viewModel.Dispose();
+ }
+}
diff --git a/src/Ehwrj.App/Models/AppSettings.cs b/src/Ehwrj.App/Models/AppSettings.cs
new file mode 100644
index 0000000..f6c5201
--- /dev/null
+++ b/src/Ehwrj.App/Models/AppSettings.cs
@@ -0,0 +1,9 @@
+namespace Ehwrj.App.Models;
+
+public sealed class AppSettings
+{
+ public MapSettings Map { get; set; } = new();
+ public OverlaySettings Overlay { get; set; } = new();
+ public string Language { get; set; } = "en-US";
+ public int PollIntervalMs { get; set; } = 500;
+}
diff --git a/src/Ehwrj.App/Models/EndpointHealth.cs b/src/Ehwrj.App/Models/EndpointHealth.cs
new file mode 100644
index 0000000..513c530
--- /dev/null
+++ b/src/Ehwrj.App/Models/EndpointHealth.cs
@@ -0,0 +1,15 @@
+namespace Ehwrj.App.Models;
+
+public enum EndpointHealthState
+{
+ Ok,
+ Warning,
+ Error,
+ NotChecked
+}
+
+public sealed record EndpointHealth(
+ string Path,
+ bool Required,
+ EndpointHealthState State,
+ string Detail);
diff --git a/src/Ehwrj.App/Models/LanguageOption.cs b/src/Ehwrj.App/Models/LanguageOption.cs
new file mode 100644
index 0000000..71cebc5
--- /dev/null
+++ b/src/Ehwrj.App/Models/LanguageOption.cs
@@ -0,0 +1,3 @@
+namespace Ehwrj.App.Models;
+
+public sealed record LanguageOption(string Code, string DisplayName);
diff --git a/src/Ehwrj.App/Models/LiveSnapshot.cs b/src/Ehwrj.App/Models/LiveSnapshot.cs
new file mode 100644
index 0000000..f143788
--- /dev/null
+++ b/src/Ehwrj.App/Models/LiveSnapshot.cs
@@ -0,0 +1,61 @@
+using Avalonia.Media.Imaging;
+using Ehwrj.Core.Models;
+
+namespace Ehwrj.App.Models;
+
+public sealed class LiveSnapshot
+{
+ private static readonly IReadOnlyList EmptyEndpointHealth =
+ [
+ new("map_info.json", Required: true, EndpointHealthState.NotChecked, "not checked"),
+ new("map_obj.json", Required: true, EndpointHealthState.NotChecked, "not checked"),
+ new("map.img", Required: true, EndpointHealthState.NotChecked, "not checked"),
+ new("state", Required: false, EndpointHealthState.NotChecked, "not checked"),
+ new("hudmsg", Required: false, EndpointHealthState.NotChecked, "not checked"),
+ new("gamechat", Required: false, EndpointHealthState.NotChecked, "not checked")
+ ];
+
+ public static LiveSnapshot Empty { get; } = new(
+ MapInfo.Empty,
+ Array.Empty(),
+ FlightState.Empty,
+ Array.Empty(),
+ null,
+ DateTimeOffset.MinValue,
+ false,
+ "Waiting for War Thunder",
+ EmptyEndpointHealth);
+
+ public LiveSnapshot(
+ MapInfo mapInfo,
+ IReadOnlyList objects,
+ FlightState flightState,
+ IReadOnlyList messages,
+ Bitmap? mapImage,
+ DateTimeOffset updatedAt,
+ bool isGameRunning,
+ string status,
+ IReadOnlyList? endpointHealth = null)
+ {
+ MapInfo = mapInfo;
+ Objects = objects;
+ FlightState = flightState;
+ Messages = messages;
+ MapImage = mapImage;
+ UpdatedAt = updatedAt;
+ IsGameRunning = isGameRunning;
+ Status = status;
+ EndpointHealth = endpointHealth ?? EmptyEndpointHealth;
+ }
+
+ public MapInfo MapInfo { get; }
+ public IReadOnlyList Objects { get; }
+ public FlightState FlightState { get; }
+ public IReadOnlyList Messages { get; }
+ public Bitmap? MapImage { get; }
+ public DateTimeOffset UpdatedAt { get; }
+ public bool IsGameRunning { get; }
+ public string Status { get; }
+ public IReadOnlyList EndpointHealth { get; }
+ public MapObject? Player => Objects.FirstOrDefault(static o => o.IsPlayer) ?? Objects.FirstOrDefault(static o => o.IsAircraft);
+}
diff --git a/src/Ehwrj.App/Models/MapSettings.cs b/src/Ehwrj.App/Models/MapSettings.cs
new file mode 100644
index 0000000..daddd42
--- /dev/null
+++ b/src/Ehwrj.App/Models/MapSettings.cs
@@ -0,0 +1,76 @@
+using Ehwrj.App.ViewModels;
+
+namespace Ehwrj.App.Models;
+
+public sealed class MapSettings : ObservableObject
+{
+ private bool _showLabels = true;
+ private bool _showAircraftMach = true;
+ private bool _followPlayer = true;
+ private bool _rotateWithPlayer;
+ private bool _showRangeRings = true;
+ private bool _showBattleLog = true;
+ private bool _showDeliveryTracker = true;
+ private double _deliveryTrackerAngle = 0.8;
+
+ public bool ShowLabels
+ {
+ get => _showLabels;
+ set => SetProperty(ref _showLabels, value);
+ }
+
+ public bool ShowAircraftMach
+ {
+ get => _showAircraftMach;
+ set => SetProperty(ref _showAircraftMach, value);
+ }
+
+ public bool FollowPlayer
+ {
+ get => _followPlayer;
+ set => SetProperty(ref _followPlayer, value);
+ }
+
+ public bool RotateWithPlayer
+ {
+ get => _rotateWithPlayer;
+ set => SetProperty(ref _rotateWithPlayer, value);
+ }
+
+ public bool ShowRangeRings
+ {
+ get => _showRangeRings;
+ set => SetProperty(ref _showRangeRings, value);
+ }
+
+ public bool ShowBattleLog
+ {
+ get => _showBattleLog;
+ set => SetProperty(ref _showBattleLog, value);
+ }
+
+ public bool ShowDeliveryTracker
+ {
+ get => _showDeliveryTracker;
+ set => SetProperty(ref _showDeliveryTracker, value);
+ }
+
+ public double DeliveryTrackerAngle
+ {
+ get => _deliveryTrackerAngle;
+ set => SetProperty(ref _deliveryTrackerAngle, Math.Clamp(value, 0.1, 1.0));
+ }
+
+ public void Reset()
+ {
+ ShowLabels = true;
+ ShowAircraftMach = true;
+ FollowPlayer = true;
+ RotateWithPlayer = false;
+ ShowRangeRings = true;
+ ShowBattleLog = true;
+ ShowDeliveryTracker = true;
+ DeliveryTrackerAngle = 0.8;
+ }
+}
+
diff --git a/src/Ehwrj.App/Models/OverlaySettings.cs b/src/Ehwrj.App/Models/OverlaySettings.cs
new file mode 100644
index 0000000..ed3d505
--- /dev/null
+++ b/src/Ehwrj.App/Models/OverlaySettings.cs
@@ -0,0 +1,283 @@
+using Ehwrj.App.ViewModels;
+
+namespace Ehwrj.App.Models;
+
+public sealed class OverlaySettings : ObservableObject
+{
+ private bool _showMiniMap = true;
+ private bool _showMach = true;
+ private bool _showSpotRadar = true;
+ private bool _spotShowDistance = true;
+ private bool _spotShowMach;
+ private bool _spotShowRelativeSpeed = true;
+ private double _size = 560;
+ private double _top = 24;
+ private double _right = 24;
+ private double _zoomPercent = 92;
+ private double _miniMapMinimumMach;
+ private double _miniMapAircraftScale = 125;
+ private double _spotOpacity = 70;
+ private double _spotDistance = 529;
+ private double _spotVerticalScale = 68;
+ private double _spotVerticalOffset = 224;
+ private double _spotArrowScale = 140;
+ private double _spotOutlineWidth = 2;
+ private double _spotDetectDistanceKm = 15;
+ private double _spotMinimumMach = 0.5;
+ private double _spotFontOpacity = 70;
+ private double _distanceFontSize = 31;
+ private double _distanceOutlineWidth = 2;
+ private double _machFontSize = 27;
+ private double _machOutlineWidth = 2;
+ private double _relativeFontSize = 24;
+ private double _relativeOutlineWidth = 2;
+ private string _spotArrowColor = "#ff1e1e";
+ private string _spotTextColor = "#ffffff";
+ private string _distanceTextColor = "#ff1e1e";
+ private string _machTextColor = "#57c7f2";
+ private string _relativeTextColor = "#19f24f";
+
+ public bool ShowMiniMap
+ {
+ get => _showMiniMap;
+ set => SetProperty(ref _showMiniMap, value);
+ }
+
+ public bool ShowMach
+ {
+ get => _showMach;
+ set => SetProperty(ref _showMach, value);
+ }
+
+ public bool ShowSpotRadar
+ {
+ get => _showSpotRadar;
+ set => SetProperty(ref _showSpotRadar, value);
+ }
+
+ public bool SpotShowDistance
+ {
+ get => _spotShowDistance;
+ set => SetProperty(ref _spotShowDistance, value);
+ }
+
+ public bool SpotShowMach
+ {
+ get => _spotShowMach;
+ set => SetProperty(ref _spotShowMach, value);
+ }
+
+ public bool SpotShowRelativeSpeed
+ {
+ get => _spotShowRelativeSpeed;
+ set => SetProperty(ref _spotShowRelativeSpeed, value);
+ }
+
+ public double Size
+ {
+ get => _size;
+ set => SetProperty(ref _size, Math.Clamp(value, 160, 2000));
+ }
+
+ public double Top
+ {
+ get => _top;
+ set => SetProperty(ref _top, Math.Clamp(value, 0, 500));
+ }
+
+ public double Right
+ {
+ get => _right;
+ set => SetProperty(ref _right, Math.Clamp(value, 0, 2000));
+ }
+
+ public double ZoomPercent
+ {
+ get => _zoomPercent;
+ set => SetProperty(ref _zoomPercent, Math.Clamp(value, 25, 400));
+ }
+
+ public double MiniMapMinimumMach
+ {
+ get => _miniMapMinimumMach;
+ set => SetProperty(ref _miniMapMinimumMach, Math.Clamp(value, 0, 2));
+ }
+
+ public double MiniMapAircraftScale
+ {
+ get => _miniMapAircraftScale;
+ set => SetProperty(ref _miniMapAircraftScale, Math.Clamp(value, 50, 200));
+ }
+
+ public double SpotOpacity
+ {
+ get => _spotOpacity;
+ set => SetProperty(ref _spotOpacity, Math.Clamp(value, 0, 100));
+ }
+
+ public double SpotDistance
+ {
+ get => _spotDistance;
+ set => SetProperty(ref _spotDistance, Math.Clamp(value, 40, 600));
+ }
+
+ public double SpotVerticalScale
+ {
+ get => _spotVerticalScale;
+ set => SetProperty(ref _spotVerticalScale, Math.Clamp(value, 40, 140));
+ }
+
+ public double SpotVerticalOffset
+ {
+ get => _spotVerticalOffset;
+ set => SetProperty(ref _spotVerticalOffset, Math.Clamp(value, -500, 500));
+ }
+
+ public double SpotArrowScale
+ {
+ get => _spotArrowScale;
+ set => SetProperty(ref _spotArrowScale, Math.Clamp(value, 50, 200));
+ }
+
+ public double SpotOutlineWidth
+ {
+ get => _spotOutlineWidth;
+ set => SetProperty(ref _spotOutlineWidth, Math.Clamp(value, 0, 10));
+ }
+
+ public double SpotDetectDistanceKm
+ {
+ get => _spotDetectDistanceKm;
+ set => SetProperty(ref _spotDetectDistanceKm, Math.Clamp(value, 0, 30));
+ }
+
+ public double SpotMinimumMach
+ {
+ get => _spotMinimumMach;
+ set => SetProperty(ref _spotMinimumMach, Math.Clamp(value, 0, 2));
+ }
+
+ public double SpotFontOpacity
+ {
+ get => _spotFontOpacity;
+ set => SetProperty(ref _spotFontOpacity, Math.Clamp(value, 0, 100));
+ }
+
+ public double DistanceFontSize
+ {
+ get => _distanceFontSize;
+ set => SetProperty(ref _distanceFontSize, Math.Clamp(value, 10, 60));
+ }
+
+ public double DistanceOutlineWidth
+ {
+ get => _distanceOutlineWidth;
+ set => SetProperty(ref _distanceOutlineWidth, Math.Clamp(value, 0, 8));
+ }
+
+ public double MachFontSize
+ {
+ get => _machFontSize;
+ set => SetProperty(ref _machFontSize, Math.Clamp(value, 10, 60));
+ }
+
+ public double MachOutlineWidth
+ {
+ get => _machOutlineWidth;
+ set => SetProperty(ref _machOutlineWidth, Math.Clamp(value, 0, 8));
+ }
+
+ public double RelativeFontSize
+ {
+ get => _relativeFontSize;
+ set => SetProperty(ref _relativeFontSize, Math.Clamp(value, 10, 60));
+ }
+
+ public double RelativeOutlineWidth
+ {
+ get => _relativeOutlineWidth;
+ set => SetProperty(ref _relativeOutlineWidth, Math.Clamp(value, 0, 8));
+ }
+
+ public string SpotArrowColor
+ {
+ get => _spotArrowColor;
+ set => SetProperty(ref _spotArrowColor, NormalizeColor(value, "#ff1e1e"));
+ }
+
+ public string SpotTextColor
+ {
+ get => _spotTextColor;
+ set => SetProperty(ref _spotTextColor, NormalizeColor(value, "#ffffff"));
+ }
+
+ public string DistanceTextColor
+ {
+ get => _distanceTextColor;
+ set => SetProperty(ref _distanceTextColor, NormalizeColor(value, "#ff1e1e"));
+ }
+
+ public string MachTextColor
+ {
+ get => _machTextColor;
+ set => SetProperty(ref _machTextColor, NormalizeColor(value, "#57c7f2"));
+ }
+
+ public string RelativeTextColor
+ {
+ get => _relativeTextColor;
+ set => SetProperty(ref _relativeTextColor, NormalizeColor(value, "#19f24f"));
+ }
+
+ public void Reset()
+ {
+ ShowMiniMap = true;
+ ShowMach = true;
+ ShowSpotRadar = true;
+ SpotShowDistance = true;
+ SpotShowMach = false;
+ SpotShowRelativeSpeed = true;
+ Size = 560;
+ Top = 24;
+ Right = 24;
+ ZoomPercent = 92;
+ MiniMapMinimumMach = 0;
+ MiniMapAircraftScale = 125;
+ SpotOpacity = 70;
+ SpotDistance = 529;
+ SpotVerticalScale = 68;
+ SpotVerticalOffset = 224;
+ SpotArrowScale = 140;
+ SpotOutlineWidth = 2;
+ SpotDetectDistanceKm = 15;
+ SpotMinimumMach = 0.5;
+ SpotFontOpacity = 70;
+ DistanceFontSize = 31;
+ DistanceOutlineWidth = 2;
+ MachFontSize = 27;
+ MachOutlineWidth = 2;
+ RelativeFontSize = 24;
+ RelativeOutlineWidth = 2;
+ SpotArrowColor = "#ff1e1e";
+ SpotTextColor = "#ffffff";
+ DistanceTextColor = "#ff1e1e";
+ MachTextColor = "#57c7f2";
+ RelativeTextColor = "#19f24f";
+ }
+
+ private static string NormalizeColor(string? value, string fallback)
+ {
+ if (string.IsNullOrWhiteSpace(value))
+ {
+ return fallback;
+ }
+
+ var trimmed = value.Trim();
+ if (!trimmed.StartsWith('#'))
+ {
+ trimmed = $"#{trimmed}";
+ }
+
+ return trimmed.Length is 7 or 9 ? trimmed : fallback;
+ }
+}
diff --git a/src/Ehwrj.App/Models/UiText.cs b/src/Ehwrj.App/Models/UiText.cs
new file mode 100644
index 0000000..76babcc
--- /dev/null
+++ b/src/Ehwrj.App/Models/UiText.cs
@@ -0,0 +1,311 @@
+namespace Ehwrj.App.Models;
+
+public sealed class UiText
+{
+ private static readonly UiText English = new("en-US")
+ {
+ Subtitle = "War Thunder local map companion",
+ Language = "Language",
+ Start = "Start",
+ Stop = "Stop",
+ Connection = "Connection",
+ Diagnostics = "Diagnostics",
+ RequiredEndpoint = "required",
+ OptionalEndpoint = "optional",
+ EndpointOk = "ok",
+ EndpointWarning = "warning",
+ EndpointError = "error",
+ EndpointNotChecked = "not checked",
+ Map = "Map",
+ ShowLabels = "Show labels",
+ ShowAircraftMach = "Show aircraft Mach",
+ FollowPlayer = "Follow player",
+ RotateWithPlayer = "Rotate with player",
+ ShowRangeRings = "Show range rings",
+ ShowBattleLog = "Show battle log",
+ DeliveryTracker = "Delivery tracker",
+ DeliveryTrackerAngle = "Delivery tracker angle",
+ ShowOverlay = "Show overlay",
+ ShowMinimap = "Show minimap",
+ ShowMachLabels = "Show Mach labels",
+ ShowSpotRadar = "Show spot radar",
+ OverlaySize = "Overlay size",
+ TopOffset = "Top offset",
+ RightOffset = "Right offset",
+ Zoom = "Zoom",
+ Minimap = "Minimap",
+ AircraftScale = "Aircraft scale",
+ MinimumMach = "Minimum Mach",
+ SpotRadar = "Spot radar",
+ ShowDistance = "Show distance",
+ ShowMach = "Show Mach",
+ ShowClosureSpeed = "Show closure speed",
+ RadarRangeKm = "Radar range, km",
+ RadarSpread = "Radar spread",
+ MarkerOpacity = "Marker opacity",
+ FontOpacity = "Font opacity",
+ VerticalScale = "Vertical scale",
+ VerticalOffset = "Vertical offset",
+ ArrowScale = "Arrow scale",
+ ArrowOutline = "Arrow outline",
+ DistanceFontSize = "Distance font size",
+ MachFontSize = "Mach font size",
+ ClosureFontSize = "Closure font size",
+ ArrowColor = "Arrow color",
+ DistanceColor = "Distance color",
+ MachColor = "Mach color",
+ ClosureColor = "Closure color",
+ Save = "Save",
+ Reset = "Reset",
+ LiveMap = "Live Map",
+ LocalApiScope = "Reads only the local game API at 127.0.0.1:8111",
+ BattleLog = "Battle Log",
+ WaitingForWarThunder = "Waiting for War Thunder",
+ ConnectedToLocalApi = "Connected to 127.0.0.1:8111",
+ WaitingForLocalMapApi = "Waiting for local map API",
+ WarThunderProcessNotDetected = "War Thunder process not detected",
+ StartWarThunderToEnableLiveData = "Start War Thunder to enable live data",
+ WaitingForMapImage = "Waiting for map.img",
+ AcesDetected = "aces.exe detected",
+ AcesNotDetected = "aces.exe not detected",
+ NeverUpdated = "Never updated",
+ PlayerNotDetected = "Player not detected",
+ Player = "Player",
+ Allied = "allied",
+ Enemy = "enemy",
+ Objects = "objects",
+ Pos = "pos",
+ Climb = "climb",
+ Heading = "hdg",
+ Updated = "Updated",
+ MessageEnemy = "Enemy",
+ MessageAlly = "Ally",
+ MessageEvent = "Event",
+ SessionStarted = "Session started",
+ AircraftVisible = "aircraft visible",
+ AlliedAircraftChanged = "Allied aircraft count changed",
+ EnemyAircraftChanged = "Enemy aircraft count changed"
+ };
+
+ private static readonly UiText Korean = new("ko-KR")
+ {
+ Subtitle = "War Thunder 로컬 맵 도구",
+ Language = "언어",
+ Start = "시작",
+ Stop = "정지",
+ Connection = "연결",
+ Diagnostics = "진단",
+ RequiredEndpoint = "필수",
+ OptionalEndpoint = "선택",
+ EndpointOk = "정상",
+ EndpointWarning = "경고",
+ EndpointError = "오류",
+ EndpointNotChecked = "미확인",
+ Map = "지도",
+ ShowLabels = "라벨 표시",
+ ShowAircraftMach = "항공기 마하 표시",
+ FollowPlayer = "플레이어 따라가기",
+ RotateWithPlayer = "플레이어 방향 회전",
+ ShowRangeRings = "거리 링 표시",
+ ShowBattleLog = "전투 로그 표시",
+ DeliveryTracker = "투하 추적기",
+ DeliveryTrackerAngle = "투하 추적 각도",
+ ShowOverlay = "오버레이 표시",
+ ShowMinimap = "미니맵 표시",
+ ShowMachLabels = "마하 라벨 표시",
+ ShowSpotRadar = "스팟 레이더 표시",
+ OverlaySize = "오버레이 크기",
+ TopOffset = "위쪽 오프셋",
+ RightOffset = "오른쪽 오프셋",
+ Zoom = "확대",
+ Minimap = "미니맵",
+ AircraftScale = "항공기 크기",
+ MinimumMach = "최소 마하",
+ SpotRadar = "스팟 레이더",
+ ShowDistance = "거리 표시",
+ ShowMach = "마하 표시",
+ ShowClosureSpeed = "접근 속도 표시",
+ RadarRangeKm = "레이더 범위, km",
+ RadarSpread = "레이더 간격",
+ MarkerOpacity = "마커 불투명도",
+ FontOpacity = "글자 불투명도",
+ VerticalScale = "세로 배율",
+ VerticalOffset = "세로 오프셋",
+ ArrowScale = "화살표 크기",
+ ArrowOutline = "화살표 외곽선",
+ DistanceFontSize = "거리 글자 크기",
+ MachFontSize = "마하 글자 크기",
+ ClosureFontSize = "접근 속도 글자 크기",
+ ArrowColor = "화살표 색상",
+ DistanceColor = "거리 색상",
+ MachColor = "마하 색상",
+ ClosureColor = "접근 속도 색상",
+ Save = "저장",
+ Reset = "초기화",
+ LiveMap = "라이브 맵",
+ LocalApiScope = "로컬 게임 API 127.0.0.1:8111만 읽음",
+ BattleLog = "전투 로그",
+ WaitingForWarThunder = "War Thunder 대기 중",
+ ConnectedToLocalApi = "127.0.0.1:8111 연결됨",
+ WaitingForLocalMapApi = "로컬 맵 API 대기 중",
+ WarThunderProcessNotDetected = "War Thunder 프로세스 감지 안 됨",
+ StartWarThunderToEnableLiveData = "실시간 데이터를 보려면 War Thunder를 시작하세요",
+ WaitingForMapImage = "map.img 대기 중",
+ AcesDetected = "aces.exe 감지됨",
+ AcesNotDetected = "aces.exe 감지 안 됨",
+ NeverUpdated = "아직 갱신 안 됨",
+ PlayerNotDetected = "플레이어 감지 안 됨",
+ Player = "플레이어",
+ Allied = "아군",
+ Enemy = "적",
+ Objects = "개 오브젝트",
+ Pos = "좌표",
+ Climb = "상승각",
+ Heading = "방위",
+ Updated = "갱신",
+ MessageEnemy = "적",
+ MessageAlly = "아군",
+ MessageEvent = "이벤트",
+ SessionStarted = "세션 시작",
+ AircraftVisible = "항공기 표시 중",
+ AlliedAircraftChanged = "아군 항공기 수 변경",
+ EnemyAircraftChanged = "적 항공기 수 변경"
+ };
+
+ private UiText(string code)
+ {
+ Code = code;
+ }
+
+ public string Code { get; }
+ public string Subtitle { get; init; } = "";
+ public string Language { get; init; } = "";
+ public string Start { get; init; } = "";
+ public string Stop { get; init; } = "";
+ public string Connection { get; init; } = "";
+ public string Diagnostics { get; init; } = "";
+ public string RequiredEndpoint { get; init; } = "";
+ public string OptionalEndpoint { get; init; } = "";
+ public string EndpointOk { get; init; } = "";
+ public string EndpointWarning { get; init; } = "";
+ public string EndpointError { get; init; } = "";
+ public string EndpointNotChecked { get; init; } = "";
+ public string Map { get; init; } = "";
+ public string ShowLabels { get; init; } = "";
+ public string ShowAircraftMach { get; init; } = "";
+ public string FollowPlayer { get; init; } = "";
+ public string RotateWithPlayer { get; init; } = "";
+ public string ShowRangeRings { get; init; } = "";
+ public string ShowBattleLog { get; init; } = "";
+ public string DeliveryTracker { get; init; } = "";
+ public string DeliveryTrackerAngle { get; init; } = "";
+ public string ShowOverlay { get; init; } = "";
+ public string ShowMinimap { get; init; } = "";
+ public string ShowMachLabels { get; init; } = "";
+ public string ShowSpotRadar { get; init; } = "";
+ public string OverlaySize { get; init; } = "";
+ public string TopOffset { get; init; } = "";
+ public string RightOffset { get; init; } = "";
+ public string Zoom { get; init; } = "";
+ public string Minimap { get; init; } = "";
+ public string AircraftScale { get; init; } = "";
+ public string MinimumMach { get; init; } = "";
+ public string SpotRadar { get; init; } = "";
+ public string ShowDistance { get; init; } = "";
+ public string ShowMach { get; init; } = "";
+ public string ShowClosureSpeed { get; init; } = "";
+ public string RadarRangeKm { get; init; } = "";
+ public string RadarSpread { get; init; } = "";
+ public string MarkerOpacity { get; init; } = "";
+ public string FontOpacity { get; init; } = "";
+ public string VerticalScale { get; init; } = "";
+ public string VerticalOffset { get; init; } = "";
+ public string ArrowScale { get; init; } = "";
+ public string ArrowOutline { get; init; } = "";
+ public string DistanceFontSize { get; init; } = "";
+ public string MachFontSize { get; init; } = "";
+ public string ClosureFontSize { get; init; } = "";
+ public string ArrowColor { get; init; } = "";
+ public string DistanceColor { get; init; } = "";
+ public string MachColor { get; init; } = "";
+ public string ClosureColor { get; init; } = "";
+ public string Save { get; init; } = "";
+ public string Reset { get; init; } = "";
+ public string LiveMap { get; init; } = "";
+ public string LocalApiScope { get; init; } = "";
+ public string BattleLog { get; init; } = "";
+ public string WaitingForWarThunder { get; init; } = "";
+ public string ConnectedToLocalApi { get; init; } = "";
+ public string WaitingForLocalMapApi { get; init; } = "";
+ public string WarThunderProcessNotDetected { get; init; } = "";
+ public string StartWarThunderToEnableLiveData { get; init; } = "";
+ public string WaitingForMapImage { get; init; } = "";
+ public string AcesDetected { get; init; } = "";
+ public string AcesNotDetected { get; init; } = "";
+ public string NeverUpdated { get; init; } = "";
+ public string PlayerNotDetected { get; init; } = "";
+ public string Player { get; init; } = "";
+ public string Allied { get; init; } = "";
+ public string Enemy { get; init; } = "";
+ public string Objects { get; init; } = "";
+ public string Pos { get; init; } = "";
+ public string Climb { get; init; } = "";
+ public string Heading { get; init; } = "";
+ public string Updated { get; init; } = "";
+ public string MessageEnemy { get; init; } = "";
+ public string MessageAlly { get; init; } = "";
+ public string MessageEvent { get; init; } = "";
+ public string SessionStarted { get; init; } = "";
+ public string AircraftVisible { get; init; } = "";
+ public string AlliedAircraftChanged { get; init; } = "";
+ public string EnemyAircraftChanged { get; init; } = "";
+
+ public static IReadOnlyList LanguageOptions { get; } =
+ [
+ new("en-US", "English"),
+ new("ko-KR", "한국어")
+ ];
+
+ public static UiText For(string? code)
+ {
+ return string.Equals(code, Korean.Code, StringComparison.OrdinalIgnoreCase)
+ ? Korean
+ : English;
+ }
+
+ public string FormatAllySummary(int count) => Code == Korean.Code ? $"{Allied} {count}" : $"{count} {Allied}";
+ public string FormatEnemySummary(int count) => Code == Korean.Code ? $"{Enemy} {count}" : $"{count} {Enemy}";
+ public string FormatObjectSummary(int count) => Code == Korean.Code ? $"{count}{Objects}" : $"{count} {Objects}";
+ public string FormatUpdated(DateTimeOffset updatedAt) => $"{Updated} {updatedAt:HH:mm:ss}";
+ public string FormatSessionStarted(int ally, int enemy) => $"{SessionStarted}: {ally} {Allied}, {enemy} {Enemy} {AircraftVisible}.";
+ public string FormatAlliedAircraftChanged(int oldCount, int newCount) => $"{AlliedAircraftChanged}: {oldCount} -> {newCount}.";
+ public string FormatEnemyAircraftChanged(int oldCount, int newCount) => $"{EnemyAircraftChanged}: {oldCount} -> {newCount}.";
+
+ public string FormatEndpointHealth(EndpointHealth health)
+ {
+ var scope = health.Required ? RequiredEndpoint : OptionalEndpoint;
+ var state = health.State switch
+ {
+ EndpointHealthState.Ok => EndpointOk,
+ EndpointHealthState.Warning => EndpointWarning,
+ EndpointHealthState.Error => EndpointError,
+ EndpointHealthState.NotChecked => EndpointNotChecked,
+ _ => health.State.ToString()
+ };
+
+ return $"{health.Path} [{scope}] {state} - {health.Detail}";
+ }
+
+ public string FormatStatus(string status)
+ {
+ return status switch
+ {
+ "Waiting for War Thunder" => WaitingForWarThunder,
+ "Connected to 127.0.0.1:8111" => ConnectedToLocalApi,
+ "War Thunder process not detected" => WarThunderProcessNotDetected,
+ _ when status.StartsWith("Waiting for local map API: ", StringComparison.Ordinal) =>
+ $"{WaitingForLocalMapApi}: {status["Waiting for local map API: ".Length..]}",
+ _ => status
+ };
+ }
+}
diff --git a/src/Ehwrj.App/OverlayWindow.axaml b/src/Ehwrj.App/OverlayWindow.axaml
new file mode 100644
index 0000000..ac6b027
--- /dev/null
+++ b/src/Ehwrj.App/OverlayWindow.axaml
@@ -0,0 +1,16 @@
+
+
+
diff --git a/src/Ehwrj.App/OverlayWindow.axaml.cs b/src/Ehwrj.App/OverlayWindow.axaml.cs
new file mode 100644
index 0000000..9305f54
--- /dev/null
+++ b/src/Ehwrj.App/OverlayWindow.axaml.cs
@@ -0,0 +1,45 @@
+using Avalonia;
+using Avalonia.Controls;
+using Ehwrj.App.Infrastructure;
+using Ehwrj.App.Models;
+
+namespace Ehwrj.App;
+
+public partial class OverlayWindow : Window
+{
+ private readonly OverlaySettings _settings;
+
+ public OverlayWindow()
+ : this(new OverlaySettings())
+ {
+ }
+
+ public OverlayWindow(OverlaySettings settings)
+ {
+ _settings = settings;
+ InitializeComponent();
+ Opened += OnOpened;
+ }
+
+ public void ApplyBounds()
+ {
+ var size = Math.Max(160, _settings.Size);
+ Width = size;
+ Height = size;
+
+ var area = Screens.Primary?.WorkingArea ?? new PixelRect(0, 0, 1920, 1080);
+ Position = new PixelPoint(
+ area.X + area.Width - (int)Math.Round(size) - (int)Math.Round(_settings.Right),
+ area.Y + (int)Math.Round(_settings.Top));
+ }
+
+ private void OnOpened(object? sender, EventArgs e)
+ {
+ if (TryGetPlatformHandle()?.Handle is { } handle)
+ {
+ Win32.MakeOverlayClickThrough(handle);
+ }
+
+ ApplyBounds();
+ }
+}
diff --git a/src/Ehwrj.App/Program.cs b/src/Ehwrj.App/Program.cs
new file mode 100644
index 0000000..8d661b9
--- /dev/null
+++ b/src/Ehwrj.App/Program.cs
@@ -0,0 +1,21 @@
+using Avalonia;
+
+namespace Ehwrj.App;
+
+internal static class Program
+{
+ [STAThread]
+ public static void Main(string[] args)
+ {
+ BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
+ }
+
+ public static AppBuilder BuildAvaloniaApp()
+ {
+ return AppBuilder.Configure()
+ .UsePlatformDetect()
+ .WithInterFont()
+ .LogToTrace();
+ }
+}
+
diff --git a/src/Ehwrj.App/Rendering/MapCanvas.cs b/src/Ehwrj.App/Rendering/MapCanvas.cs
new file mode 100644
index 0000000..153a485
--- /dev/null
+++ b/src/Ehwrj.App/Rendering/MapCanvas.cs
@@ -0,0 +1,463 @@
+using System.ComponentModel;
+using System.Globalization;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Media;
+using Avalonia.VisualTree;
+using Ehwrj.App.Models;
+using Ehwrj.Core.Geometry;
+using Ehwrj.Core.Models;
+
+namespace Ehwrj.App.Rendering;
+
+public class MapCanvas : Control
+{
+ private MapSettings? _subscribedMapSettings;
+ private OverlaySettings? _subscribedOverlaySettings;
+
+ public static readonly StyledProperty SnapshotProperty = AvaloniaProperty.Register(
+ nameof(Snapshot),
+ LiveSnapshot.Empty);
+
+ public static readonly StyledProperty OverlaySettingsProperty = AvaloniaProperty.Register(
+ nameof(OverlaySettings),
+ new OverlaySettings());
+
+ public static readonly StyledProperty MapSettingsProperty = AvaloniaProperty.Register(
+ nameof(MapSettings),
+ new MapSettings());
+
+ public static readonly StyledProperty UiProperty = AvaloniaProperty.Register(
+ nameof(Ui),
+ UiText.For("en-US"));
+
+ public LiveSnapshot Snapshot
+ {
+ get => GetValue(SnapshotProperty);
+ set => SetValue(SnapshotProperty, value);
+ }
+
+ public OverlaySettings OverlaySettings
+ {
+ get => GetValue(OverlaySettingsProperty);
+ set => SetValue(OverlaySettingsProperty, value);
+ }
+
+ public MapSettings MapSettings
+ {
+ get => GetValue(MapSettingsProperty);
+ set => SetValue(MapSettingsProperty, value);
+ }
+
+ public UiText Ui
+ {
+ get => GetValue(UiProperty);
+ set => SetValue(UiProperty, value);
+ }
+
+ static MapCanvas()
+ {
+ AffectsRender(SnapshotProperty, OverlaySettingsProperty, MapSettingsProperty, UiProperty);
+ }
+
+ protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
+ {
+ base.OnPropertyChanged(change);
+
+ if (change.Property == OverlaySettingsProperty)
+ {
+ SubscribeOverlaySettings(change.NewValue as OverlaySettings);
+ }
+ else if (change.Property == MapSettingsProperty)
+ {
+ SubscribeMapSettings(change.NewValue as MapSettings);
+ }
+ }
+
+ protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
+ {
+ SubscribeMapSettings(null);
+ SubscribeOverlaySettings(null);
+ base.OnDetachedFromVisualTree(e);
+ }
+
+ public override void Render(DrawingContext context)
+ {
+ base.Render(context);
+
+ var bounds = new Rect(0, 0, Bounds.Width, Bounds.Height);
+ context.FillRectangle(new SolidColorBrush(Color.FromRgb(7, 16, 20)), bounds);
+
+ var view = CreateView(bounds);
+ DrawRotatedMap(context, view);
+ DrawRangeRings(context, view);
+ DrawDeliveryTracker(context, view);
+ DrawObjects(context, view, detailed: true);
+ DrawStatus(context, bounds);
+ }
+
+ private ViewState CreateView(Rect bounds)
+ {
+ var imageSize = Snapshot.MapImage?.Size ?? new Size(1, 1);
+ var rect = Fit(bounds, imageSize.Width, imageSize.Height, 0.96);
+ var player = Snapshot.Player;
+
+ if (MapSettings.FollowPlayer && player is not null)
+ {
+ var projected = ProjectPointRaw(player, Snapshot, rect);
+ if (projected is not null)
+ {
+ const double scale = 1.45;
+ var width = rect.Width * scale;
+ var height = rect.Height * scale;
+ var normalizedX = (projected.Value.X - rect.Left) / Math.Max(1, rect.Width);
+ var normalizedY = (projected.Value.Y - rect.Top) / Math.Max(1, rect.Height);
+ rect = new Rect(
+ bounds.Center.X - normalizedX * width,
+ bounds.Center.Y - normalizedY * height,
+ width,
+ height);
+ }
+ }
+
+ var rotation = MapSettings.RotateWithPlayer && player is not null
+ ? ObjectHeading(player) ?? 0
+ : 0;
+
+ return new ViewState(rect, bounds.Center, rotation);
+ }
+
+ private void DrawRotatedMap(DrawingContext context, ViewState view)
+ {
+ if (Math.Abs(view.RotationRadians) < 0.0001)
+ {
+ DrawMap(context, view.Rect);
+ return;
+ }
+
+ using (context.PushTransform(Matrix.CreateRotation(-view.RotationRadians, view.Center)))
+ {
+ DrawMap(context, view.Rect);
+ }
+ }
+
+ private void DrawMap(DrawingContext context, Rect rect)
+ {
+ if (Snapshot.MapImage is not null)
+ {
+ context.DrawImage(Snapshot.MapImage, new Rect(Snapshot.MapImage.Size), rect);
+ }
+ else
+ {
+ context.FillRectangle(new SolidColorBrush(Color.FromRgb(10, 26, 32)), rect);
+ DrawCenteredText(context, Ui.WaitingForMapImage, rect, 18, Colors.White);
+ }
+
+ var gridPen = new Pen(new SolidColorBrush(Color.FromArgb(60, 230, 237, 243)), 1);
+ for (var i = 1; i < 6; i++)
+ {
+ var x = rect.Left + rect.Width * i / 6;
+ var y = rect.Top + rect.Height * i / 6;
+ context.DrawLine(gridPen, new Point(x, rect.Top), new Point(x, rect.Bottom));
+ context.DrawLine(gridPen, new Point(rect.Left, y), new Point(rect.Right, y));
+ }
+
+ context.DrawRectangle(null, new Pen(new SolidColorBrush(Color.FromArgb(110, 230, 237, 243)), 1), rect);
+ }
+
+ protected void DrawObjects(DrawingContext context, ViewState view, bool detailed)
+ {
+ foreach (var obj in Snapshot.Objects)
+ {
+ var point = ProjectPoint(obj, Snapshot, view);
+ if (point is null) continue;
+
+ var brush = new SolidColorBrush(ObjectColor(obj));
+
+ var radius = obj.IsPlayer ? 7 : obj.IsAircraft ? 5 : 4;
+ context.DrawEllipse(brush, new Pen(Brushes.Black, 1), point.Value, radius, radius);
+
+ if (detailed && MapSettings.ShowAircraftMach && obj.Mach.HasValue && obj.IsAircraft)
+ {
+ DrawText(context, $"M {obj.Mach.Value:0.0}", new Point(point.Value.X + 9, point.Value.Y - 10), 12, Colors.White);
+ }
+
+ if (detailed && MapSettings.ShowLabels)
+ {
+ var label = obj.Name ?? obj.Icon ?? obj.Type;
+ if (!string.IsNullOrWhiteSpace(label))
+ {
+ DrawText(context, label, new Point(point.Value.X + 9, point.Value.Y + 4), 12, Color.FromRgb(220, 229, 238));
+ }
+ }
+ }
+ }
+
+ private void DrawRangeRings(DrawingContext context, ViewState view)
+ {
+ if (!MapSettings.ShowRangeRings || Snapshot.Player is null)
+ {
+ return;
+ }
+
+ var playerPoint = ProjectPoint(Snapshot.Player, Snapshot, view);
+ if (playerPoint is null)
+ {
+ return;
+ }
+
+ var pen = new Pen(new SolidColorBrush(Color.FromArgb(75, 230, 237, 243)), 1);
+ var radiusUnit = Math.Min(view.Rect.Width, view.Rect.Height) * 0.1;
+ for (var i = 1; i <= 3; i++)
+ {
+ var radius = radiusUnit * i;
+ context.DrawEllipse(null, pen, playerPoint.Value, radius, radius);
+ DrawText(context, $"{i * 10} km", new Point(playerPoint.Value.X + radius + 4, playerPoint.Value.Y - 8), 11, Color.FromArgb(160, 230, 237, 243));
+ }
+ }
+
+ private void DrawDeliveryTracker(DrawingContext context, ViewState view)
+ {
+ if (!MapSettings.ShowDeliveryTracker)
+ {
+ return;
+ }
+
+ var friendlyAircraft = Snapshot.Objects.Where(static o =>
+ o.IsAircraft && o.Kind is MapObjectKind.Ally or MapObjectKind.Squad or MapObjectKind.Player);
+ var targets = Snapshot.Objects.Where(static o =>
+ o.IsEnemyBombingPoint || o.Kind == MapObjectKind.Enemy && (o.IsObjective || !o.IsAircraft));
+ var pen = new Pen(new SolidColorBrush(Color.FromArgb(170, 255, 212, 94)), 2);
+
+ foreach (var aircraft in friendlyAircraft)
+ {
+ var start = ProjectPoint(aircraft, Snapshot, view);
+ var forward = HeadingVector(aircraft);
+ if (start is null || forward is null)
+ {
+ continue;
+ }
+
+ foreach (var target in targets)
+ {
+ var end = ProjectPoint(target, Snapshot, view);
+ if (end is null)
+ {
+ continue;
+ }
+
+ var dx = end.Value.X - start.Value.X;
+ var dy = end.Value.Y - start.Value.Y;
+ var distance = Math.Sqrt(dx * dx + dy * dy);
+ if (distance < 1)
+ {
+ continue;
+ }
+
+ var dot = (dx * forward.Value.X + dy * forward.Value.Y) / distance;
+ if (dot < MapSettings.DeliveryTrackerAngle)
+ {
+ continue;
+ }
+
+ context.DrawLine(pen, start.Value, end.Value);
+ }
+ }
+ }
+
+ private void DrawStatus(DrawingContext context, Rect bounds)
+ {
+ var status = Snapshot.IsGameRunning
+ ? Ui.FormatStatus(Snapshot.Status)
+ : Ui.StartWarThunderToEnableLiveData;
+ DrawText(context, status, new Point(18, bounds.Bottom - 32), 13, Color.FromRgb(147, 164, 179));
+ }
+
+ protected static Rect Fit(Rect bounds, double imageWidth, double imageHeight, double fill)
+ {
+ var targetWidth = bounds.Width * fill;
+ var targetHeight = bounds.Height * fill;
+ var scale = Math.Min(targetWidth / Math.Max(1, imageWidth), targetHeight / Math.Max(1, imageHeight));
+ var width = imageWidth * scale;
+ var height = imageHeight * scale;
+ return new Rect(bounds.Left + (bounds.Width - width) / 2, bounds.Top + (bounds.Height - height) / 2, width, height);
+ }
+
+ protected static Point? ProjectPoint(MapObject obj, LiveSnapshot snapshot, ViewState view)
+ {
+ var point = ProjectPointRaw(obj, snapshot, view.Rect);
+ return point.HasValue ? RotateAround(point.Value, view.Center, -view.RotationRadians) : null;
+ }
+
+ protected static Point? ProjectPointRaw(MapObject obj, LiveSnapshot snapshot, Rect rect)
+ {
+ var viewport = new MapViewport(rect.Left, rect.Top, rect.Width, rect.Height);
+ var projected = CoordinateProjector.Project(obj, snapshot.MapInfo, viewport);
+ return projected.HasValue ? new Point(projected.Value.X, projected.Value.Y) : null;
+ }
+
+ protected static void DrawCenteredText(DrawingContext context, string text, Rect rect, double size, Color color)
+ {
+ var formatted = CreateText(text, size, color);
+ context.DrawText(formatted, new Point(rect.Left + (rect.Width - formatted.Width) / 2, rect.Top + (rect.Height - formatted.Height) / 2));
+ }
+
+ protected static void DrawText(DrawingContext context, string text, Point point, double size, Color color)
+ {
+ context.DrawText(CreateText(text, size, color), point);
+ }
+
+ protected static void DrawOutlinedText(
+ DrawingContext context,
+ string text,
+ Point point,
+ double size,
+ Color color,
+ double outlineWidth,
+ double opacityPercent)
+ {
+ var foreground = WithOpacity(color, opacityPercent);
+ if (outlineWidth > 0)
+ {
+ var outline = WithOpacity(Colors.Black, Math.Min(100, opacityPercent + 20));
+ var offsets = new[]
+ {
+ new Point(-outlineWidth, 0),
+ new Point(outlineWidth, 0),
+ new Point(0, -outlineWidth),
+ new Point(0, outlineWidth),
+ new Point(-outlineWidth, -outlineWidth),
+ new Point(outlineWidth, outlineWidth)
+ };
+
+ foreach (var offset in offsets)
+ {
+ DrawText(context, text, new Point(point.X + offset.X, point.Y + offset.Y), size, outline);
+ }
+ }
+
+ DrawText(context, text, point, size, foreground);
+ }
+
+ protected static Color ParseColor(string? value, Color fallback)
+ {
+ return Color.TryParse(value, out var color) ? color : fallback;
+ }
+
+ protected static Color ObjectColor(MapObject obj)
+ {
+ return obj.Kind switch
+ {
+ MapObjectKind.Player => Color.FromRgb(246, 200, 95),
+ MapObjectKind.Ally => Color.FromRgb(76, 201, 167),
+ MapObjectKind.Squad => Color.FromRgb(120, 156, 255),
+ MapObjectKind.Enemy => Color.FromRgb(255, 82, 82),
+ MapObjectKind.Objective => Color.FromRgb(222, 202, 132),
+ _ => obj.IsAircraft ? Color.FromRgb(255, 82, 82) : Color.FromRgb(222, 202, 132)
+ };
+ }
+
+ protected static double? ObjectHeading(MapObject obj)
+ {
+ if (obj.DirectionX.HasValue && obj.DirectionY.HasValue)
+ {
+ return Math.Atan2(obj.DirectionY.Value, obj.DirectionX.Value);
+ }
+
+ return obj.HeadingRadians;
+ }
+
+ protected static Point? HeadingVector(MapObject obj)
+ {
+ var heading = ObjectHeading(obj);
+ if (!heading.HasValue)
+ {
+ return null;
+ }
+
+ return new Point(Math.Cos(heading.Value), Math.Sin(heading.Value));
+ }
+
+ private static Point RotateAround(Point point, Point center, double radians)
+ {
+ if (Math.Abs(radians) < 0.0001)
+ {
+ return point;
+ }
+
+ var cos = Math.Cos(radians);
+ var sin = Math.Sin(radians);
+ var dx = point.X - center.X;
+ var dy = point.Y - center.Y;
+ return new Point(
+ center.X + dx * cos - dy * sin,
+ center.Y + dx * sin + dy * cos);
+ }
+
+ protected static FormattedText CreateText(string text, double size, Color color)
+ {
+ return new FormattedText(
+ text,
+ CultureInfo.InvariantCulture,
+ FlowDirection.LeftToRight,
+ new Typeface("Inter"),
+ size,
+ new SolidColorBrush(color));
+ }
+
+ private static Color WithOpacity(Color color, double opacityPercent)
+ {
+ var alpha = (byte)Math.Clamp(opacityPercent / 100.0 * 255, 0, 255);
+ return Color.FromArgb(alpha, color.R, color.G, color.B);
+ }
+
+ private void SubscribeOverlaySettings(OverlaySettings? settings)
+ {
+ if (ReferenceEquals(_subscribedOverlaySettings, settings))
+ {
+ return;
+ }
+
+ if (_subscribedOverlaySettings is not null)
+ {
+ _subscribedOverlaySettings.PropertyChanged -= OnOverlaySettingsChanged;
+ }
+
+ _subscribedOverlaySettings = settings;
+ if (_subscribedOverlaySettings is not null)
+ {
+ _subscribedOverlaySettings.PropertyChanged += OnOverlaySettingsChanged;
+ }
+ }
+
+ private void SubscribeMapSettings(MapSettings? settings)
+ {
+ if (ReferenceEquals(_subscribedMapSettings, settings))
+ {
+ return;
+ }
+
+ if (_subscribedMapSettings is not null)
+ {
+ _subscribedMapSettings.PropertyChanged -= OnMapSettingsChanged;
+ }
+
+ _subscribedMapSettings = settings;
+ if (_subscribedMapSettings is not null)
+ {
+ _subscribedMapSettings.PropertyChanged += OnMapSettingsChanged;
+ }
+ }
+
+ private void OnMapSettingsChanged(object? sender, PropertyChangedEventArgs e)
+ {
+ InvalidateVisual();
+ }
+
+ private void OnOverlaySettingsChanged(object? sender, PropertyChangedEventArgs e)
+ {
+ InvalidateVisual();
+ }
+}
+
+public readonly record struct ViewState(Rect Rect, Point Center, double RotationRadians);
diff --git a/src/Ehwrj.App/Rendering/OverlayCanvas.cs b/src/Ehwrj.App/Rendering/OverlayCanvas.cs
new file mode 100644
index 0000000..ddef94f
--- /dev/null
+++ b/src/Ehwrj.App/Rendering/OverlayCanvas.cs
@@ -0,0 +1,155 @@
+using Avalonia;
+using Avalonia.Media;
+using Ehwrj.Core.Geometry;
+
+namespace Ehwrj.App.Rendering;
+
+public sealed class OverlayCanvas : MapCanvas
+{
+ public override void Render(DrawingContext context)
+ {
+ var bounds = new Rect(0, 0, Bounds.Width, Bounds.Height);
+ var settings = OverlaySettings;
+ if (!settings.ShowMiniMap && !settings.ShowSpotRadar)
+ {
+ return;
+ }
+
+ if (settings.ShowMiniMap)
+ {
+ DrawMiniMap(context, bounds);
+ }
+
+ if (settings.ShowSpotRadar)
+ {
+ DrawSpotRadar(context, bounds);
+ }
+ }
+
+ private void DrawMiniMap(DrawingContext context, Rect bounds)
+ {
+ var alpha = (byte)Math.Clamp(OverlaySettings.SpotOpacity / 100.0 * 220, 0, 220);
+ context.DrawRectangle(new SolidColorBrush(Color.FromArgb(120, 0, 0, 0)), null, bounds, 8, 8);
+
+ if (Snapshot.MapImage is not null)
+ {
+ using (context.PushOpacity(0.82))
+ {
+ context.DrawImage(Snapshot.MapImage, new Rect(Snapshot.MapImage.Size), bounds);
+ }
+ }
+
+ var rect = new Rect(0, 0, bounds.Width, bounds.Height);
+ foreach (var obj in Snapshot.Objects)
+ {
+ var point = ProjectPointRaw(obj, Snapshot, rect);
+ if (point is null) continue;
+ if (obj.IsAircraft && obj.Mach.GetValueOrDefault() < OverlaySettings.MiniMapMinimumMach) continue;
+
+ var color = obj.IsPlayer ? Color.FromRgb(76, 201, 167) : obj.IsAircraft ? Color.FromRgb(255, 30, 30) : Color.FromRgb(240, 220, 150);
+ color = Color.FromArgb(alpha, color.R, color.G, color.B);
+ var brush = new SolidColorBrush(color);
+ var scale = OverlaySettings.MiniMapAircraftScale / 100.0;
+ var radius = (obj.IsPlayer ? 8 : obj.IsAircraft ? 5 : 3) * scale;
+ context.DrawEllipse(brush, new Pen(Brushes.Black, 1), point.Value, radius, radius);
+
+ if (OverlaySettings.ShowMach && obj.Mach.HasValue)
+ {
+ var textColor = ParseColor(OverlaySettings.SpotTextColor, Colors.White);
+ DrawOutlinedText(context, obj.Mach.Value.ToString("0.0"), new Point(point.Value.X + 8, point.Value.Y - 10), 13, textColor, 1, OverlaySettings.SpotFontOpacity);
+ }
+ }
+ }
+
+ private void DrawSpotRadar(DrawingContext context, Rect bounds)
+ {
+ var player = Snapshot.Player;
+ if (player is null) return;
+
+ var minSide = Math.Min(bounds.Width, bounds.Height);
+ var center = new Point(
+ bounds.Width / 2,
+ bounds.Height / 2 + OverlaySettings.SpotVerticalOffset * minSide / 2240.0);
+ var radius = minSide * 0.42 * (OverlaySettings.SpotDistance / 529.0);
+ radius = Math.Clamp(radius, minSide * 0.08, minSide * 0.48);
+ var radarPen = new Pen(new SolidColorBrush(Color.FromArgb(90, 255, 255, 255)), 1);
+ context.DrawEllipse(null, radarPen, center, radius, radius);
+ context.DrawLine(radarPen, new Point(center.X, center.Y - radius), new Point(center.X, center.Y + radius));
+ context.DrawLine(radarPen, new Point(center.X - radius, center.Y), new Point(center.X + radius, center.Y));
+
+ foreach (var target in Snapshot.Objects.Where(static o => o.IsAircraft && !o.IsPlayer))
+ {
+ var distance = CoordinateProjector.ApproximateDistanceKm(player, target);
+ if (!double.IsFinite(distance) || distance <= 0 || distance > OverlaySettings.SpotDetectDistanceKm) continue;
+ if (target.Mach.GetValueOrDefault() < OverlaySettings.SpotMinimumMach) continue;
+
+ var dx = target.X!.Value - player.X!.Value;
+ var dy = target.Y!.Value - player.Y!.Value;
+ var rawDistance = Math.Sqrt(dx * dx + dy * dy);
+ if (rawDistance <= double.Epsilon) continue;
+
+ var normalizedDistance = Math.Clamp(distance / Math.Max(1, OverlaySettings.SpotDetectDistanceKm), 0, 1);
+ var point = new Point(
+ center.X + dx / rawDistance * normalizedDistance * radius,
+ center.Y + dy / rawDistance * normalizedDistance * radius * (OverlaySettings.SpotVerticalScale / 100.0));
+ var opacity = (byte)Math.Clamp(OverlaySettings.SpotOpacity / 100.0 * 255, 0, 255);
+ var arrowColor = ParseColor(OverlaySettings.SpotArrowColor, Color.FromRgb(255, 30, 30));
+ var brush = new SolidColorBrush(Color.FromArgb(opacity, arrowColor.R, arrowColor.G, arrowColor.B));
+ DrawTriangle(context, point, 12 * OverlaySettings.SpotArrowScale / 100.0, brush, OverlaySettings.SpotOutlineWidth);
+
+ var lineY = point.Y - 13;
+ if (OverlaySettings.SpotShowDistance)
+ {
+ DrawOutlinedText(
+ context,
+ $"{distance:0.0} km",
+ new Point(point.X + 14, lineY),
+ OverlaySettings.DistanceFontSize,
+ ParseColor(OverlaySettings.DistanceTextColor, Color.FromRgb(255, 30, 30)),
+ OverlaySettings.DistanceOutlineWidth,
+ OverlaySettings.SpotFontOpacity);
+ lineY += OverlaySettings.DistanceFontSize + 2;
+ }
+
+ if (OverlaySettings.SpotShowMach && target.Mach.HasValue)
+ {
+ DrawOutlinedText(
+ context,
+ $"M {target.Mach.Value:0.0}",
+ new Point(point.X + 14, lineY),
+ OverlaySettings.MachFontSize,
+ ParseColor(OverlaySettings.MachTextColor, Color.FromRgb(87, 199, 242)),
+ OverlaySettings.MachOutlineWidth,
+ OverlaySettings.SpotFontOpacity);
+ lineY += OverlaySettings.MachFontSize + 2;
+ }
+
+ if (OverlaySettings.SpotShowRelativeSpeed && target.ClosureSpeed.HasValue)
+ {
+ DrawOutlinedText(
+ context,
+ $"{target.ClosureSpeed.Value:+0;-0;0} m/s",
+ new Point(point.X + 14, lineY),
+ OverlaySettings.RelativeFontSize,
+ ParseColor(OverlaySettings.RelativeTextColor, Color.FromRgb(25, 242, 79)),
+ OverlaySettings.RelativeOutlineWidth,
+ OverlaySettings.SpotFontOpacity);
+ }
+ }
+ }
+
+ private static void DrawTriangle(DrawingContext context, Point center, double size, IBrush brush, double outlineWidth)
+ {
+ var geometry = new StreamGeometry();
+ using (var ctx = geometry.Open())
+ {
+ ctx.BeginFigure(new Point(center.X, center.Y - size), true);
+ ctx.LineTo(new Point(center.X + size * 0.8, center.Y + size * 0.7));
+ ctx.LineTo(new Point(center.X - size * 0.8, center.Y + size * 0.7));
+ ctx.EndFigure(true);
+ }
+
+ var outline = outlineWidth > 0 ? new Pen(Brushes.Black, outlineWidth) : null;
+ context.DrawGeometry(brush, outline, geometry);
+ }
+}
diff --git a/src/Ehwrj.App/Services/LiveMapService.cs b/src/Ehwrj.App/Services/LiveMapService.cs
new file mode 100644
index 0000000..a13ecb0
--- /dev/null
+++ b/src/Ehwrj.App/Services/LiveMapService.cs
@@ -0,0 +1,215 @@
+using Ehwrj.App.Models;
+using Ehwrj.Core.Models;
+using Ehwrj.Core.Services;
+
+namespace Ehwrj.App.Services;
+
+public sealed class LiveMapService : IDisposable
+{
+ private static readonly EndpointDescriptor[] Endpoints =
+ [
+ new("map_info.json", Required: true),
+ new("map_obj.json", Required: true),
+ new("map.img", Required: true),
+ new("state", Required: false),
+ new("hudmsg", Required: false),
+ new("gamechat", Required: false)
+ ];
+
+ private readonly IWarThunderClient _client;
+ private readonly ProcessProbe _processProbe;
+ private readonly ObjectTracker _tracker = new();
+ private CancellationTokenSource? _cts;
+ private Task? _loop;
+ private byte[]? _lastImageBytes;
+ private Avalonia.Media.Imaging.Bitmap? _lastImage;
+
+ public LiveMapService(IWarThunderClient client, ProcessProbe processProbe)
+ {
+ _client = client;
+ _processProbe = processProbe;
+ }
+
+ public event EventHandler? SnapshotUpdated;
+
+ public bool IsRunning => _loop is { IsCompleted: false };
+
+ public void Start(int intervalMs)
+ {
+ if (IsRunning) return;
+ _cts = new CancellationTokenSource();
+ _loop = Task.Run(() => RunAsync(Math.Max(100, intervalMs), _cts.Token));
+ }
+
+ public void Stop()
+ {
+ _cts?.Cancel();
+ }
+
+ private async Task RunAsync(int intervalMs, CancellationToken cancellationToken)
+ {
+ using var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(intervalMs));
+ while (!cancellationToken.IsCancellationRequested)
+ {
+ await PublishOnceAsync(cancellationToken).ConfigureAwait(false);
+ await timer.WaitForNextTickAsync(cancellationToken).ConfigureAwait(false);
+ }
+ }
+
+ private async Task PublishOnceAsync(CancellationToken cancellationToken)
+ {
+ var gameRunning = _processProbe.IsWarThunderRunning();
+ var endpointHealth = new List();
+ try
+ {
+ var mapInfoJson = await ReadRequiredAsync("map_info.json", _client.GetMapInfoAsync, endpointHealth, cancellationToken).ConfigureAwait(false);
+ if (mapInfoJson is null)
+ {
+ PublishFailure(gameRunning, endpointHealth, "Waiting for local map API: map_info.json failed");
+ return;
+ }
+
+ var objectsJson = await ReadRequiredAsync("map_obj.json", _client.GetObjectsAsync, endpointHealth, cancellationToken).ConfigureAwait(false);
+ if (objectsJson is null)
+ {
+ PublishFailure(gameRunning, endpointHealth, "Waiting for local map API: map_obj.json failed");
+ return;
+ }
+
+ var imageBytes = await ReadRequiredAsync("map.img", _client.GetMapImageAsync, endpointHealth, cancellationToken).ConfigureAwait(false);
+ if (imageBytes is null)
+ {
+ PublishFailure(gameRunning, endpointHealth, "Waiting for local map API: map.img failed");
+ return;
+ }
+
+ var stateJson = await ReadOptionalAsync("state", _client.TryGetStateAsync, endpointHealth, cancellationToken).ConfigureAwait(false);
+ var hudJson = await ReadOptionalAsync("hudmsg", _client.TryGetHudMessagesAsync, endpointHealth, cancellationToken).ConfigureAwait(false);
+ var chatJson = await ReadOptionalAsync("gamechat", _client.TryGetGameChatAsync, endpointHealth, cancellationToken).ConfigureAwait(false);
+
+ var mapInfo = MapInfo.FromJson(mapInfoJson);
+ var flightState = FlightState.FromJson(stateJson);
+ var objects = MapObject.FromJson(objectsJson, _tracker, flightState);
+ var messages = BattleMessage.FromJson(hudJson)
+ .Concat(BattleMessage.FromJson(chatJson))
+ .DistinctBy(static m => $"{m.Enemy}:{m.Text}", StringComparer.Ordinal)
+ .ToArray();
+ var mapImage = GetBitmap(imageBytes);
+ var snapshot = new LiveSnapshot(mapInfo, objects, flightState, messages, mapImage, DateTimeOffset.Now, gameRunning, "Connected to 127.0.0.1:8111", CompleteEndpointHealth(endpointHealth));
+ SnapshotUpdated?.Invoke(this, snapshot);
+ }
+ catch (OperationCanceledException)
+ {
+ }
+ catch (Exception ex)
+ {
+ var status = gameRunning
+ ? $"Waiting for local map API: {ex.Message}"
+ : "War Thunder process not detected";
+ PublishFailure(gameRunning, endpointHealth, status);
+ }
+ }
+
+ private async Task ReadRequiredAsync(
+ string path,
+ Func> read,
+ List health,
+ CancellationToken cancellationToken)
+ where T : class
+ {
+ try
+ {
+ var value = await read(cancellationToken).ConfigureAwait(false);
+ health.Add(new EndpointHealth(path, Required: true, EndpointHealthState.Ok, "ok"));
+ return value;
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+ catch (Exception ex)
+ {
+ health.Add(new EndpointHealth(path, Required: true, EndpointHealthState.Error, ex.Message));
+ return null;
+ }
+ }
+
+ private async Task ReadOptionalAsync(
+ string path,
+ Func> read,
+ List health,
+ CancellationToken cancellationToken)
+ {
+ try
+ {
+ var value = await read(cancellationToken).ConfigureAwait(false);
+ health.Add(value is null
+ ? new EndpointHealth(path, Required: false, EndpointHealthState.Warning, "not available")
+ : new EndpointHealth(path, Required: false, EndpointHealthState.Ok, "ok"));
+ return value;
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+ catch (Exception ex)
+ {
+ health.Add(new EndpointHealth(path, Required: false, EndpointHealthState.Warning, ex.Message));
+ return null;
+ }
+ }
+
+ private void PublishFailure(bool gameRunning, List endpointHealth, string status)
+ {
+ SnapshotUpdated?.Invoke(
+ this,
+ new LiveSnapshot(
+ MapInfo.Empty,
+ Array.Empty(),
+ FlightState.Empty,
+ Array.Empty(),
+ _lastImage,
+ DateTimeOffset.Now,
+ gameRunning,
+ status,
+ CompleteEndpointHealth(endpointHealth)));
+ }
+
+ private static IReadOnlyList CompleteEndpointHealth(IReadOnlyList health)
+ {
+ var completed = new List(health);
+ foreach (var endpoint in Endpoints)
+ {
+ if (completed.Any(h => string.Equals(h.Path, endpoint.Path, StringComparison.Ordinal)))
+ {
+ continue;
+ }
+
+ completed.Add(new EndpointHealth(endpoint.Path, endpoint.Required, EndpointHealthState.NotChecked, "not checked"));
+ }
+
+ return completed;
+ }
+
+ private Avalonia.Media.Imaging.Bitmap? GetBitmap(byte[] imageBytes)
+ {
+ if (_lastImageBytes is not null && imageBytes.SequenceEqual(_lastImageBytes))
+ {
+ return _lastImage;
+ }
+
+ using var stream = new MemoryStream(imageBytes);
+ var bitmap = new Avalonia.Media.Imaging.Bitmap(stream);
+ _lastImageBytes = imageBytes;
+ _lastImage = bitmap;
+ return bitmap;
+ }
+
+ public void Dispose()
+ {
+ Stop();
+ _cts?.Dispose();
+ }
+}
+
+internal sealed record EndpointDescriptor(string Path, bool Required);
diff --git a/src/Ehwrj.App/Services/SettingsStore.cs b/src/Ehwrj.App/Services/SettingsStore.cs
new file mode 100644
index 0000000..ee179f5
--- /dev/null
+++ b/src/Ehwrj.App/Services/SettingsStore.cs
@@ -0,0 +1,45 @@
+using System.IO;
+using System.Text.Json;
+using Ehwrj.App.Models;
+
+namespace Ehwrj.App.Services;
+
+public sealed class SettingsStore
+{
+ private readonly JsonSerializerOptions _jsonOptions = new()
+ {
+ WriteIndented = true
+ };
+
+ public string SettingsDirectory { get; } = Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
+ "Ehwrj");
+
+ public string SettingsPath => Path.Combine(SettingsDirectory, "settings.json");
+
+ public AppSettings Load()
+ {
+ try
+ {
+ if (!File.Exists(SettingsPath))
+ {
+ return new AppSettings();
+ }
+
+ var json = File.ReadAllText(SettingsPath);
+ return JsonSerializer.Deserialize(json, _jsonOptions) ?? new AppSettings();
+ }
+ catch
+ {
+ return new AppSettings();
+ }
+ }
+
+ public void Save(AppSettings settings)
+ {
+ Directory.CreateDirectory(SettingsDirectory);
+ var json = JsonSerializer.Serialize(settings, _jsonOptions);
+ File.WriteAllText(SettingsPath, json);
+ }
+}
+
diff --git a/src/Ehwrj.App/ViewModels/MainViewModel.cs b/src/Ehwrj.App/ViewModels/MainViewModel.cs
new file mode 100644
index 0000000..d776839
--- /dev/null
+++ b/src/Ehwrj.App/ViewModels/MainViewModel.cs
@@ -0,0 +1,274 @@
+using System.Collections.ObjectModel;
+using System.Windows.Input;
+using Avalonia.Threading;
+using Ehwrj.App.Models;
+using Ehwrj.App.Services;
+using Ehwrj.Core.Models;
+
+namespace Ehwrj.App.ViewModels;
+
+public sealed class MainViewModel : ObservableObject, IDisposable
+{
+ private readonly LiveMapService _service;
+ private readonly SettingsStore _settingsStore;
+ private LiveSnapshot _snapshot = LiveSnapshot.Empty;
+ private bool _isOverlayEnabled;
+ private int _lastAllyCount = -1;
+ private int _lastEnemyCount = -1;
+ private DateTimeOffset? _sessionStartedAt;
+ private readonly HashSet _seenMessages = new(StringComparer.Ordinal);
+
+ public MainViewModel(LiveMapService service, SettingsStore settingsStore, AppSettings settings)
+ {
+ _service = service;
+ _settingsStore = settingsStore;
+ Settings = settings;
+
+ StartCommand = new RelayCommand(Start);
+ StopCommand = new RelayCommand(Stop);
+ SaveCommand = new RelayCommand(Save);
+ ResetSettingsCommand = new RelayCommand(ResetSettings);
+
+ _service.SnapshotUpdated += OnSnapshotUpdated;
+ UpdateEndpointHealth(Snapshot);
+ }
+
+ public AppSettings Settings { get; }
+ public ICommand StartCommand { get; }
+ public ICommand StopCommand { get; }
+ public ICommand SaveCommand { get; }
+ public ICommand ResetSettingsCommand { get; }
+ public ObservableCollection BattleLogEntries { get; } = new();
+ public ObservableCollection EndpointHealthEntries { get; } = new();
+ public IReadOnlyList LanguageOptions => UiText.LanguageOptions;
+ public UiText Ui => UiText.For(Settings.Language);
+
+ public LanguageOption SelectedLanguageOption
+ {
+ get => LanguageOptions.FirstOrDefault(o => string.Equals(o.Code, Settings.Language, StringComparison.OrdinalIgnoreCase))
+ ?? LanguageOptions[0];
+ set
+ {
+ if (string.Equals(Settings.Language, value.Code, StringComparison.OrdinalIgnoreCase))
+ {
+ return;
+ }
+
+ Settings.Language = value.Code;
+ OnPropertyChanged();
+ OnPropertyChanged(nameof(Ui));
+ NotifyLocalizedProperties();
+ RebuildBattleLog();
+ Save();
+ }
+ }
+
+ public LiveSnapshot Snapshot
+ {
+ get => _snapshot;
+ private set
+ {
+ if (SetProperty(ref _snapshot, value))
+ {
+ OnPropertyChanged(nameof(Status));
+ OnPropertyChanged(nameof(ProcessStatus));
+ OnPropertyChanged(nameof(SnapshotSummary));
+ OnPropertyChanged(nameof(AllySummary));
+ OnPropertyChanged(nameof(EnemySummary));
+ OnPropertyChanged(nameof(BattleTimerText));
+ OnPropertyChanged(nameof(PlayerInfo));
+ OnPropertyChanged(nameof(LastUpdatedText));
+ UpdateEndpointHealth(value);
+ UpdateBattleLog(value);
+ }
+ }
+ }
+
+ public bool IsOverlayEnabled
+ {
+ get => _isOverlayEnabled;
+ set => SetProperty(ref _isOverlayEnabled, value);
+ }
+
+ public string Status => Ui.FormatStatus(Snapshot.Status);
+ public string ProcessStatus => Snapshot.IsGameRunning ? Ui.AcesDetected : Ui.AcesNotDetected;
+ public string SnapshotSummary => Ui.FormatObjectSummary(Snapshot.Objects.Count);
+ public string AllySummary => Ui.FormatAllySummary(AllyCount);
+ public string EnemySummary => Ui.FormatEnemySummary(EnemyCount);
+ public string BattleTimerText => _sessionStartedAt.HasValue
+ ? FormatElapsed(DateTimeOffset.Now - _sessionStartedAt.Value)
+ : "0s";
+ public string PlayerInfo => FormatPlayerInfo();
+ public string LastUpdatedText => Snapshot.UpdatedAt == DateTimeOffset.MinValue
+ ? Ui.NeverUpdated
+ : Ui.FormatUpdated(Snapshot.UpdatedAt);
+
+ private int AllyCount => Snapshot.Objects.Count(static o => o.IsAircraft && o.Kind is MapObjectKind.Ally or MapObjectKind.Squad or MapObjectKind.Player);
+ private int EnemyCount => Snapshot.Objects.Count(static o => o.IsAircraft && o.Kind == MapObjectKind.Enemy);
+
+ private void Start()
+ {
+ _service.Start(Settings.PollIntervalMs);
+ }
+
+ private void Stop()
+ {
+ _service.Stop();
+ }
+
+ private void Save()
+ {
+ _settingsStore.Save(Settings);
+ }
+
+ private void ResetSettings()
+ {
+ Settings.Map.Reset();
+ Settings.Overlay.Reset();
+ Save();
+ }
+
+ private void UpdateBattleLog(LiveSnapshot snapshot)
+ {
+ if (snapshot.UpdatedAt == DateTimeOffset.MinValue)
+ {
+ return;
+ }
+
+ _sessionStartedAt ??= DateTimeOffset.Now;
+ var ally = AllyCount;
+ var enemy = EnemyCount;
+ foreach (var message in snapshot.Messages)
+ {
+ var key = $"{message.Enemy}:{message.Text}";
+ if (_seenMessages.Add(key))
+ {
+ var prefix = message.Enemy switch
+ {
+ true => Ui.MessageEnemy,
+ false => Ui.MessageAlly,
+ _ => Ui.MessageEvent
+ };
+ AddBattleLog($"{prefix}: {message.Text}");
+ }
+ }
+
+ if (_lastAllyCount < 0 || _lastEnemyCount < 0)
+ {
+ AddBattleLog(Ui.FormatSessionStarted(ally, enemy));
+ }
+ else
+ {
+ if (ally != _lastAllyCount)
+ {
+ AddBattleLog(Ui.FormatAlliedAircraftChanged(_lastAllyCount, ally));
+ }
+
+ if (enemy != _lastEnemyCount)
+ {
+ AddBattleLog(Ui.FormatEnemyAircraftChanged(_lastEnemyCount, enemy));
+ }
+ }
+
+ _lastAllyCount = ally;
+ _lastEnemyCount = enemy;
+ }
+
+ private void AddBattleLog(string text)
+ {
+ BattleLogEntries.Add($"{DateTimeOffset.Now:HH:mm:ss} {text}");
+ while (BattleLogEntries.Count > 120)
+ {
+ BattleLogEntries.RemoveAt(0);
+ }
+ }
+
+ private string FormatPlayerInfo()
+ {
+ var player = Snapshot.Player;
+ if (player is null)
+ {
+ return Ui.PlayerNotDetected;
+ }
+
+ var parts = new List { player.Name ?? Ui.Player };
+ if (player.X.HasValue && player.Y.HasValue)
+ {
+ parts.Add($"{Ui.Pos} {player.X.Value:0.000}, {player.Y.Value:0.000}");
+ }
+
+ if (player.Mach.HasValue)
+ {
+ parts.Add($"M {player.Mach.Value:0.0}");
+ }
+
+ if (player.ClimbAngleDegrees.HasValue)
+ {
+ parts.Add($"{Ui.Climb} {player.ClimbAngleDegrees.Value:+0;-0;0} deg");
+ }
+
+ var speed = Snapshot.FlightState.TrueAirspeedKmh ?? Snapshot.FlightState.IndicatedAirspeedKmh;
+ if (speed.HasValue)
+ {
+ parts.Add($"{speed.Value:0} km/h");
+ }
+
+ if (player.HeadingRadians.HasValue)
+ {
+ parts.Add($"{Ui.Heading} {player.HeadingRadians.Value * 180 / Math.PI:0} deg");
+ }
+
+ return string.Join(" | ", parts);
+ }
+
+ private static string FormatElapsed(TimeSpan elapsed)
+ {
+ return elapsed.TotalMinutes >= 1
+ ? $"{(int)elapsed.TotalMinutes}m {elapsed.Seconds}s"
+ : $"{Math.Max(0, elapsed.Seconds)}s";
+ }
+
+ private void OnSnapshotUpdated(object? sender, LiveSnapshot snapshot)
+ {
+ Dispatcher.UIThread.Post(() => Snapshot = snapshot);
+ }
+
+ private void RebuildBattleLog()
+ {
+ BattleLogEntries.Clear();
+ _seenMessages.Clear();
+ _lastAllyCount = -1;
+ _lastEnemyCount = -1;
+ UpdateBattleLog(Snapshot);
+ UpdateEndpointHealth(Snapshot);
+ }
+
+ private void NotifyLocalizedProperties()
+ {
+ OnPropertyChanged(nameof(Status));
+ OnPropertyChanged(nameof(ProcessStatus));
+ OnPropertyChanged(nameof(SnapshotSummary));
+ OnPropertyChanged(nameof(AllySummary));
+ OnPropertyChanged(nameof(EnemySummary));
+ OnPropertyChanged(nameof(BattleTimerText));
+ OnPropertyChanged(nameof(PlayerInfo));
+ OnPropertyChanged(nameof(LastUpdatedText));
+ UpdateEndpointHealth(Snapshot);
+ }
+
+ private void UpdateEndpointHealth(LiveSnapshot snapshot)
+ {
+ EndpointHealthEntries.Clear();
+ foreach (var health in snapshot.EndpointHealth)
+ {
+ EndpointHealthEntries.Add(Ui.FormatEndpointHealth(health));
+ }
+ }
+
+ public void Dispose()
+ {
+ _service.SnapshotUpdated -= OnSnapshotUpdated;
+ _settingsStore.Save(Settings);
+ _service.Dispose();
+ }
+}
diff --git a/src/Ehwrj.App/ViewModels/ObservableObject.cs b/src/Ehwrj.App/ViewModels/ObservableObject.cs
new file mode 100644
index 0000000..ab86e62
--- /dev/null
+++ b/src/Ehwrj.App/ViewModels/ObservableObject.cs
@@ -0,0 +1,27 @@
+using System.ComponentModel;
+using System.Runtime.CompilerServices;
+
+namespace Ehwrj.App.ViewModels;
+
+public abstract class ObservableObject : INotifyPropertyChanged
+{
+ public event PropertyChangedEventHandler? PropertyChanged;
+
+ protected bool SetProperty(ref T field, T value, [CallerMemberName] string? propertyName = null)
+ {
+ if (EqualityComparer.Default.Equals(field, value))
+ {
+ return false;
+ }
+
+ field = value;
+ OnPropertyChanged(propertyName);
+ return true;
+ }
+
+ protected void OnPropertyChanged([CallerMemberName] string? propertyName = null)
+ {
+ PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
+ }
+}
+
diff --git a/src/Ehwrj.App/ViewModels/RelayCommand.cs b/src/Ehwrj.App/ViewModels/RelayCommand.cs
new file mode 100644
index 0000000..549425f
--- /dev/null
+++ b/src/Ehwrj.App/ViewModels/RelayCommand.cs
@@ -0,0 +1,33 @@
+using System.Windows.Input;
+
+namespace Ehwrj.App.ViewModels;
+
+public sealed class RelayCommand : ICommand
+{
+ private readonly Action _execute;
+ private readonly Func? _canExecute;
+
+ public RelayCommand(Action execute, Func? canExecute = null)
+ {
+ _execute = execute;
+ _canExecute = canExecute;
+ }
+
+ public event EventHandler? CanExecuteChanged;
+
+ public bool CanExecute(object? parameter)
+ {
+ return _canExecute?.Invoke() ?? true;
+ }
+
+ public void Execute(object? parameter)
+ {
+ _execute();
+ }
+
+ public void RaiseCanExecuteChanged()
+ {
+ CanExecuteChanged?.Invoke(this, EventArgs.Empty);
+ }
+}
+
diff --git a/src/Ehwrj.Core/Ehwrj.Core.csproj b/src/Ehwrj.Core/Ehwrj.Core.csproj
new file mode 100644
index 0000000..a95a2f7
--- /dev/null
+++ b/src/Ehwrj.Core/Ehwrj.Core.csproj
@@ -0,0 +1,7 @@
+
+
+ net8.0
+ Ehwrj.Core
+
+
+
diff --git a/src/Ehwrj.Core/Geometry/CoordinateProjector.cs b/src/Ehwrj.Core/Geometry/CoordinateProjector.cs
new file mode 100644
index 0000000..d1a5eac
--- /dev/null
+++ b/src/Ehwrj.Core/Geometry/CoordinateProjector.cs
@@ -0,0 +1,49 @@
+using Ehwrj.Core.Models;
+
+namespace Ehwrj.Core.Geometry;
+
+public static class CoordinateProjector
+{
+ public static ProjectedPoint? Project(MapObject obj, MapInfo mapInfo, MapViewport viewport)
+ {
+ if (!obj.X.HasValue || !obj.Y.HasValue || viewport.Width <= 0 || viewport.Height <= 0)
+ {
+ return null;
+ }
+
+ var x = Normalize(obj.X.Value, mapInfo.MinX, mapInfo.MaxX);
+ var y = Normalize(obj.Y.Value, mapInfo.MinY, mapInfo.MaxY);
+
+ return new ProjectedPoint(
+ viewport.Left + x * viewport.Width,
+ viewport.Top + y * viewport.Height);
+ }
+
+ public static double ApproximateDistanceKm(MapObject a, MapObject b)
+ {
+ if (!a.X.HasValue || !a.Y.HasValue || !b.X.HasValue || !b.Y.HasValue)
+ {
+ return double.NaN;
+ }
+
+ var dx = a.X.Value - b.X.Value;
+ var dy = a.Y.Value - b.Y.Value;
+ return Math.Sqrt(dx * dx + dy * dy) * 100;
+ }
+
+ private static double Normalize(double value, double? min, double? max)
+ {
+ if (min.HasValue && max.HasValue && Math.Abs(max.Value - min.Value) > double.Epsilon)
+ {
+ return Math.Clamp((value - min.Value) / (max.Value - min.Value), 0, 1);
+ }
+
+ if (value is >= 0 and <= 1)
+ {
+ return value;
+ }
+
+ return Math.Clamp((value + 1) / 2, 0, 1);
+ }
+}
+
diff --git a/src/Ehwrj.Core/Geometry/MapViewport.cs b/src/Ehwrj.Core/Geometry/MapViewport.cs
new file mode 100644
index 0000000..174c3f4
--- /dev/null
+++ b/src/Ehwrj.Core/Geometry/MapViewport.cs
@@ -0,0 +1,8 @@
+namespace Ehwrj.Core.Geometry;
+
+public readonly record struct MapViewport(double Left, double Top, double Width, double Height)
+{
+ public double Right => Left + Width;
+ public double Bottom => Top + Height;
+}
+
diff --git a/src/Ehwrj.Core/Geometry/ProjectedPoint.cs b/src/Ehwrj.Core/Geometry/ProjectedPoint.cs
new file mode 100644
index 0000000..86b0a7a
--- /dev/null
+++ b/src/Ehwrj.Core/Geometry/ProjectedPoint.cs
@@ -0,0 +1,4 @@
+namespace Ehwrj.Core.Geometry;
+
+public readonly record struct ProjectedPoint(double X, double Y);
+
diff --git a/src/Ehwrj.Core/Models/BattleMessage.cs b/src/Ehwrj.Core/Models/BattleMessage.cs
new file mode 100644
index 0000000..f447e1f
--- /dev/null
+++ b/src/Ehwrj.Core/Models/BattleMessage.cs
@@ -0,0 +1,132 @@
+using System.Text.Json;
+
+namespace Ehwrj.Core.Models;
+
+public sealed record BattleMessage(string Text, bool? Enemy)
+{
+ public static IReadOnlyList FromJson(string? json)
+ {
+ if (string.IsNullOrWhiteSpace(json))
+ {
+ return Array.Empty();
+ }
+
+ try
+ {
+ using var document = JsonDocument.Parse(json);
+ var messages = new List();
+ Collect(document.RootElement, messages);
+ return messages
+ .Where(static m => !string.IsNullOrWhiteSpace(m.Text))
+ .DistinctBy(static m => $"{m.Enemy}:{m.Text}", StringComparer.Ordinal)
+ .ToArray();
+ }
+ catch (JsonException)
+ {
+ return Array.Empty();
+ }
+ }
+
+ private static void Collect(JsonElement element, List messages)
+ {
+ switch (element.ValueKind)
+ {
+ case JsonValueKind.Array:
+ foreach (var item in element.EnumerateArray())
+ {
+ Collect(item, messages);
+ }
+ break;
+ case JsonValueKind.Object:
+ if (TryCreate(element, out var message))
+ {
+ messages.Add(message);
+ }
+ else
+ {
+ foreach (var property in element.EnumerateObject())
+ {
+ if (property.Name is "items" or "events" or "damage" or "messages" or "gamechat" or "hudmsg")
+ {
+ Collect(property.Value, messages);
+ }
+ }
+ }
+ break;
+ case JsonValueKind.String:
+ messages.Add(new BattleMessage(Clean(element.GetString()), null));
+ break;
+ }
+ }
+
+ private static bool TryCreate(JsonElement element, out BattleMessage message)
+ {
+ var text = ReadString(element, "text", "msg", "message", "event", "title");
+ if (string.IsNullOrWhiteSpace(text))
+ {
+ message = new BattleMessage(string.Empty, null);
+ return false;
+ }
+
+ var enemy = ReadBool(element, "enemy", "is_enemy", "hostile");
+ message = new BattleMessage(Clean(text), enemy);
+ return true;
+ }
+
+ private static string? ReadString(JsonElement element, params string[] names)
+ {
+ foreach (var name in names)
+ {
+ if (!element.TryGetProperty(name, out var value))
+ {
+ continue;
+ }
+
+ return value.ValueKind == JsonValueKind.String ? value.GetString() : value.ToString();
+ }
+
+ return null;
+ }
+
+ private static bool? ReadBool(JsonElement element, params string[] names)
+ {
+ foreach (var name in names)
+ {
+ if (!element.TryGetProperty(name, out var value))
+ {
+ continue;
+ }
+
+ if (value.ValueKind is JsonValueKind.True or JsonValueKind.False)
+ {
+ return value.GetBoolean();
+ }
+ }
+
+ return null;
+ }
+
+ private static string Clean(string? text)
+ {
+ if (string.IsNullOrWhiteSpace(text))
+ {
+ return string.Empty;
+ }
+
+ var result = text;
+ while (true)
+ {
+ var start = result.IndexOf('<', StringComparison.Ordinal);
+ var end = result.IndexOf('>', StringComparison.Ordinal);
+ if (start < 0 || end <= start)
+ {
+ break;
+ }
+
+ result = result.Remove(start, end - start + 1);
+ }
+
+ return result.Trim();
+ }
+}
+
diff --git a/src/Ehwrj.Core/Models/FlightState.cs b/src/Ehwrj.Core/Models/FlightState.cs
new file mode 100644
index 0000000..2916dda
--- /dev/null
+++ b/src/Ehwrj.Core/Models/FlightState.cs
@@ -0,0 +1,124 @@
+using System.Globalization;
+using System.Text.Json;
+
+namespace Ehwrj.Core.Models;
+
+public sealed class FlightState
+{
+ public static FlightState Empty { get; } = new(new Dictionary(StringComparer.Ordinal), "{}");
+
+ public FlightState(IReadOnlyDictionary values, string rawJson)
+ {
+ Values = values;
+ RawJson = rawJson;
+ }
+
+ public IReadOnlyDictionary Values { get; }
+ public string RawJson { get; }
+ public double? TrueAirspeedKmh => GetNumber("TAS, km/h", "TAS", "true_air_speed");
+ public double? IndicatedAirspeedKmh => GetNumber("IAS, km/h", "IAS", "indicated_air_speed");
+ public double? VerticalSpeedMetersPerSecond => GetNumber("Vy, m/s", "Vy", "vertical_speed");
+ public double? AltitudeMeters => GetNumber("H, m", "H", "altitude");
+ public double? Mach => CalculateMach();
+ public double? ClimbAngleDegrees => CalculateClimbAngle();
+
+ public static FlightState FromJson(string? json)
+ {
+ if (string.IsNullOrWhiteSpace(json))
+ {
+ return Empty;
+ }
+
+ try
+ {
+ using var document = JsonDocument.Parse(json);
+ var values = new Dictionary(StringComparer.Ordinal);
+ Flatten(document.RootElement, values, prefix: null);
+ return new FlightState(values, json);
+ }
+ catch (JsonException)
+ {
+ return Empty;
+ }
+ }
+
+ public double? GetNumber(params string[] names)
+ {
+ foreach (var name in names)
+ {
+ if (!Values.TryGetValue(name, out var text))
+ {
+ continue;
+ }
+
+ if (double.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out var value))
+ {
+ return value;
+ }
+
+ var normalized = text.Replace(',', '.');
+ if (double.TryParse(normalized, NumberStyles.Float, CultureInfo.InvariantCulture, out value))
+ {
+ return value;
+ }
+ }
+
+ return null;
+ }
+
+ private double? CalculateMach()
+ {
+ var kmh = TrueAirspeedKmh ?? IndicatedAirspeedKmh ?? GetNumber("M, km/h");
+ if (!kmh.HasValue || kmh.Value <= 0)
+ {
+ return null;
+ }
+
+ return Math.Clamp(kmh.Value / 3.6 / 343.0, 0, 5);
+ }
+
+ private double? CalculateClimbAngle()
+ {
+ var kmh = TrueAirspeedKmh ?? IndicatedAirspeedKmh;
+ var vertical = VerticalSpeedMetersPerSecond;
+ if (!kmh.HasValue || !vertical.HasValue || kmh.Value <= 1)
+ {
+ return null;
+ }
+
+ var speedMs = kmh.Value / 3.6;
+ var ratio = Math.Clamp(vertical.Value / speedMs, -1, 1);
+ return Math.Asin(ratio) * 180.0 / Math.PI;
+ }
+
+ private static void Flatten(JsonElement element, Dictionary values, string? prefix)
+ {
+ switch (element.ValueKind)
+ {
+ case JsonValueKind.Object:
+ foreach (var property in element.EnumerateObject())
+ {
+ var name = prefix is null ? property.Name : $"{prefix}.{property.Name}";
+ Flatten(property.Value, values, name);
+ }
+ break;
+ case JsonValueKind.Array:
+ var index = 0;
+ foreach (var item in element.EnumerateArray())
+ {
+ Flatten(item, values, prefix is null ? $"[{index}]" : $"{prefix}[{index}]");
+ index++;
+ }
+ break;
+ case JsonValueKind.String:
+ if (prefix is not null) values[prefix] = element.GetString() ?? string.Empty;
+ break;
+ case JsonValueKind.Number:
+ case JsonValueKind.True:
+ case JsonValueKind.False:
+ if (prefix is not null) values[prefix] = element.ToString();
+ break;
+ }
+ }
+}
+
diff --git a/src/Ehwrj.Core/Models/MapInfo.cs b/src/Ehwrj.Core/Models/MapInfo.cs
new file mode 100644
index 0000000..4e0e032
--- /dev/null
+++ b/src/Ehwrj.Core/Models/MapInfo.cs
@@ -0,0 +1,138 @@
+using System.Globalization;
+using System.Text.Json;
+
+namespace Ehwrj.Core.Models;
+
+public sealed class MapInfo
+{
+ public static MapInfo Empty { get; } = new(null, null, null, null, null, null, null);
+
+ public MapInfo(
+ double? minX,
+ double? maxX,
+ double? minY,
+ double? maxY,
+ double? gridStepX,
+ double? gridStepY,
+ string? raw)
+ {
+ MinX = minX;
+ MaxX = maxX;
+ MinY = minY;
+ MaxY = maxY;
+ GridStepX = gridStepX;
+ GridStepY = gridStepY;
+ Raw = raw;
+ }
+
+ public double? MinX { get; }
+ public double? MaxX { get; }
+ public double? MinY { get; }
+ public double? MaxY { get; }
+ public double? GridStepX { get; }
+ public double? GridStepY { get; }
+ public string? Raw { get; }
+
+ public static MapInfo FromJson(string json)
+ {
+ using var document = JsonDocument.Parse(json);
+ var root = document.RootElement;
+
+ var minX = ReadNumber(root, "min_x", "xmin", "map_min_x");
+ var maxX = ReadNumber(root, "max_x", "xmax", "map_max_x");
+ var minY = ReadNumber(root, "min_y", "ymin", "map_min_y");
+ var maxY = ReadNumber(root, "max_y", "ymax", "map_max_y");
+
+ if (root.TryGetProperty("map_min", out var mapMin) && mapMin.ValueKind == JsonValueKind.Array)
+ {
+ minX ??= ReadArrayNumber(mapMin, 0);
+ minY ??= ReadArrayNumber(mapMin, 1);
+ }
+
+ if (root.TryGetProperty("map_max", out var mapMax) && mapMax.ValueKind == JsonValueKind.Array)
+ {
+ maxX ??= ReadArrayNumber(mapMax, 0);
+ maxY ??= ReadArrayNumber(mapMax, 1);
+ }
+
+ if (root.TryGetProperty("bounds", out var bounds) &&
+ bounds.ValueKind == JsonValueKind.Array &&
+ bounds.GetArrayLength() >= 2 &&
+ bounds[0].ValueKind == JsonValueKind.Array &&
+ bounds[1].ValueKind == JsonValueKind.Array)
+ {
+ minX ??= ReadArrayNumber(bounds[0], 0);
+ minY ??= ReadArrayNumber(bounds[0], 1);
+ maxX ??= ReadArrayNumber(bounds[1], 0);
+ maxY ??= ReadArrayNumber(bounds[1], 1);
+ }
+
+ if (root.TryGetProperty("grid_size", out var gridSize) && gridSize.ValueKind == JsonValueKind.Array)
+ {
+ minX ??= 0;
+ minY ??= 0;
+ maxX ??= ReadArrayNumber(gridSize, 0);
+ maxY ??= ReadArrayNumber(gridSize, 1);
+ }
+
+ double? gridStepX = null;
+ double? gridStepY = null;
+ if (root.TryGetProperty("grid_steps", out var gridSteps) && gridSteps.ValueKind == JsonValueKind.Array)
+ {
+ gridStepX = ReadArrayNumber(gridSteps, 0);
+ gridStepY = ReadArrayNumber(gridSteps, 1);
+ }
+
+ return new MapInfo(minX, maxX, minY, maxY, gridStepX, gridStepY, json);
+ }
+
+ private static double? ReadNumber(JsonElement element, params string[] names)
+ {
+ foreach (var name in names)
+ {
+ if (element.ValueKind == JsonValueKind.Object &&
+ element.TryGetProperty(name, out var value))
+ {
+ var number = ReadNumber(value);
+ if (number.HasValue) return number;
+ }
+ }
+
+ return null;
+ }
+
+ private static double? ReadNumber(JsonElement value)
+ {
+ return value.ValueKind switch
+ {
+ JsonValueKind.Number when value.TryGetDouble(out var n) => n,
+ JsonValueKind.String => ParseNumber(value.GetString()),
+ _ => null
+ };
+ }
+
+ private static double? ReadArrayNumber(JsonElement array, int index)
+ {
+ return array.ValueKind == JsonValueKind.Array && array.GetArrayLength() > index
+ ? ReadNumber(array[index])
+ : null;
+ }
+
+ private static double? ParseNumber(string? text)
+ {
+ if (string.IsNullOrWhiteSpace(text))
+ {
+ return null;
+ }
+
+ if (double.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out var value))
+ {
+ return value;
+ }
+
+ var normalized = text.Replace(',', '.');
+ return double.TryParse(normalized, NumberStyles.Float, CultureInfo.InvariantCulture, out value)
+ ? value
+ : null;
+ }
+}
diff --git a/src/Ehwrj.Core/Models/MapObject.cs b/src/Ehwrj.Core/Models/MapObject.cs
new file mode 100644
index 0000000..580de14
--- /dev/null
+++ b/src/Ehwrj.Core/Models/MapObject.cs
@@ -0,0 +1,296 @@
+using System.Globalization;
+using System.Text.Json;
+
+namespace Ehwrj.Core.Models;
+
+public sealed class MapObject
+{
+ public required string Id { get; init; }
+ public string? Name { get; init; }
+ public string? Type { get; init; }
+ public string? Icon { get; init; }
+ public string? Team { get; init; }
+ public string? Color { get; init; }
+ public double? X { get; init; }
+ public double? Y { get; init; }
+ public double? DirectionX { get; init; }
+ public double? DirectionY { get; init; }
+ public double? HeadingRadians { get; init; }
+ public double? Mach { get; init; }
+ public double? ClosureSpeed { get; init; }
+ public double? ClimbAngleDegrees { get; init; }
+ public MapObjectKind Kind { get; init; }
+ public bool IsPlayer { get; init; }
+ public bool IsAircraft { get; init; }
+ public bool IsObjective { get; init; }
+ public bool IsEnemyBombingPoint { get; init; }
+ public string RawJson { get; init; } = "{}";
+
+ public static IReadOnlyList FromJson(string json, ObjectTracker tracker, FlightState? flightState = null)
+ {
+ using var document = JsonDocument.Parse(json);
+ var root = document.RootElement;
+ var source = root.ValueKind == JsonValueKind.Array
+ ? root.EnumerateArray()
+ : root.ValueKind == JsonValueKind.Object && root.TryGetProperty("objects", out var objects) && objects.ValueKind == JsonValueKind.Array
+ ? objects.EnumerateArray()
+ : Array.Empty().AsEnumerable();
+
+ return source.Select((item, index) => FromElement(item, index, tracker, flightState ?? FlightState.Empty)).ToArray();
+ }
+
+ private static MapObject FromElement(JsonElement element, int index, ObjectTracker tracker, FlightState flightState)
+ {
+ var name = ReadString(element, "name", "title", "label");
+ var type = ReadString(element, "type", "object_type");
+ var icon = ReadString(element, "icon", "icon_type");
+ var team = ReadString(element, "team", "army", "side");
+ var color = ReadString(element, "color", "colour");
+ var id = ReadString(element, "id", "uid") ?? StableId(name, type, icon, index);
+ var x = ReadNumber(element, "x", "sx", "lon");
+ var y = ReadNumber(element, "y", "sy", "lat");
+ ReadVector(element, ref x, ref y, "pos", "position", "coord", "coords", "location");
+
+ var dx = ReadNumber(element, "dx", "dir_x", "vx");
+ var dy = ReadNumber(element, "dy", "dir_y", "vy");
+ ReadVector(element, ref dx, ref dy, "dir", "direction", "velocity", "vector");
+ var heading = NormalizeHeading(ReadNumber(element, "angle", "rotation", "dir", "heading"));
+
+ var classifier = $"{name} {type} {icon} {team}".ToLowerInvariant();
+ var kind = Classify(classifier, color);
+ var isPlayer = kind == MapObjectKind.Player;
+ var isAircraft = classifier.Contains("air", StringComparison.Ordinal) ||
+ classifier.Contains("plane", StringComparison.Ordinal) ||
+ classifier.Contains("fighter", StringComparison.Ordinal) ||
+ classifier.Contains("bomber", StringComparison.Ordinal) ||
+ classifier.Contains("attacker", StringComparison.Ordinal) ||
+ classifier.Contains("interceptor", StringComparison.Ordinal) ||
+ classifier.Contains("player", StringComparison.Ordinal);
+ var isEnemyBombingPoint = classifier.Contains("bombing_point", StringComparison.Ordinal) &&
+ kind == MapObjectKind.Enemy;
+ var isObjective = kind == MapObjectKind.Objective ||
+ isEnemyBombingPoint ||
+ classifier.Contains("capture", StringComparison.Ordinal) ||
+ classifier.Contains("zone", StringComparison.Ordinal) ||
+ classifier.Contains("objective", StringComparison.Ordinal);
+
+ var motion = tracker.Update(id, x, y);
+ var mach = isPlayer && flightState.Mach.HasValue
+ ? flightState.Mach
+ : motion.Mach;
+ var climbAngle = isPlayer ? flightState.ClimbAngleDegrees : null;
+
+ return new MapObject
+ {
+ Id = id,
+ Name = name,
+ Type = type,
+ Icon = icon,
+ Team = team,
+ Color = color,
+ X = x,
+ Y = y,
+ DirectionX = dx,
+ DirectionY = dy,
+ HeadingRadians = heading,
+ Mach = mach,
+ ClosureSpeed = motion.ClosureSpeed,
+ ClimbAngleDegrees = climbAngle,
+ Kind = kind,
+ IsPlayer = isPlayer,
+ IsAircraft = isAircraft,
+ IsObjective = isObjective,
+ IsEnemyBombingPoint = isEnemyBombingPoint,
+ RawJson = element.GetRawText()
+ };
+ }
+
+ private static MapObjectKind Classify(string classifier, string? color)
+ {
+ if (classifier.Contains("player", StringComparison.Ordinal) ||
+ classifier.Contains("own", StringComparison.Ordinal) ||
+ classifier.Contains("self", StringComparison.Ordinal))
+ {
+ return MapObjectKind.Player;
+ }
+
+ if (classifier.Contains("squad", StringComparison.Ordinal))
+ {
+ return MapObjectKind.Squad;
+ }
+
+ if (classifier.Contains("enemy", StringComparison.Ordinal) ||
+ classifier.Contains("hostile", StringComparison.Ordinal) ||
+ classifier.Contains("bombing_point", StringComparison.Ordinal))
+ {
+ return MapObjectKind.Enemy;
+ }
+
+ if (classifier.Contains("ally", StringComparison.Ordinal) ||
+ classifier.Contains("friendly", StringComparison.Ordinal) ||
+ classifier.Contains("defending_point", StringComparison.Ordinal))
+ {
+ return MapObjectKind.Ally;
+ }
+
+ if (classifier.Contains("capture", StringComparison.Ordinal) ||
+ classifier.Contains("objective", StringComparison.Ordinal) ||
+ classifier.Contains("zone", StringComparison.Ordinal))
+ {
+ return MapObjectKind.Objective;
+ }
+
+ var colorKind = ClassifyColor(color);
+ if (colorKind != MapObjectKind.Unknown)
+ {
+ return colorKind;
+ }
+
+ return MapObjectKind.Unknown;
+ }
+
+ private static MapObjectKind ClassifyColor(string? color)
+ {
+ if (string.IsNullOrWhiteSpace(color))
+ {
+ return MapObjectKind.Unknown;
+ }
+
+ var text = color.Trim();
+ if (text.StartsWith('#') && text.Length == 7)
+ {
+ var r = Convert.ToInt32(text[1..3], 16);
+ var g = Convert.ToInt32(text[3..5], 16);
+ var b = Convert.ToInt32(text[5..7], 16);
+ return ClassifyRgb(r, g, b);
+ }
+
+ return MapObjectKind.Unknown;
+ }
+
+ private static MapObjectKind ClassifyRgb(int r, int g, int b)
+ {
+ if (r > 210 && g > 210 && b > 210)
+ {
+ return MapObjectKind.Player;
+ }
+
+ if (r > 160 && r > g * 1.25 && r > b * 1.25)
+ {
+ return MapObjectKind.Enemy;
+ }
+
+ if (g > 120 && b > 120 && r < 120)
+ {
+ return MapObjectKind.Ally;
+ }
+
+ if (r > 185 && g > 145 && b < 130)
+ {
+ return MapObjectKind.Player;
+ }
+
+ return MapObjectKind.Unknown;
+ }
+
+ private static double? NormalizeHeading(double? heading)
+ {
+ if (!heading.HasValue || !double.IsFinite(heading.Value))
+ {
+ return null;
+ }
+
+ var value = heading.Value;
+ if (Math.Abs(value) > Math.Tau)
+ {
+ value *= Math.PI / 180.0;
+ }
+
+ while (value < -Math.PI) value += Math.Tau;
+ while (value > Math.PI) value -= Math.Tau;
+ return value;
+ }
+
+ private static string StableId(string? name, string? type, string? icon, int index)
+ {
+ return $"{name ?? "object"}:{type ?? "unknown"}:{icon ?? "none"}:{index}";
+ }
+
+ private static string? ReadString(JsonElement element, params string[] names)
+ {
+ foreach (var name in names)
+ {
+ if (!element.TryGetProperty(name, out var value)) continue;
+ if (value.ValueKind == JsonValueKind.String) return value.GetString();
+ if (value.ValueKind is JsonValueKind.Number or JsonValueKind.True or JsonValueKind.False) return value.ToString();
+ }
+
+ return null;
+ }
+
+ private static double? ReadNumber(JsonElement element, params string[] names)
+ {
+ foreach (var name in names)
+ {
+ if (!element.TryGetProperty(name, out var value)) continue;
+ if (value.ValueKind == JsonValueKind.Number && value.TryGetDouble(out var number)) return number;
+ if (value.ValueKind == JsonValueKind.String)
+ {
+ var parsed = ParseNumber(value.GetString());
+ if (parsed.HasValue) return parsed;
+ }
+ }
+
+ return null;
+ }
+
+ private static void ReadVector(JsonElement element, ref double? x, ref double? y, params string[] names)
+ {
+ if (x.HasValue && y.HasValue)
+ {
+ return;
+ }
+
+ foreach (var name in names)
+ {
+ if (!element.TryGetProperty(name, out var value) ||
+ value.ValueKind != JsonValueKind.Array ||
+ value.GetArrayLength() < 2)
+ {
+ continue;
+ }
+
+ x ??= ReadNumberValue(value[0]);
+ y ??= ReadNumberValue(value[1]);
+ return;
+ }
+ }
+
+ private static double? ReadNumberValue(JsonElement value)
+ {
+ return value.ValueKind switch
+ {
+ JsonValueKind.Number when value.TryGetDouble(out var number) => number,
+ JsonValueKind.String => ParseNumber(value.GetString()),
+ _ => null
+ };
+ }
+
+ private static double? ParseNumber(string? text)
+ {
+ if (string.IsNullOrWhiteSpace(text))
+ {
+ return null;
+ }
+
+ if (double.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out var value))
+ {
+ return value;
+ }
+
+ var normalized = text.Replace(',', '.');
+ return double.TryParse(normalized, NumberStyles.Float, CultureInfo.InvariantCulture, out value)
+ ? value
+ : null;
+ }
+}
diff --git a/src/Ehwrj.Core/Models/MapObjectKind.cs b/src/Ehwrj.Core/Models/MapObjectKind.cs
new file mode 100644
index 0000000..21694f2
--- /dev/null
+++ b/src/Ehwrj.Core/Models/MapObjectKind.cs
@@ -0,0 +1,12 @@
+namespace Ehwrj.Core.Models;
+
+public enum MapObjectKind
+{
+ Unknown,
+ Ally,
+ Squad,
+ Enemy,
+ Player,
+ Objective
+}
+
diff --git a/src/Ehwrj.Core/Models/ObjectTracker.cs b/src/Ehwrj.Core/Models/ObjectTracker.cs
new file mode 100644
index 0000000..19414b5
--- /dev/null
+++ b/src/Ehwrj.Core/Models/ObjectTracker.cs
@@ -0,0 +1,38 @@
+namespace Ehwrj.Core.Models;
+
+public sealed class ObjectTracker
+{
+ private readonly Dictionary _last = new(StringComparer.Ordinal);
+
+ public MotionEstimate Update(string id, double? x, double? y)
+ {
+ if (!x.HasValue || !y.HasValue)
+ {
+ return MotionEstimate.Empty;
+ }
+
+ var now = DateTimeOffset.UtcNow;
+ if (!_last.TryGetValue(id, out var prior))
+ {
+ _last[id] = new TrackPoint(x.Value, y.Value, now, null, null);
+ return MotionEstimate.Empty;
+ }
+
+ var seconds = Math.Max(0.001, (now - prior.UpdatedAt).TotalSeconds);
+ var distance = Math.Sqrt(Math.Pow(x.Value - prior.X, 2) + Math.Pow(y.Value - prior.Y, 2));
+ var pseudoMetersPerSecond = distance * 100_000 / seconds;
+ var rawMach = Math.Clamp(pseudoMetersPerSecond / 343.0, 0, 5);
+ var mach = prior.Mach.HasValue ? prior.Mach.Value * 0.72 + rawMach * 0.28 : rawMach;
+ var closure = prior.ClosureSpeed.HasValue ? prior.ClosureSpeed.Value * 0.65 + pseudoMetersPerSecond * 0.35 : pseudoMetersPerSecond;
+
+ _last[id] = new TrackPoint(x.Value, y.Value, now, mach, closure);
+ return new MotionEstimate(mach, closure);
+ }
+
+ private sealed record TrackPoint(double X, double Y, DateTimeOffset UpdatedAt, double? Mach, double? ClosureSpeed);
+}
+
+public readonly record struct MotionEstimate(double? Mach, double? ClosureSpeed)
+{
+ public static MotionEstimate Empty { get; } = new(null, null);
+}
diff --git a/src/Ehwrj.Core/Services/CaptureFixtureAnalyzer.cs b/src/Ehwrj.Core/Services/CaptureFixtureAnalyzer.cs
new file mode 100644
index 0000000..f32391f
--- /dev/null
+++ b/src/Ehwrj.Core/Services/CaptureFixtureAnalyzer.cs
@@ -0,0 +1,452 @@
+using System.Globalization;
+using System.Text.Json;
+using Ehwrj.Core.Models;
+
+namespace Ehwrj.Core.Services;
+
+public static class CaptureFixtureAnalyzer
+{
+ private static readonly string[] RequiredFiles = ["map_info.json", "map_obj.json", "map.img"];
+ private static readonly string[] OptionalFiles = ["state.json", "hudmsg.json", "gamechat.json"];
+
+ public static CaptureFixtureReport AnalyzeDirectory(string directory)
+ {
+ var fullPath = Path.GetFullPath(directory);
+ var files = RequiredFiles.Concat(OptionalFiles)
+ .ToDictionary(static name => name, name => File.Exists(Path.Combine(fullPath, name)), StringComparer.Ordinal);
+ var warnings = new List();
+
+ foreach (var required in RequiredFiles)
+ {
+ if (!files[required])
+ {
+ warnings.Add($"Missing required file: {required}");
+ }
+ }
+
+ var mapInfo = ReadMapInfo(fullPath, warnings);
+ var flightState = ReadFlightState(fullPath, warnings);
+ var objects = ReadObjects(fullPath, flightState, warnings);
+ var hudMessages = ReadMessages(fullPath, "hudmsg.json", warnings);
+ var chatMessages = ReadMessages(fullPath, "gamechat.json", warnings);
+ var mapImageBytes = ReadMapImageBytes(fullPath, warnings);
+
+ var playerCount = objects.Count(static o => o.IsPlayer);
+ var alliedAircraft = objects.Count(static o => o.IsAircraft && o.Kind is MapObjectKind.Ally or MapObjectKind.Squad or MapObjectKind.Player);
+ var enemyAircraft = objects.Count(static o => o.IsAircraft && o.Kind == MapObjectKind.Enemy);
+ var objectiveCount = objects.Count(static o => o.IsObjective);
+ var unknownCount = objects.Count(static o => o.Kind == MapObjectKind.Unknown);
+ var objectFields = CollectObjectFieldStats(objects, warnings);
+ var unknownSamples = CollectUnknownObjectSamples(objects);
+ var stateFieldNames = flightState.Values.Keys
+ .Order(StringComparer.Ordinal)
+ .Take(40)
+ .ToArray();
+
+ if (objects.Count == 0 && files["map_obj.json"])
+ {
+ warnings.Add("map_obj.json parsed successfully but did not contain any recognized objects.");
+ }
+
+ if (objects.Count > 0 && playerCount == 0)
+ {
+ warnings.Add("No player object was detected; follow/rotate/player panel behavior may need map_obj tuning.");
+ }
+
+ if (objects.Count > 0 && enemyAircraft == 0)
+ {
+ warnings.Add("No enemy aircraft were detected; spot radar tuning needs a richer capture.");
+ }
+
+ if (unknownCount > 0)
+ {
+ warnings.Add("Some map objects were not classified; inspect the unknown object samples and object field coverage.");
+ }
+
+ if (!HasMapBounds(mapInfo) && files["map_info.json"])
+ {
+ warnings.Add("Map bounds or grid size were not detected; projection may fall back to normalized coordinates.");
+ }
+
+ if (flightState.Values.Count == 0 && files["state.json"])
+ {
+ warnings.Add("state.json did not contain parseable telemetry fields.");
+ }
+
+ return new CaptureFixtureReport(
+ fullPath,
+ files,
+ mapInfo,
+ objects.Count,
+ playerCount,
+ alliedAircraft,
+ enemyAircraft,
+ objectiveCount,
+ unknownCount,
+ mapImageBytes,
+ flightState.Values.Count,
+ flightState.TrueAirspeedKmh.HasValue || flightState.IndicatedAirspeedKmh.HasValue,
+ flightState.AltitudeMeters.HasValue,
+ flightState.Mach,
+ hudMessages.Count,
+ chatMessages.Count,
+ objectFields,
+ unknownSamples,
+ stateFieldNames,
+ warnings);
+ }
+
+ private static MapInfo ReadMapInfo(string directory, List warnings)
+ {
+ var path = Path.Combine(directory, "map_info.json");
+ if (!File.Exists(path))
+ {
+ return MapInfo.Empty;
+ }
+
+ try
+ {
+ return MapInfo.FromJson(File.ReadAllText(path));
+ }
+ catch (Exception ex) when (ex is IOException or System.Text.Json.JsonException)
+ {
+ warnings.Add($"map_info.json could not be parsed: {ex.Message}");
+ return MapInfo.Empty;
+ }
+ }
+
+ private static IReadOnlyList ReadObjects(string directory, FlightState flightState, List warnings)
+ {
+ var path = Path.Combine(directory, "map_obj.json");
+ if (!File.Exists(path))
+ {
+ return Array.Empty();
+ }
+
+ try
+ {
+ return MapObject.FromJson(File.ReadAllText(path), new ObjectTracker(), flightState);
+ }
+ catch (Exception ex) when (ex is IOException or System.Text.Json.JsonException)
+ {
+ warnings.Add($"map_obj.json could not be parsed: {ex.Message}");
+ return Array.Empty();
+ }
+ }
+
+ private static FlightState ReadFlightState(string directory, List warnings)
+ {
+ var path = Path.Combine(directory, "state.json");
+ if (!File.Exists(path))
+ {
+ return FlightState.Empty;
+ }
+
+ try
+ {
+ return FlightState.FromJson(File.ReadAllText(path));
+ }
+ catch (IOException ex)
+ {
+ warnings.Add($"state.json could not be read: {ex.Message}");
+ return FlightState.Empty;
+ }
+ }
+
+ private static IReadOnlyList ReadMessages(string directory, string fileName, List warnings)
+ {
+ var path = Path.Combine(directory, fileName);
+ if (!File.Exists(path))
+ {
+ return Array.Empty();
+ }
+
+ try
+ {
+ return BattleMessage.FromJson(File.ReadAllText(path));
+ }
+ catch (IOException ex)
+ {
+ warnings.Add($"{fileName} could not be read: {ex.Message}");
+ return Array.Empty();
+ }
+ }
+
+ private static long? ReadMapImageBytes(string directory, List warnings)
+ {
+ var path = Path.Combine(directory, "map.img");
+ if (!File.Exists(path))
+ {
+ return null;
+ }
+
+ try
+ {
+ return new FileInfo(path).Length;
+ }
+ catch (IOException ex)
+ {
+ warnings.Add($"map.img could not be inspected: {ex.Message}");
+ return null;
+ }
+ }
+
+ private static bool HasMapBounds(MapInfo info)
+ {
+ return info.MinX.HasValue && info.MaxX.HasValue && info.MinY.HasValue && info.MaxY.HasValue;
+ }
+
+ private static IReadOnlyList CollectObjectFieldStats(IReadOnlyList objects, List warnings)
+ {
+ var counts = new Dictionary(StringComparer.Ordinal);
+
+ foreach (var obj in objects)
+ {
+ try
+ {
+ using var document = JsonDocument.Parse(obj.RawJson);
+ if (document.RootElement.ValueKind != JsonValueKind.Object)
+ {
+ continue;
+ }
+
+ foreach (var property in document.RootElement.EnumerateObject())
+ {
+ counts[property.Name] = counts.TryGetValue(property.Name, out var count) ? count + 1 : 1;
+ }
+ }
+ catch (JsonException ex)
+ {
+ warnings.Add($"Could not inspect raw object fields for {obj.Id}: {ex.Message}");
+ }
+ }
+
+ return counts
+ .OrderByDescending(static item => item.Value)
+ .ThenBy(static item => item.Key, StringComparer.Ordinal)
+ .Select(static item => new CaptureFieldStat(item.Key, item.Value))
+ .ToArray();
+ }
+
+ private static IReadOnlyList CollectUnknownObjectSamples(IReadOnlyList objects)
+ {
+ return objects
+ .Where(static o => o.Kind == MapObjectKind.Unknown)
+ .Take(8)
+ .Select(SummarizeObject)
+ .ToArray();
+ }
+
+ private static string SummarizeObject(MapObject obj)
+ {
+ var parts = new List { $"id={obj.Id}" };
+
+ AddPart(parts, "name", obj.Name);
+ AddPart(parts, "type", obj.Type);
+ AddPart(parts, "icon", obj.Icon);
+ AddPart(parts, "team", obj.Team);
+ AddPart(parts, "color", obj.Color);
+
+ if (obj.X.HasValue && obj.Y.HasValue)
+ {
+ parts.Add($"xy={obj.X.Value.ToString("0.###", CultureInfo.InvariantCulture)},{obj.Y.Value.ToString("0.###", CultureInfo.InvariantCulture)}");
+ }
+
+ return string.Join(" ", parts);
+ }
+
+ private static void AddPart(List parts, string name, string? value)
+ {
+ if (!string.IsNullOrWhiteSpace(value))
+ {
+ parts.Add($"{name}={value}");
+ }
+ }
+}
+
+public sealed record CaptureFieldStat(string Name, int Count);
+
+public sealed class CaptureFixtureReport
+{
+ public CaptureFixtureReport(
+ string directory,
+ IReadOnlyDictionary files,
+ MapInfo mapInfo,
+ int objectCount,
+ int playerCount,
+ int alliedAircraftCount,
+ int enemyAircraftCount,
+ int objectiveCount,
+ int unknownObjectCount,
+ long? mapImageBytes,
+ int stateFieldCount,
+ bool hasSpeedTelemetry,
+ bool hasAltitudeTelemetry,
+ double? mach,
+ int hudMessageCount,
+ int chatMessageCount,
+ IReadOnlyList objectFields,
+ IReadOnlyList unknownObjectSamples,
+ IReadOnlyList stateFieldNames,
+ IReadOnlyList warnings)
+ {
+ Directory = directory;
+ Files = files;
+ MapInfo = mapInfo;
+ ObjectCount = objectCount;
+ PlayerCount = playerCount;
+ AlliedAircraftCount = alliedAircraftCount;
+ EnemyAircraftCount = enemyAircraftCount;
+ ObjectiveCount = objectiveCount;
+ UnknownObjectCount = unknownObjectCount;
+ MapImageBytes = mapImageBytes;
+ StateFieldCount = stateFieldCount;
+ HasSpeedTelemetry = hasSpeedTelemetry;
+ HasAltitudeTelemetry = hasAltitudeTelemetry;
+ Mach = mach;
+ HudMessageCount = hudMessageCount;
+ ChatMessageCount = chatMessageCount;
+ ObjectFields = objectFields;
+ UnknownObjectSamples = unknownObjectSamples;
+ StateFieldNames = stateFieldNames;
+ Warnings = warnings;
+ }
+
+ public string Directory { get; }
+ public IReadOnlyDictionary Files { get; }
+ public MapInfo MapInfo { get; }
+ public int ObjectCount { get; }
+ public int PlayerCount { get; }
+ public int AlliedAircraftCount { get; }
+ public int EnemyAircraftCount { get; }
+ public int ObjectiveCount { get; }
+ public int UnknownObjectCount { get; }
+ public long? MapImageBytes { get; }
+ public int StateFieldCount { get; }
+ public bool HasSpeedTelemetry { get; }
+ public bool HasAltitudeTelemetry { get; }
+ public double? Mach { get; }
+ public int HudMessageCount { get; }
+ public int ChatMessageCount { get; }
+ public IReadOnlyList ObjectFields { get; }
+ public IReadOnlyList UnknownObjectSamples { get; }
+ public IReadOnlyList StateFieldNames { get; }
+ public IReadOnlyList Warnings { get; }
+ public bool HasRequiredFiles => Files.Where(static item => item.Key is "map_info.json" or "map_obj.json" or "map.img").All(static item => item.Value);
+ public bool IsUsableForReplay => HasRequiredFiles && ObjectCount > 0 && MapImageBytes.GetValueOrDefault() > 0;
+
+ public string ToText()
+ {
+ var lines = new List
+ {
+ "Ehwrj capture quality report",
+ $"Generated at: {DateTimeOffset.Now:O}",
+ $"Directory: {Directory}",
+ "",
+ "Files:"
+ };
+
+ foreach (var file in Files.OrderBy(static item => item.Key, StringComparer.Ordinal))
+ {
+ lines.Add($"- {file.Key}: {(file.Value ? "present" : "missing")}");
+ }
+
+ lines.Add("");
+ lines.Add("Map:");
+ lines.Add($"- bounds: {FormatBounds(MapInfo)}");
+ lines.Add($"- grid step: {FormatNumber(MapInfo.GridStepX)} x {FormatNumber(MapInfo.GridStepY)}");
+ lines.Add($"- map image bytes: {MapImageBytes?.ToString(CultureInfo.InvariantCulture) ?? "missing"}");
+
+ lines.Add("");
+ lines.Add("Objects:");
+ lines.Add($"- total: {ObjectCount}");
+ lines.Add($"- player: {PlayerCount}");
+ lines.Add($"- allied aircraft: {AlliedAircraftCount}");
+ lines.Add($"- enemy aircraft: {EnemyAircraftCount}");
+ lines.Add($"- objectives: {ObjectiveCount}");
+ lines.Add($"- unknown: {UnknownObjectCount}");
+
+ lines.Add("");
+ lines.Add("Object field coverage:");
+ if (ObjectFields.Count == 0)
+ {
+ lines.Add("- none");
+ }
+ else
+ {
+ foreach (var field in ObjectFields.Take(40))
+ {
+ lines.Add($"- {field.Name}: {field.Count}");
+ }
+ }
+
+ lines.Add("");
+ lines.Add("Unknown object samples:");
+ if (UnknownObjectSamples.Count == 0)
+ {
+ lines.Add("- none");
+ }
+ else
+ {
+ foreach (var sample in UnknownObjectSamples)
+ {
+ lines.Add($"- {sample}");
+ }
+ }
+
+ lines.Add("");
+ lines.Add("Telemetry:");
+ lines.Add($"- state fields: {StateFieldCount}");
+ lines.Add($"- speed telemetry: {FormatBool(HasSpeedTelemetry)}");
+ lines.Add($"- altitude telemetry: {FormatBool(HasAltitudeTelemetry)}");
+ lines.Add($"- derived Mach: {FormatNumber(Mach)}");
+
+ lines.Add("");
+ lines.Add("State field names:");
+ if (StateFieldNames.Count == 0)
+ {
+ lines.Add("- none");
+ }
+ else
+ {
+ foreach (var field in StateFieldNames)
+ {
+ lines.Add($"- {field}");
+ }
+ }
+
+ lines.Add("");
+ lines.Add("Messages:");
+ lines.Add($"- HUD messages: {HudMessageCount}");
+ lines.Add($"- game chat messages: {ChatMessageCount}");
+
+ lines.Add("");
+ lines.Add($"Replay usable: {FormatBool(IsUsableForReplay)}");
+ lines.Add($"Warnings: {Warnings.Count}");
+ foreach (var warning in Warnings)
+ {
+ lines.Add($"- {warning}");
+ }
+
+ return string.Join(Environment.NewLine, lines) + Environment.NewLine;
+ }
+
+ private static string FormatBounds(MapInfo info)
+ {
+ if (!info.MinX.HasValue || !info.MaxX.HasValue || !info.MinY.HasValue || !info.MaxY.HasValue)
+ {
+ return "unknown";
+ }
+
+ return $"{FormatNumber(info.MinX)}..{FormatNumber(info.MaxX)}, {FormatNumber(info.MinY)}..{FormatNumber(info.MaxY)}";
+ }
+
+ private static string FormatBool(bool value) => value ? "yes" : "no";
+
+ private static string FormatNumber(double? value)
+ {
+ return value.HasValue
+ ? value.Value.ToString("0.###", CultureInfo.InvariantCulture)
+ : "unknown";
+ }
+}
diff --git a/src/Ehwrj.Core/Services/LoopbackGuard.cs b/src/Ehwrj.Core/Services/LoopbackGuard.cs
new file mode 100644
index 0000000..72a5e5f
--- /dev/null
+++ b/src/Ehwrj.Core/Services/LoopbackGuard.cs
@@ -0,0 +1,37 @@
+using System.Net;
+
+namespace Ehwrj.Core.Services;
+
+public static class LoopbackGuard
+{
+ public static void EnsureLoopbackHttp(Uri uri, string parameterName)
+ {
+ if (!IsLoopbackHttp(uri))
+ {
+ throw new ArgumentException("Only HTTP loopback addresses are allowed.", parameterName);
+ }
+ }
+
+ public static bool IsLoopbackHttp(Uri uri)
+ {
+ return uri.IsAbsoluteUri &&
+ string.Equals(uri.Scheme, Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase) &&
+ uri.IsLoopback;
+ }
+
+ public static bool IsLoopbackHost(string host)
+ {
+ if (string.IsNullOrWhiteSpace(host))
+ {
+ return false;
+ }
+
+ if (string.Equals(host, "localhost", StringComparison.OrdinalIgnoreCase))
+ {
+ return true;
+ }
+
+ return IPAddress.TryParse(host, out var address) && IPAddress.IsLoopback(address);
+ }
+}
+
diff --git a/src/Ehwrj.Core/Services/ProcessProbe.cs b/src/Ehwrj.Core/Services/ProcessProbe.cs
new file mode 100644
index 0000000..830e86e
--- /dev/null
+++ b/src/Ehwrj.Core/Services/ProcessProbe.cs
@@ -0,0 +1,18 @@
+using System.Diagnostics;
+
+namespace Ehwrj.Core.Services;
+
+public sealed class ProcessProbe
+{
+ public bool IsWarThunderRunning()
+ {
+ try
+ {
+ return Process.GetProcessesByName("aces").Length > 0;
+ }
+ catch
+ {
+ return false;
+ }
+ }
+}
diff --git a/src/Ehwrj.Core/Services/WarThunderClient.cs b/src/Ehwrj.Core/Services/WarThunderClient.cs
new file mode 100644
index 0000000..1b23095
--- /dev/null
+++ b/src/Ehwrj.Core/Services/WarThunderClient.cs
@@ -0,0 +1,104 @@
+namespace Ehwrj.Core.Services;
+
+public interface IWarThunderClient
+{
+ Task GetMapInfoAsync(CancellationToken cancellationToken);
+ Task GetObjectsAsync(CancellationToken cancellationToken);
+ Task GetMapImageAsync(CancellationToken cancellationToken);
+ Task TryGetStateAsync(CancellationToken cancellationToken);
+ Task TryGetHudMessagesAsync(CancellationToken cancellationToken);
+ Task TryGetGameChatAsync(CancellationToken cancellationToken);
+}
+
+public sealed class WarThunderClient : IWarThunderClient, IDisposable
+{
+ private readonly HttpClient _httpClient;
+ private readonly bool _ownsClient;
+
+ public WarThunderClient()
+ : this(new Uri("http://127.0.0.1:8111/"))
+ {
+ }
+
+ public WarThunderClient(Uri baseAddress)
+ : this(new HttpClient
+ {
+ BaseAddress = baseAddress,
+ Timeout = TimeSpan.FromMilliseconds(900)
+ }, ownsClient: true)
+ {
+ }
+
+ public WarThunderClient(HttpClient httpClient, bool ownsClient = false)
+ {
+ _httpClient = httpClient;
+ _ownsClient = ownsClient;
+
+ if (_httpClient.BaseAddress is null)
+ {
+ _httpClient.BaseAddress = new Uri("http://127.0.0.1:8111/");
+ }
+
+ LoopbackGuard.EnsureLoopbackHttp(_httpClient.BaseAddress, nameof(httpClient));
+ }
+
+ public Task GetMapInfoAsync(CancellationToken cancellationToken)
+ {
+ return _httpClient.GetStringAsync("map_info.json", cancellationToken);
+ }
+
+ public Task GetObjectsAsync(CancellationToken cancellationToken)
+ {
+ return _httpClient.GetStringAsync("map_obj.json", cancellationToken);
+ }
+
+ public Task GetMapImageAsync(CancellationToken cancellationToken)
+ {
+ return _httpClient.GetByteArrayAsync("map.img", cancellationToken);
+ }
+
+ public Task TryGetStateAsync(CancellationToken cancellationToken)
+ {
+ return TryGetStringAsync("state", cancellationToken);
+ }
+
+ public Task TryGetHudMessagesAsync(CancellationToken cancellationToken)
+ {
+ return TryGetStringAsync("hudmsg", cancellationToken);
+ }
+
+ public Task TryGetGameChatAsync(CancellationToken cancellationToken)
+ {
+ return TryGetStringAsync("gamechat", cancellationToken);
+ }
+
+ private async Task TryGetStringAsync(string path, CancellationToken cancellationToken)
+ {
+ try
+ {
+ using var response = await _httpClient.GetAsync(path, cancellationToken).ConfigureAwait(false);
+ if (!response.IsSuccessStatusCode)
+ {
+ return null;
+ }
+
+ return await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
+ }
+ catch (HttpRequestException)
+ {
+ return null;
+ }
+ catch (TaskCanceledException) when (!cancellationToken.IsCancellationRequested)
+ {
+ return null;
+ }
+ }
+
+ public void Dispose()
+ {
+ if (_ownsClient)
+ {
+ _httpClient.Dispose();
+ }
+ }
+}
diff --git a/tests/Ehwrj.Tests/Ehwrj.Tests.csproj b/tests/Ehwrj.Tests/Ehwrj.Tests.csproj
new file mode 100644
index 0000000..3bb77d6
--- /dev/null
+++ b/tests/Ehwrj.Tests/Ehwrj.Tests.csproj
@@ -0,0 +1,12 @@
+
+
+ Exe
+ net8.0
+ Ehwrj.Tests
+
+
+
+
+
+
+
diff --git a/tests/Ehwrj.Tests/Program.cs b/tests/Ehwrj.Tests/Program.cs
new file mode 100644
index 0000000..7de1645
--- /dev/null
+++ b/tests/Ehwrj.Tests/Program.cs
@@ -0,0 +1,322 @@
+using Ehwrj.App.Models;
+using Ehwrj.Core.Geometry;
+using Ehwrj.Core.Models;
+using Ehwrj.Core.Services;
+
+namespace Ehwrj.Tests;
+
+internal static class Program
+{
+ private static int Main()
+ {
+ try
+ {
+ ParsesMapInfoGridSize();
+ ParsesMapInfoBoundsAndStringNumbers();
+ ParsesObjectsAndClassifiesAircraft();
+ ParsesObjectCoordinateAndDirectionArrays();
+ ClassifiesTeamsAndObjectives();
+ ParsesHeadingInDegrees();
+ ParsesFlightState();
+ ParsesBattleMessages();
+ AnalyzesCaptureFixtureQuality();
+ EnforcesLoopbackNetworkScope();
+ LocalizesUiTextAndStatus();
+ ParsesWrappedObjectList();
+ ProjectsNormalizedCoordinates();
+ ProjectsBoundedCoordinates();
+ Console.WriteLine("All tests passed.");
+ return 0;
+ }
+ catch (Exception ex)
+ {
+ Console.Error.WriteLine(ex.Message);
+ return 1;
+ }
+ }
+
+ private static void ParsesMapInfoGridSize()
+ {
+ var info = MapInfo.FromJson("""{"grid_size":[10,20],"grid_steps":[1,2]}""");
+ AssertEqual(0, info.MinX, "MinX");
+ AssertEqual(10, info.MaxX, "MaxX");
+ AssertEqual(0, info.MinY, "MinY");
+ AssertEqual(20, info.MaxY, "MaxY");
+ AssertEqual(1, info.GridStepX, "GridStepX");
+ AssertEqual(2, info.GridStepY, "GridStepY");
+ }
+
+ private static void ParsesMapInfoBoundsAndStringNumbers()
+ {
+ var info = MapInfo.FromJson(
+ """
+ {
+ "bounds": [["-1,5","-2.25"],["3,5","4.25"]],
+ "grid_steps": ["0,5","1.25"]
+ }
+ """);
+
+ AssertEqual(-1.5, info.MinX, "bounds MinX");
+ AssertEqual(3.5, info.MaxX, "bounds MaxX");
+ AssertEqual(-2.25, info.MinY, "bounds MinY");
+ AssertEqual(4.25, info.MaxY, "bounds MaxY");
+ AssertEqual(0.5, info.GridStepX, "string GridStepX");
+ AssertEqual(1.25, info.GridStepY, "string GridStepY");
+ }
+
+ private static void ParsesObjectsAndClassifiesAircraft()
+ {
+ var tracker = new ObjectTracker();
+ var objects = MapObject.FromJson(
+ """
+ [
+ {"id":"self","type":"player_aircraft","x":0.5,"y":0.5},
+ {"id":"bandit","type":"aircraft","x":0.6,"y":0.6},
+ {"id":"zone","type":"capture_zone","x":0.2,"y":0.3}
+ ]
+ """,
+ tracker);
+
+ AssertEqual(3, objects.Count, "object count");
+ AssertTrue(objects[0].IsPlayer, "player classification");
+ AssertTrue(objects[1].IsAircraft, "aircraft classification");
+ AssertTrue(!objects[2].IsAircraft, "zone classification");
+ }
+
+ private static void ParsesObjectCoordinateAndDirectionArrays()
+ {
+ var tracker = new ObjectTracker();
+ var objects = MapObject.FromJson(
+ """
+ [
+ {
+ "id":"array-bandit",
+ "icon":"fighter",
+ "team":"enemy",
+ "pos":["0,25","0.75"],
+ "direction":["1","0"],
+ "heading":"180"
+ }
+ ]
+ """,
+ tracker);
+
+ AssertEqual(0.25, objects[0].X, "array x");
+ AssertEqual(0.75, objects[0].Y, "array y");
+ AssertEqual(1, objects[0].DirectionX, "array direction x");
+ AssertEqual(0, objects[0].DirectionY, "array direction y");
+ AssertEqual(Math.PI, objects[0].HeadingRadians.GetValueOrDefault(), "string heading radians");
+ AssertEqual((int)MapObjectKind.Enemy, (int)objects[0].Kind, "array enemy kind");
+ }
+
+ private static void ClassifiesTeamsAndObjectives()
+ {
+ var tracker = new ObjectTracker();
+ var objects = MapObject.FromJson(
+ """
+ [
+ {"id":"ally","type":"aircraft","team":"ally","x":0.1,"y":0.2},
+ {"id":"enemy","type":"aircraft","team":"enemy","x":0.2,"y":0.3},
+ {"id":"squad","type":"aircraft","team":"squad","x":0.3,"y":0.4},
+ {"id":"target","type":"bombing_point","team":"enemy","x":0.4,"y":0.5}
+ ]
+ """,
+ tracker);
+
+ AssertEqual((int)MapObjectKind.Ally, (int)objects[0].Kind, "ally kind");
+ AssertEqual((int)MapObjectKind.Enemy, (int)objects[1].Kind, "enemy kind");
+ AssertEqual((int)MapObjectKind.Squad, (int)objects[2].Kind, "squad kind");
+ AssertTrue(objects[3].IsEnemyBombingPoint, "enemy bombing point");
+ }
+
+ private static void ParsesHeadingInDegrees()
+ {
+ var tracker = new ObjectTracker();
+ var objects = MapObject.FromJson("""[{"id":"p","type":"player_aircraft","heading":90,"x":0.5,"y":0.5}]""", tracker);
+ AssertTrue(objects[0].HeadingRadians.HasValue, "heading exists");
+ AssertEqual(Math.PI / 2, objects[0].HeadingRadians.GetValueOrDefault(), "heading radians");
+ }
+
+ private static void ParsesFlightState()
+ {
+ var state = FlightState.FromJson("""{"TAS, km/h":720,"IAS, km/h":"650","Vy, m/s":36,"H, m":2500}""");
+ AssertEqual(720, state.TrueAirspeedKmh, "tas");
+ AssertEqual(650, state.IndicatedAirspeedKmh, "ias");
+ AssertTrue(state.Mach.HasValue, "mach");
+ AssertTrue(state.ClimbAngleDegrees.HasValue, "climb angle");
+ }
+
+ private static void ParsesBattleMessages()
+ {
+ var messages = BattleMessage.FromJson(
+ """
+ {
+ "events": [
+ {"text":"Enemy spotted","enemy":true},
+ {"message":"Ally captured A","enemy":false}
+ ]
+ }
+ """);
+
+ AssertEqual(2, messages.Count, "message count");
+ AssertEqual("Enemy spotted", messages[0].Text, "cleaned message");
+ AssertTrue(messages[0].Enemy == true, "enemy flag");
+ AssertTrue(messages[1].Enemy == false, "ally flag");
+ }
+
+ private static void AnalyzesCaptureFixtureQuality()
+ {
+ var directory = Path.Combine(Path.GetTempPath(), $"ehwrj-capture-test-{Guid.NewGuid():N}");
+ Directory.CreateDirectory(directory);
+
+ try
+ {
+ File.WriteAllText(Path.Combine(directory, "map_info.json"), """{"grid_size":[10,20],"grid_steps":[1,2]}""");
+ File.WriteAllText(
+ Path.Combine(directory, "map_obj.json"),
+ """
+ [
+ {"id":"self","type":"player_aircraft","team":"ally","x":0.5,"y":0.5},
+ {"id":"wing","type":"aircraft","team":"ally","x":0.4,"y":0.5},
+ {"id":"bandit","type":"aircraft","team":"enemy","x":0.6,"y":0.5},
+ {"id":"target","type":"bombing_point","team":"enemy","x":0.7,"y":0.5},
+ {"id":"mystery","type":"supply_marker","x":0.2,"y":0.8,"custom_flag":"sample"}
+ ]
+ """);
+ File.WriteAllBytes(Path.Combine(directory, "map.img"), [1, 2, 3, 4]);
+ File.WriteAllText(Path.Combine(directory, "state.json"), """{"TAS, km/h":720,"H, m":2500}""");
+ File.WriteAllText(Path.Combine(directory, "hudmsg.json"), """{"events":[{"text":"Enemy spotted","enemy":true}]}""");
+ File.WriteAllText(Path.Combine(directory, "gamechat.json"), """["Good luck"]""");
+
+ var report = CaptureFixtureAnalyzer.AnalyzeDirectory(directory);
+ AssertTrue(report.IsUsableForReplay, "capture replay usable");
+ AssertEqual(5, report.ObjectCount, "capture object count");
+ AssertEqual(1, report.PlayerCount, "capture player count");
+ AssertEqual(2, report.AlliedAircraftCount, "capture allied aircraft count");
+ AssertEqual(1, report.EnemyAircraftCount, "capture enemy aircraft count");
+ AssertEqual(1, report.ObjectiveCount, "capture objective count");
+ AssertEqual(1, report.UnknownObjectCount, "capture unknown object count");
+ AssertTrue(report.ObjectFields.Any(static f => f.Name == "custom_flag" && f.Count == 1), "capture object field coverage");
+ AssertTrue(report.UnknownObjectSamples.Any(static s => s.Contains("supply_marker", StringComparison.Ordinal)), "capture unknown sample");
+ AssertTrue(report.StateFieldNames.Contains("TAS, km/h", StringComparer.Ordinal), "capture state field names");
+ AssertTrue(report.HasSpeedTelemetry, "capture speed telemetry");
+ AssertTrue(report.HasAltitudeTelemetry, "capture altitude telemetry");
+ AssertEqual(1, report.HudMessageCount, "capture hud messages");
+ AssertEqual(1, report.ChatMessageCount, "capture chat messages");
+ AssertTrue(report.ToText().Contains("Replay usable: yes", StringComparison.Ordinal), "capture report text");
+ AssertTrue(report.ToText().Contains("Object field coverage:", StringComparison.Ordinal), "capture field report text");
+ }
+ finally
+ {
+ Directory.Delete(directory, recursive: true);
+ }
+ }
+
+ private static void EnforcesLoopbackNetworkScope()
+ {
+ AssertTrue(LoopbackGuard.IsLoopbackHttp(new Uri("http://127.0.0.1:8111/")), "127 loopback allowed");
+ AssertTrue(LoopbackGuard.IsLoopbackHttp(new Uri("http://localhost:8111/")), "localhost allowed");
+ AssertTrue(LoopbackGuard.IsLoopbackHost("127.0.0.1"), "127 host allowed");
+ AssertTrue(LoopbackGuard.IsLoopbackHost("localhost"), "localhost host allowed");
+ AssertTrue(!LoopbackGuard.IsLoopbackHttp(new Uri("https://127.0.0.1:8111/")), "https rejected");
+ AssertTrue(!LoopbackGuard.IsLoopbackHttp(new Uri("http://example.com:8111/")), "external host rejected");
+ AssertTrue(!LoopbackGuard.IsLoopbackHost("0.0.0.0"), "wildcard host rejected");
+ }
+
+ private static void LocalizesUiTextAndStatus()
+ {
+ var english = UiText.For("en-US");
+ var korean = UiText.For("ko-KR");
+
+ AssertEqual("Language", english.Language, "english language label");
+ AssertEqual("언어", korean.Language, "korean language label");
+ AssertEqual("127.0.0.1:8111 연결됨", korean.FormatStatus("Connected to 127.0.0.1:8111"), "localized connected status");
+ AssertTrue(UiText.LanguageOptions.Any(static o => o.Code == "ko-KR"), "korean language option");
+ }
+
+ private static void ProjectsNormalizedCoordinates()
+ {
+ var point = CoordinateProjector.Project(
+ new MapObject { Id = "p", X = 0.25, Y = 0.75 },
+ MapInfo.Empty,
+ new MapViewport(0, 0, 400, 200));
+
+ AssertTrue(point.HasValue, "projection exists");
+ var projected = point.GetValueOrDefault();
+ AssertEqual(100, projected.X, "projected x");
+ AssertEqual(150, projected.Y, "projected y");
+ }
+
+ private static void ParsesWrappedObjectList()
+ {
+ var tracker = new ObjectTracker();
+ var objects = MapObject.FromJson(
+ """
+ {
+ "objects": [
+ {"id":"a","icon":"fighter","x":"0.1","y":"0.9"}
+ ]
+ }
+ """,
+ tracker);
+
+ AssertEqual(1, objects.Count, "wrapped object count");
+ AssertTrue(objects[0].IsAircraft, "wrapped aircraft classification");
+ AssertEqual(0.1, objects[0].X, "wrapped x");
+ AssertEqual(0.9, objects[0].Y, "wrapped y");
+ }
+
+ private static void ProjectsBoundedCoordinates()
+ {
+ var info = new MapInfo(-100, 100, -50, 50, null, null, null);
+ var point = CoordinateProjector.Project(
+ new MapObject { Id = "p", X = 0, Y = 25 },
+ info,
+ new MapViewport(10, 20, 200, 100));
+
+ AssertTrue(point.HasValue, "bounded projection exists");
+ var projected = point.GetValueOrDefault();
+ AssertEqual(110, projected.X, "bounded x");
+ AssertEqual(95, projected.Y, "bounded y");
+ }
+
+ private static void AssertTrue(bool condition, string name)
+ {
+ if (!condition)
+ {
+ throw new InvalidOperationException($"Assertion failed: {name}");
+ }
+ }
+
+ private static void AssertEqual(double expected, double? actual, string name)
+ {
+ if (!actual.HasValue || Math.Abs(expected - actual.Value) > 0.0001)
+ {
+ throw new InvalidOperationException($"Assertion failed: {name}; expected {expected}, got {actual}");
+ }
+ }
+
+ private static void AssertEqual(double expected, double actual, string name)
+ {
+ if (Math.Abs(expected - actual) > 0.0001)
+ {
+ throw new InvalidOperationException($"Assertion failed: {name}; expected {expected}, got {actual}");
+ }
+ }
+
+ private static void AssertEqual(int expected, int actual, string name)
+ {
+ if (expected != actual)
+ {
+ throw new InvalidOperationException($"Assertion failed: {name}; expected {expected}, got {actual}");
+ }
+ }
+
+ private static void AssertEqual(string expected, string actual, string name)
+ {
+ if (!string.Equals(expected, actual, StringComparison.Ordinal))
+ {
+ throw new InvalidOperationException($"Assertion failed: {name}; expected {expected}, got {actual}");
+ }
+ }
+}
diff --git a/tools/Ehwrj.Tools.Capture/Ehwrj.Tools.Capture.csproj b/tools/Ehwrj.Tools.Capture/Ehwrj.Tools.Capture.csproj
new file mode 100644
index 0000000..7db5c87
--- /dev/null
+++ b/tools/Ehwrj.Tools.Capture/Ehwrj.Tools.Capture.csproj
@@ -0,0 +1,11 @@
+
+
+ Exe
+ net8.0
+ Ehwrj.Tools.Capture
+
+
+
+
+
+
diff --git a/tools/Ehwrj.Tools.Capture/Program.cs b/tools/Ehwrj.Tools.Capture/Program.cs
new file mode 100644
index 0000000..29f256b
--- /dev/null
+++ b/tools/Ehwrj.Tools.Capture/Program.cs
@@ -0,0 +1,202 @@
+using System.Globalization;
+using System.Net.Http.Headers;
+using Ehwrj.Core.Services;
+
+namespace Ehwrj.Tools.Capture;
+
+internal static class Program
+{
+ private static readonly Endpoint[] Endpoints =
+ {
+ new("map_info.json", "map_info.json", Required: true),
+ new("map_obj.json", "map_obj.json", Required: true),
+ new("map.img", "map.img", Required: true),
+ new("state", "state.json", Required: false),
+ new("hudmsg", "hudmsg.json", Required: false),
+ new("gamechat", "gamechat.json", Required: false)
+ };
+
+ private static async Task Main(string[] args)
+ {
+ var options = CaptureOptions.Parse(args);
+ if (options.ShowHelp)
+ {
+ CaptureOptions.PrintHelp();
+ return 0;
+ }
+
+ if (!string.IsNullOrWhiteSpace(options.ValidateDirectory))
+ {
+ return WriteCaptureReport(options.ValidateDirectory);
+ }
+
+ var captureDir = options.OutputDirectory ?? Path.Combine(
+ "captures",
+ DateTimeOffset.Now.ToString("yyyyMMdd-HHmmss", CultureInfo.InvariantCulture));
+
+ try
+ {
+ LoopbackGuard.EnsureLoopbackHttp(options.BaseAddress, "--base-url");
+ }
+ catch (ArgumentException ex)
+ {
+ Console.Error.WriteLine($"error: {ex.Message}");
+ return 1;
+ }
+
+ Directory.CreateDirectory(captureDir);
+
+ using var client = new HttpClient
+ {
+ BaseAddress = options.BaseAddress,
+ Timeout = TimeSpan.FromMilliseconds(options.TimeoutMs)
+ };
+ client.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("EhwrjCapture", "1.0"));
+
+ Console.WriteLine($"Capturing War Thunder local API from {client.BaseAddress}");
+ Console.WriteLine($"Output: {Path.GetFullPath(captureDir)}");
+
+ var failures = 0;
+ foreach (var endpoint in Endpoints)
+ {
+ var ok = await CaptureEndpointAsync(client, endpoint, captureDir).ConfigureAwait(false);
+ if (!ok && endpoint.Required)
+ {
+ failures++;
+ }
+ }
+
+ File.WriteAllText(
+ Path.Combine(captureDir, "README.txt"),
+ $"""
+ Ehwrj local API capture
+ Captured at: {DateTimeOffset.Now:O}
+ Source: {client.BaseAddress}
+
+ Files:
+ - map_info.json
+ - map_obj.json
+ - map.img
+ - state.json, hudmsg.json, gamechat.json when available
+
+ Use with:
+ scripts/run-local-api-stub.sh 8111 {Path.GetFullPath(captureDir)}
+ """);
+
+ WriteCaptureReport(captureDir);
+
+ if (failures > 0)
+ {
+ Console.Error.WriteLine($"{failures} required endpoint(s) failed.");
+ return 1;
+ }
+
+ Console.WriteLine("Capture complete.");
+ return 0;
+ }
+
+ private static int WriteCaptureReport(string captureDir)
+ {
+ if (!Directory.Exists(captureDir))
+ {
+ Console.Error.WriteLine($"error: capture directory not found: {captureDir}");
+ return 1;
+ }
+
+ var report = CaptureFixtureAnalyzer.AnalyzeDirectory(captureDir);
+ var reportPath = Path.Combine(report.Directory, "capture-report.txt");
+ File.WriteAllText(reportPath, report.ToText());
+ Console.WriteLine($"report: {reportPath}");
+
+ if (report.Warnings.Count > 0)
+ {
+ Console.WriteLine($"{report.Warnings.Count} capture warning(s); see capture-report.txt.");
+ }
+
+ return report.HasRequiredFiles ? 0 : 1;
+ }
+
+ private static async Task CaptureEndpointAsync(HttpClient client, Endpoint endpoint, string captureDir)
+ {
+ try
+ {
+ using var response = await client.GetAsync(endpoint.Path).ConfigureAwait(false);
+ if (!response.IsSuccessStatusCode)
+ {
+ var level = endpoint.Required ? "error" : "skip";
+ Console.WriteLine($"{level}: {endpoint.Path} returned {(int)response.StatusCode}");
+ return false;
+ }
+
+ var bytes = await response.Content.ReadAsByteArrayAsync().ConfigureAwait(false);
+ var outputPath = Path.Combine(captureDir, endpoint.FileName);
+ await File.WriteAllBytesAsync(outputPath, bytes).ConfigureAwait(false);
+ Console.WriteLine($"saved: {endpoint.Path} -> {outputPath} ({bytes.Length} bytes)");
+ return true;
+ }
+ catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException)
+ {
+ var level = endpoint.Required ? "error" : "skip";
+ Console.WriteLine($"{level}: {endpoint.Path} failed: {ex.Message}");
+ return false;
+ }
+ }
+}
+
+internal sealed record Endpoint(string Path, string FileName, bool Required);
+
+internal sealed record CaptureOptions(Uri BaseAddress, string? OutputDirectory, string? ValidateDirectory, int TimeoutMs, bool ShowHelp)
+{
+ public static CaptureOptions Parse(IReadOnlyList args)
+ {
+ var baseAddress = new Uri("http://127.0.0.1:8111/");
+ string? output = null;
+ string? validate = null;
+ var timeoutMs = 1200;
+
+ for (var i = 0; i < args.Count; i++)
+ {
+ switch (args[i])
+ {
+ case "-h":
+ case "--help":
+ return new CaptureOptions(baseAddress, output, validate, timeoutMs, ShowHelp: true);
+ case "--base-url" when i + 1 < args.Count && Uri.TryCreate(args[++i], UriKind.Absolute, out var uri):
+ baseAddress = uri;
+ break;
+ case "--out" when i + 1 < args.Count:
+ output = args[++i];
+ break;
+ case "--validate" when i + 1 < args.Count:
+ validate = args[++i];
+ break;
+ case "--timeout-ms" when i + 1 < args.Count && int.TryParse(args[++i], NumberStyles.None, CultureInfo.InvariantCulture, out var parsed):
+ timeoutMs = Math.Clamp(parsed, 200, 10_000);
+ break;
+ default:
+ Console.Error.WriteLine($"Ignoring unknown argument: {args[i]}");
+ break;
+ }
+ }
+
+ return new CaptureOptions(EnsureTrailingSlash(baseAddress), output, validate, timeoutMs, ShowHelp: false);
+ }
+
+ public static void PrintHelp()
+ {
+ Console.WriteLine("""
+ Usage:
+ dotnet run --project tools/Ehwrj.Tools.Capture -- [--base-url http://127.0.0.1:8111/] [--out captures/name] [--timeout-ms 1200]
+ dotnet run --project tools/Ehwrj.Tools.Capture -- --validate captures/name
+
+ Captures War Thunder local map, telemetry, and message endpoints into a fixture directory.
+ Writes capture-report.txt with parser coverage and replay readiness.
+ """);
+ }
+
+ private static Uri EnsureTrailingSlash(Uri uri)
+ {
+ var text = uri.ToString();
+ return text.EndsWith('/') ? uri : new Uri($"{text}/");
+ }
+}
diff --git a/tools/Ehwrj.Tools.LocalApiStub/Ehwrj.Tools.LocalApiStub.csproj b/tools/Ehwrj.Tools.LocalApiStub/Ehwrj.Tools.LocalApiStub.csproj
new file mode 100644
index 0000000..ad05f9b
--- /dev/null
+++ b/tools/Ehwrj.Tools.LocalApiStub/Ehwrj.Tools.LocalApiStub.csproj
@@ -0,0 +1,11 @@
+
+
+ Exe
+ net8.0
+ Ehwrj.Tools.LocalApiStub
+
+
+
+
+
+
diff --git a/tools/Ehwrj.Tools.LocalApiStub/Program.cs b/tools/Ehwrj.Tools.LocalApiStub/Program.cs
new file mode 100644
index 0000000..0dfa560
--- /dev/null
+++ b/tools/Ehwrj.Tools.LocalApiStub/Program.cs
@@ -0,0 +1,412 @@
+using System.Globalization;
+using System.Net;
+using System.Text;
+using System.Text.Json;
+using Ehwrj.Core.Services;
+
+namespace Ehwrj.Tools.LocalApiStub;
+
+internal static class Program
+{
+ private static readonly JsonSerializerOptions JsonOptions = new()
+ {
+ WriteIndented = true
+ };
+
+ private static async Task Main(string[] args)
+ {
+ var options = StubOptions.Parse(args);
+ if (options.ShowHelp)
+ {
+ StubOptions.PrintHelp();
+ return 0;
+ }
+
+ if (!LoopbackGuard.IsLoopbackHost(options.Host))
+ {
+ Console.Error.WriteLine("Only loopback hosts are allowed for the local API stub.");
+ return 1;
+ }
+
+ using var listener = new HttpListener();
+ var prefix = $"http://{options.Host}:{options.Port}/";
+ listener.Prefixes.Add(prefix);
+
+ using var shutdown = new CancellationTokenSource();
+ Console.CancelKeyPress += (_, eventArgs) =>
+ {
+ eventArgs.Cancel = true;
+ shutdown.Cancel();
+ listener.Stop();
+ };
+
+ try
+ {
+ listener.Start();
+ }
+ catch (HttpListenerException ex)
+ {
+ Console.Error.WriteLine($"Failed to listen on {prefix}: {ex.Message}");
+ return 1;
+ }
+
+ Console.WriteLine($"Ehwrj local API stub listening on {prefix}");
+ if (options.FixtureDirectory is not null)
+ {
+ Console.WriteLine($"Fixture replay: {Path.GetFullPath(options.FixtureDirectory)}");
+ }
+ Console.WriteLine("Endpoints: /map_info.json /map_obj.json /map.img /state /hudmsg /gamechat");
+ Console.WriteLine("Press Ctrl+C to stop.");
+
+ var startedAt = DateTimeOffset.UtcNow;
+ while (!shutdown.IsCancellationRequested)
+ {
+ HttpListenerContext context;
+ try
+ {
+ context = await listener.GetContextAsync().WaitAsync(shutdown.Token).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException)
+ {
+ break;
+ }
+ catch (HttpListenerException) when (shutdown.IsCancellationRequested)
+ {
+ break;
+ }
+
+ _ = Task.Run(() => HandleAsync(context, startedAt, options), shutdown.Token);
+ }
+
+ return 0;
+ }
+
+ private static async Task HandleAsync(HttpListenerContext context, DateTimeOffset startedAt, StubOptions options)
+ {
+ try
+ {
+ var path = context.Request.Url?.AbsolutePath ?? "/";
+ if (await TryWriteFixtureAsync(context, path, options.FixtureDirectory).ConfigureAwait(false))
+ {
+ return;
+ }
+
+ switch (path)
+ {
+ case "/map_info.json":
+ await WriteJsonAsync(context, CreateMapInfo()).ConfigureAwait(false);
+ break;
+ case "/map_obj.json":
+ await WriteJsonAsync(context, CreateMapObjects((DateTimeOffset.UtcNow - startedAt).TotalSeconds)).ConfigureAwait(false);
+ break;
+ case "/map.img":
+ await WriteBytesAsync(context, "image/bmp", MapImageGenerator.CreateBmp(512, 512)).ConfigureAwait(false);
+ break;
+ case "/state":
+ await WriteJsonAsync(context, CreateState((DateTimeOffset.UtcNow - startedAt).TotalSeconds)).ConfigureAwait(false);
+ break;
+ case "/hudmsg":
+ await WriteJsonAsync(context, CreateHudMessages((DateTimeOffset.UtcNow - startedAt).TotalSeconds)).ConfigureAwait(false);
+ break;
+ case "/gamechat":
+ await WriteJsonAsync(context, CreateGameChat((DateTimeOffset.UtcNow - startedAt).TotalSeconds)).ConfigureAwait(false);
+ break;
+ default:
+ await WriteTextAsync(context, "Ehwrj local API stub\n").ConfigureAwait(false);
+ break;
+ }
+ }
+ catch (Exception ex)
+ {
+ context.Response.StatusCode = 500;
+ await WriteTextAsync(context, ex.Message).ConfigureAwait(false);
+ }
+ finally
+ {
+ context.Response.Close();
+ }
+ }
+
+ private static object CreateMapInfo()
+ {
+ return new
+ {
+ grid_size = new[] { 1.0, 1.0 },
+ grid_steps = new[] { 0.1, 0.1 },
+ min_x = 0.0,
+ max_x = 1.0,
+ min_y = 0.0,
+ max_y = 1.0
+ };
+ }
+
+ private static object[] CreateMapObjects(double seconds)
+ {
+ var phase = seconds * 0.18;
+ var playerX = 0.5 + Math.Cos(phase) * 0.08;
+ var playerY = 0.5 + Math.Sin(phase) * 0.08;
+
+ return new object[]
+ {
+ new
+ {
+ id = "player",
+ name = "Player",
+ type = "player_aircraft",
+ team = "player",
+ icon = "fighter",
+ x = Round(playerX),
+ y = Round(playerY),
+ dx = Round(Math.Cos(phase)),
+ dy = Round(Math.Sin(phase))
+ },
+ new
+ {
+ id = "bandit-1",
+ name = "Bandit 1",
+ type = "aircraft",
+ team = "enemy",
+ icon = "fighter",
+ x = Round(0.5 + Math.Cos(phase + 1.8) * 0.22),
+ y = Round(0.5 + Math.Sin(phase + 1.8) * 0.18)
+ },
+ new
+ {
+ id = "bandit-2",
+ name = "Bandit 2",
+ type = "aircraft",
+ team = "enemy",
+ icon = "bomber",
+ x = Round(0.5 + Math.Cos(phase * 0.7 + 3.2) * 0.34),
+ y = Round(0.5 + Math.Sin(phase * 0.7 + 3.2) * 0.25)
+ },
+ new
+ {
+ id = "objective-a",
+ name = "A",
+ type = "capture_zone",
+ team = "ally",
+ icon = "zone",
+ x = 0.25,
+ y = 0.28
+ },
+ new
+ {
+ id = "objective-b",
+ name = "B",
+ type = "bombing_point",
+ team = "enemy",
+ icon = "zone",
+ x = 0.72,
+ y = 0.68
+ }
+ };
+ }
+
+ private static object CreateState(double seconds)
+ {
+ return new Dictionary
+ {
+ ["TAS, km/h"] = Math.Round(720 + Math.Sin(seconds * 0.25) * 80, 1),
+ ["IAS, km/h"] = Math.Round(650 + Math.Sin(seconds * 0.22) * 65, 1),
+ ["Vy, m/s"] = Math.Round(Math.Sin(seconds * 0.34) * 38, 2),
+ ["H, m"] = Math.Round(2200 + Math.Sin(seconds * 0.11) * 420, 1)
+ };
+ }
+
+ private static object CreateHudMessages(double seconds)
+ {
+ var wave = (int)(seconds / 12) % 4;
+ return new
+ {
+ events = new object[]
+ {
+ new { text = wave == 0 ? "Enemy aircraft spotted" : "Maintain formation", enemy = wave == 0 },
+ new { text = "Objective B under attack", enemy = true }
+ }
+ };
+ }
+
+ private static object CreateGameChat(double seconds)
+ {
+ var wave = (int)(seconds / 15) % 3;
+ return new
+ {
+ items = new object[]
+ {
+ new { text = wave == 0 ? "Cover the bomber route" : "Regroup near A", enemy = false }
+ }
+ };
+ }
+
+ private static double Round(double value)
+ {
+ return Math.Round(Math.Clamp(value, 0.02, 0.98), 5);
+ }
+
+ private static Task WriteJsonAsync(HttpListenerContext context, object value)
+ {
+ var json = JsonSerializer.Serialize(value, JsonOptions);
+ return WriteBytesAsync(context, "application/json", Encoding.UTF8.GetBytes(json));
+ }
+
+ private static Task WriteTextAsync(HttpListenerContext context, string text)
+ {
+ return WriteBytesAsync(context, "text/plain; charset=utf-8", Encoding.UTF8.GetBytes(text));
+ }
+
+ private static async Task WriteBytesAsync(HttpListenerContext context, string contentType, byte[] bytes)
+ {
+ context.Response.ContentType = contentType;
+ context.Response.ContentLength64 = bytes.Length;
+ await context.Response.OutputStream.WriteAsync(bytes).ConfigureAwait(false);
+ }
+
+ private static async Task TryWriteFixtureAsync(HttpListenerContext context, string path, string? fixtureDirectory)
+ {
+ if (fixtureDirectory is null)
+ {
+ return false;
+ }
+
+ var fileName = path switch
+ {
+ "/map_info.json" => "map_info.json",
+ "/map_obj.json" => "map_obj.json",
+ "/map.img" => "map.img",
+ "/state" => "state.json",
+ "/hudmsg" => "hudmsg.json",
+ "/gamechat" => "gamechat.json",
+ _ => null
+ };
+
+ if (fileName is null)
+ {
+ return false;
+ }
+
+ var filePath = Path.Combine(fixtureDirectory, fileName);
+ if (!File.Exists(filePath))
+ {
+ return false;
+ }
+
+ var bytes = await File.ReadAllBytesAsync(filePath).ConfigureAwait(false);
+ var contentType = fileName.EndsWith(".json", StringComparison.OrdinalIgnoreCase)
+ ? "application/json"
+ : "image/bmp";
+ await WriteBytesAsync(context, contentType, bytes).ConfigureAwait(false);
+ return true;
+ }
+}
+
+internal sealed record StubOptions(string Host, int Port, string? FixtureDirectory, bool ShowHelp)
+{
+ public static StubOptions Parse(IReadOnlyList args)
+ {
+ var host = "127.0.0.1";
+ var port = 8111;
+ string? fixtureDirectory = null;
+
+ for (var i = 0; i < args.Count; i++)
+ {
+ switch (args[i])
+ {
+ case "-h":
+ case "--help":
+ return new StubOptions(host, port, fixtureDirectory, true);
+ case "--host" when i + 1 < args.Count:
+ host = args[++i];
+ break;
+ case "--port" when i + 1 < args.Count && int.TryParse(args[++i], NumberStyles.None, CultureInfo.InvariantCulture, out var parsed):
+ port = parsed;
+ break;
+ case "--fixture-dir" when i + 1 < args.Count:
+ fixtureDirectory = args[++i];
+ break;
+ default:
+ Console.Error.WriteLine($"Ignoring unknown argument: {args[i]}");
+ break;
+ }
+ }
+
+ return new StubOptions(host, port, fixtureDirectory, false);
+ }
+
+ public static void PrintHelp()
+ {
+ Console.WriteLine("""
+ Usage:
+ dotnet run --project tools/Ehwrj.Tools.LocalApiStub -- [--host 127.0.0.1] [--port 8111] [--fixture-dir captures/name]
+
+ Serves deterministic War Thunder-like local map, telemetry, and message endpoints for UI development.
+ When --fixture-dir is supplied, files captured by Ehwrj.Tools.Capture are replayed first.
+ """);
+ }
+}
+
+internal static class MapImageGenerator
+{
+ public static byte[] CreateBmp(int width, int height)
+ {
+ var stride = ((width * 3 + 3) / 4) * 4;
+ var pixelBytes = stride * height;
+ var fileSize = 54 + pixelBytes;
+ var bytes = new byte[fileSize];
+
+ bytes[0] = (byte)'B';
+ bytes[1] = (byte)'M';
+ WriteInt32(bytes, 2, fileSize);
+ WriteInt32(bytes, 10, 54);
+ WriteInt32(bytes, 14, 40);
+ WriteInt32(bytes, 18, width);
+ WriteInt32(bytes, 22, height);
+ WriteInt16(bytes, 26, 1);
+ WriteInt16(bytes, 28, 24);
+ WriteInt32(bytes, 34, pixelBytes);
+
+ for (var y = 0; y < height; y++)
+ {
+ var sourceY = height - 1 - y;
+ for (var x = 0; x < width; x++)
+ {
+ var nx = x / (double)Math.Max(1, width - 1);
+ var ny = sourceY / (double)Math.Max(1, height - 1);
+ var grid = x % 64 == 0 || sourceY % 64 == 0;
+ var water = Math.Sin(nx * 8.0) + Math.Cos(ny * 7.0) > 1.15;
+
+ var r = water ? (byte)28 : (byte)(42 + ny * 34);
+ var g = water ? (byte)78 : (byte)(74 + nx * 46);
+ var b = water ? (byte)98 : (byte)(58 + (1 - ny) * 34);
+
+ if (grid)
+ {
+ r = (byte)Math.Min(255, r + 38);
+ g = (byte)Math.Min(255, g + 38);
+ b = (byte)Math.Min(255, b + 38);
+ }
+
+ var offset = 54 + y * stride + x * 3;
+ bytes[offset] = b;
+ bytes[offset + 1] = g;
+ bytes[offset + 2] = r;
+ }
+ }
+
+ return bytes;
+ }
+
+ private static void WriteInt16(byte[] bytes, int offset, short value)
+ {
+ bytes[offset] = (byte)value;
+ bytes[offset + 1] = (byte)(value >> 8);
+ }
+
+ private static void WriteInt32(byte[] bytes, int offset, int value)
+ {
+ bytes[offset] = (byte)value;
+ bytes[offset + 1] = (byte)(value >> 8);
+ bytes[offset + 2] = (byte)(value >> 16);
+ bytes[offset + 3] = (byte)(value >> 24);
+ }
+}