Add Ehwrj clean-room live map
Some checks failed
build / build-test-publish (push) Has been cancelled

This commit is contained in:
2026-06-02 22:49:24 +09:00
parent c93ab38cbd
commit cba5243ce4
71 changed files with 5990 additions and 9 deletions

27
.editorconfig Normal file
View File

@@ -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

59
.github/workflows/build.yml vendored Normal file
View File

@@ -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

13
.gitignore vendored Normal file
View File

@@ -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/**

58
CONTRIBUTING.md Normal file
View File

@@ -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.

13
Directory.Build.props Normal file
View File

@@ -0,0 +1,13 @@
<Project>
<PropertyGroup>
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<AnalysisLevel>latest</AnalysisLevel>
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<Deterministic>true</Deterministic>
<ContinuousIntegrationBuild Condition="'$(CI)' == 'true'">true</ContinuousIntegrationBuild>
<EnableWindowsTargeting>true</EnableWindowsTargeting>
</PropertyGroup>
</Project>

59
Ehwrj.sln Normal file
View File

@@ -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

25
LICENSE
View File

@@ -1,11 +1,22 @@
DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE MIT License
Version 2, December 2004
Copyright (C) 2004 Sam Hocevar <sam@hocevar.net> 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 The above copyright notice and this permission notice shall be included in all
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 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.

207
README.md
View File

@@ -1,3 +1,206 @@
# ehwrj # 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.

23
SECURITY.md Normal file
View File

@@ -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.

BIN
artifacts/ehwrj-win-x64.zip Normal file

Binary file not shown.

BIN
artifacts/ehwrj-win-x64/Ehwrj.exe Executable file

Binary file not shown.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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

Binary file not shown.

Binary file not shown.

Binary file not shown.

84
docs/architecture.md Normal file
View File

@@ -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.

31
docs/feature-matrix.md Normal file
View File

@@ -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.

116
scripts/bootstrap-ubuntu.sh Executable file
View File

@@ -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"

12
scripts/capture-local-api.sh Executable file
View File

@@ -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

80
scripts/package-win-x64.sh Executable file
View File

@@ -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"

18
scripts/publish-win-x64.sh Executable file
View File

@@ -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

12
scripts/run-local-api-stub.sh Executable file
View File

@@ -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

7
scripts/validate-capture.sh Executable file
View File

@@ -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"

33
scripts/verify-safety.sh Executable file
View File

@@ -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."

34
src/Ehwrj.App/App.axaml Normal file
View File

@@ -0,0 +1,34 @@
<Application x:Class="Ehwrj.App.App"
xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<Application.Styles>
<FluentTheme />
<Style Selector="Window">
<Setter Property="FontFamily" Value="Inter, Segoe UI, Arial" />
</Style>
<Style Selector="TextBlock">
<Setter Property="Foreground" Value="#E6EDF3" />
</Style>
<Style Selector="Button">
<Setter Property="MinHeight" Value="34" />
<Setter Property="Padding" Value="14,6" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="Background" Value="#4CC9A7" />
<Setter Property="Foreground" Value="#07110F" />
<Setter Property="FontWeight" Value="SemiBold" />
<Setter Property="Cursor" Value="Hand" />
</Style>
<Style Selector="Button.secondary">
<Setter Property="Background" Value="#2A3642" />
<Setter Property="Foreground" Value="#E6EDF3" />
</Style>
<Style Selector="Slider">
<Setter Property="Margin" Value="0,4,0,12" />
</Style>
<Style Selector="CheckBox">
<Setter Property="Foreground" Value="#E6EDF3" />
<Setter Property="Margin" Value="0,0,0,10" />
</Style>
</Application.Styles>
</Application>

View File

@@ -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();
}
}

View File

@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<RootNamespace>Ehwrj.App</RootNamespace>
<AssemblyName>Ehwrj</AssemblyName>
<AvaloniaUseCompiledBindingsByDefault>false</AvaloniaUseCompiledBindingsByDefault>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Avalonia" Version="11.3.7" />
<PackageReference Include="Avalonia.Desktop" Version="11.3.7" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.3.7" />
<PackageReference Include="Avalonia.Fonts.Inter" Version="11.3.7" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../Ehwrj.Core/Ehwrj.Core.csproj" />
</ItemGroup>
</Project>

View File

@@ -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);
}

View File

@@ -0,0 +1,227 @@
<Window x:Class="Ehwrj.App.MainWindow"
xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:rendering="using:Ehwrj.App.Rendering"
Title="Ehwrj"
Width="1280"
Height="780"
MinWidth="980"
MinHeight="640"
Background="#0B0F14"
WindowStartupLocation="CenterScreen">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="320" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Border Grid.Column="0" Background="#121A22" Padding="24">
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel>
<TextBlock Text="Ehwrj" FontSize="32" FontWeight="SemiBold" />
<TextBlock Text="{Binding Ui.Subtitle}" Foreground="#93A4B3" Margin="0,4,0,18" />
<TextBlock Text="{Binding Ui.Language}" FontSize="13" Foreground="#93A4B3" />
<ComboBox ItemsSource="{Binding LanguageOptions}"
SelectedItem="{Binding SelectedLanguageOption}"
Margin="0,4,0,20">
<ComboBox.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding DisplayName}" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<StackPanel Orientation="Horizontal" Margin="0,0,0,20">
<Button Content="{Binding Ui.Start}" Command="{Binding StartCommand}" Margin="0,0,8,0" />
<Button Content="{Binding Ui.Stop}" Classes="secondary" Command="{Binding StopCommand}" />
</StackPanel>
<TextBlock Text="{Binding Ui.Connection}" FontSize="13" Foreground="#93A4B3" />
<TextBlock Text="{Binding Status}" TextWrapping="Wrap" Margin="0,4,0,18" />
<Expander Header="{Binding Ui.Diagnostics}" IsExpanded="False" Margin="0,0,0,6">
<ItemsControl ItemsSource="{Binding EndpointHealthEntries}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding}" TextWrapping="Wrap" Foreground="#C9D3DD" FontSize="12" Margin="0,0,0,6" />
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</Expander>
<Expander Header="{Binding Ui.Map}" IsExpanded="True" Margin="0,0,0,6">
<StackPanel>
<CheckBox Content="{Binding Ui.ShowLabels}" IsChecked="{Binding Settings.Map.ShowLabels}" />
<CheckBox Content="{Binding Ui.ShowAircraftMach}" IsChecked="{Binding Settings.Map.ShowAircraftMach}" />
<CheckBox Content="{Binding Ui.FollowPlayer}" IsChecked="{Binding Settings.Map.FollowPlayer}" />
<CheckBox Content="{Binding Ui.RotateWithPlayer}" IsChecked="{Binding Settings.Map.RotateWithPlayer}" />
<CheckBox Content="{Binding Ui.ShowRangeRings}" IsChecked="{Binding Settings.Map.ShowRangeRings}" />
<CheckBox Content="{Binding Ui.ShowBattleLog}" IsChecked="{Binding Settings.Map.ShowBattleLog}" />
<CheckBox Content="{Binding Ui.DeliveryTracker}" IsChecked="{Binding Settings.Map.ShowDeliveryTracker}" />
<TextBlock Text="{Binding Ui.DeliveryTrackerAngle}" Foreground="#93A4B3" />
<Slider Minimum="0.1" Maximum="1" Value="{Binding Settings.Map.DeliveryTrackerAngle}" />
</StackPanel>
</Expander>
<CheckBox Content="{Binding Ui.ShowOverlay}" IsChecked="{Binding IsOverlayEnabled}" />
<CheckBox Content="{Binding Ui.ShowMinimap}" IsChecked="{Binding Settings.Overlay.ShowMiniMap}" />
<CheckBox Content="{Binding Ui.ShowMachLabels}" IsChecked="{Binding Settings.Overlay.ShowMach}" />
<CheckBox Content="{Binding Ui.ShowSpotRadar}" IsChecked="{Binding Settings.Overlay.ShowSpotRadar}" />
<TextBlock Text="{Binding Ui.OverlaySize}" Foreground="#93A4B3" />
<Slider Minimum="160" Maximum="2000" Value="{Binding Settings.Overlay.Size}" />
<TextBlock Text="{Binding Ui.TopOffset}" Foreground="#93A4B3" />
<Slider Minimum="0" Maximum="500" Value="{Binding Settings.Overlay.Top}" />
<TextBlock Text="{Binding Ui.RightOffset}" Foreground="#93A4B3" />
<Slider Minimum="0" Maximum="2000" Value="{Binding Settings.Overlay.Right}" />
<TextBlock Text="{Binding Ui.Zoom}" Foreground="#93A4B3" />
<Slider Minimum="25" Maximum="400" Value="{Binding Settings.Overlay.ZoomPercent}" />
<Expander Header="{Binding Ui.Minimap}" IsExpanded="True" Margin="0,6,0,0">
<StackPanel>
<TextBlock Text="{Binding Ui.AircraftScale}" Foreground="#93A4B3" />
<Slider Minimum="50" Maximum="200" Value="{Binding Settings.Overlay.MiniMapAircraftScale}" />
<TextBlock Text="{Binding Ui.MinimumMach}" Foreground="#93A4B3" />
<Slider Minimum="0" Maximum="2" TickFrequency="0.1" Value="{Binding Settings.Overlay.MiniMapMinimumMach}" />
</StackPanel>
</Expander>
<Expander Header="{Binding Ui.SpotRadar}" IsExpanded="True" Margin="0,4,0,0">
<StackPanel>
<CheckBox Content="{Binding Ui.ShowDistance}" IsChecked="{Binding Settings.Overlay.SpotShowDistance}" />
<CheckBox Content="{Binding Ui.ShowMach}" IsChecked="{Binding Settings.Overlay.SpotShowMach}" />
<CheckBox Content="{Binding Ui.ShowClosureSpeed}" IsChecked="{Binding Settings.Overlay.SpotShowRelativeSpeed}" />
<TextBlock Text="{Binding Ui.RadarRangeKm}" Foreground="#93A4B3" />
<Slider Minimum="0" Maximum="30" Value="{Binding Settings.Overlay.SpotDetectDistanceKm}" />
<TextBlock Text="{Binding Ui.RadarSpread}" Foreground="#93A4B3" />
<Slider Minimum="40" Maximum="600" Value="{Binding Settings.Overlay.SpotDistance}" />
<TextBlock Text="{Binding Ui.MinimumMach}" Foreground="#93A4B3" />
<Slider Minimum="0" Maximum="2" TickFrequency="0.1" Value="{Binding Settings.Overlay.SpotMinimumMach}" />
<TextBlock Text="{Binding Ui.MarkerOpacity}" Foreground="#93A4B3" />
<Slider Minimum="0" Maximum="100" Value="{Binding Settings.Overlay.SpotOpacity}" />
<TextBlock Text="{Binding Ui.FontOpacity}" Foreground="#93A4B3" />
<Slider Minimum="0" Maximum="100" Value="{Binding Settings.Overlay.SpotFontOpacity}" />
<TextBlock Text="{Binding Ui.VerticalScale}" Foreground="#93A4B3" />
<Slider Minimum="40" Maximum="140" Value="{Binding Settings.Overlay.SpotVerticalScale}" />
<TextBlock Text="{Binding Ui.VerticalOffset}" Foreground="#93A4B3" />
<Slider Minimum="-500" Maximum="500" Value="{Binding Settings.Overlay.SpotVerticalOffset}" />
<TextBlock Text="{Binding Ui.ArrowScale}" Foreground="#93A4B3" />
<Slider Minimum="50" Maximum="200" Value="{Binding Settings.Overlay.SpotArrowScale}" />
<TextBlock Text="{Binding Ui.ArrowOutline}" Foreground="#93A4B3" />
<Slider Minimum="0" Maximum="10" Value="{Binding Settings.Overlay.SpotOutlineWidth}" />
<TextBlock Text="{Binding Ui.DistanceFontSize}" Foreground="#93A4B3" />
<Slider Minimum="10" Maximum="60" Value="{Binding Settings.Overlay.DistanceFontSize}" />
<TextBlock Text="{Binding Ui.MachFontSize}" Foreground="#93A4B3" />
<Slider Minimum="10" Maximum="60" Value="{Binding Settings.Overlay.MachFontSize}" />
<TextBlock Text="{Binding Ui.ClosureFontSize}" Foreground="#93A4B3" />
<Slider Minimum="10" Maximum="60" Value="{Binding Settings.Overlay.RelativeFontSize}" />
<TextBlock Text="{Binding Ui.ArrowColor}" Foreground="#93A4B3" />
<TextBox Text="{Binding Settings.Overlay.SpotArrowColor}" Watermark="#ff1e1e" Margin="0,4,0,10" />
<TextBlock Text="{Binding Ui.DistanceColor}" Foreground="#93A4B3" />
<TextBox Text="{Binding Settings.Overlay.DistanceTextColor}" Watermark="#ff1e1e" Margin="0,4,0,10" />
<TextBlock Text="{Binding Ui.MachColor}" Foreground="#93A4B3" />
<TextBox Text="{Binding Settings.Overlay.MachTextColor}" Watermark="#57c7f2" Margin="0,4,0,10" />
<TextBlock Text="{Binding Ui.ClosureColor}" Foreground="#93A4B3" />
<TextBox Text="{Binding Settings.Overlay.RelativeTextColor}" Watermark="#19f24f" Margin="0,4,0,10" />
</StackPanel>
</Expander>
<StackPanel Orientation="Horizontal" Margin="0,10,0,0">
<Button Content="{Binding Ui.Save}" Command="{Binding SaveCommand}" Margin="0,0,8,0" />
<Button Content="{Binding Ui.Reset}" Classes="secondary" Command="{Binding ResetSettingsCommand}" />
</StackPanel>
</StackPanel>
</ScrollViewer>
</Border>
<Grid Grid.Column="1" Margin="24">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<Grid Grid.Row="0" Margin="0,0,0,18">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<StackPanel>
<TextBlock Text="{Binding Ui.LiveMap}" FontSize="24" FontWeight="SemiBold" />
<TextBlock Text="{Binding Ui.LocalApiScope}" Foreground="#93A4B3" />
<TextBlock Text="{Binding PlayerInfo}" Foreground="#C9D3DD" Margin="0,6,0,0" />
</StackPanel>
<StackPanel Grid.Column="1" Orientation="Horizontal" VerticalAlignment="Bottom" Spacing="16">
<TextBlock Text="{Binding AllySummary}" Foreground="#4CC9A7" />
<TextBlock Text="{Binding EnemySummary}" Foreground="#FF5252" />
<TextBlock Text="{Binding BattleTimerText}" Foreground="#F6C85F" />
<TextBlock Text="{Binding SnapshotSummary}" Foreground="#93A4B3" />
</StackPanel>
</Grid>
<Grid Grid.Row="1">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="280" />
</Grid.ColumnDefinitions>
<Border Grid.Column="0" Background="#071014" ClipToBounds="True">
<rendering:MapCanvas Snapshot="{Binding Snapshot}"
MapSettings="{Binding Settings.Map}"
OverlaySettings="{Binding Settings.Overlay}"
Ui="{Binding Ui}" />
</Border>
<Border Grid.Column="1" Background="#121A22" Padding="16" Margin="18,0,0,0" IsVisible="{Binding Settings.Map.ShowBattleLog}">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<TextBlock Text="{Binding Ui.BattleLog}" FontSize="16" FontWeight="SemiBold" Margin="0,0,0,12" />
<ScrollViewer Grid.Row="1" VerticalScrollBarVisibility="Auto">
<ItemsControl ItemsSource="{Binding BattleLogEntries}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding}" TextWrapping="Wrap" Foreground="#C9D3DD" Margin="0,0,0,8" />
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</Grid>
</Border>
</Grid>
<Grid Grid.Row="2" Margin="0,18,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<TextBlock Text="{Binding ProcessStatus}" Foreground="#93A4B3" />
<TextBlock Grid.Column="1" Text="{Binding LastUpdatedText}" Foreground="#93A4B3" />
</Grid>
</Grid>
</Grid>
</Window>

View File

@@ -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();
}
}

View File

@@ -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;
}

View File

@@ -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);

View File

@@ -0,0 +1,3 @@
namespace Ehwrj.App.Models;
public sealed record LanguageOption(string Code, string DisplayName);

View File

@@ -0,0 +1,61 @@
using Avalonia.Media.Imaging;
using Ehwrj.Core.Models;
namespace Ehwrj.App.Models;
public sealed class LiveSnapshot
{
private static readonly IReadOnlyList<EndpointHealth> 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<MapObject>(),
FlightState.Empty,
Array.Empty<BattleMessage>(),
null,
DateTimeOffset.MinValue,
false,
"Waiting for War Thunder",
EmptyEndpointHealth);
public LiveSnapshot(
MapInfo mapInfo,
IReadOnlyList<MapObject> objects,
FlightState flightState,
IReadOnlyList<BattleMessage> messages,
Bitmap? mapImage,
DateTimeOffset updatedAt,
bool isGameRunning,
string status,
IReadOnlyList<EndpointHealth>? 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<MapObject> Objects { get; }
public FlightState FlightState { get; }
public IReadOnlyList<BattleMessage> Messages { get; }
public Bitmap? MapImage { get; }
public DateTimeOffset UpdatedAt { get; }
public bool IsGameRunning { get; }
public string Status { get; }
public IReadOnlyList<EndpointHealth> EndpointHealth { get; }
public MapObject? Player => Objects.FirstOrDefault(static o => o.IsPlayer) ?? Objects.FirstOrDefault(static o => o.IsAircraft);
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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<LanguageOption> 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
};
}
}

View File

@@ -0,0 +1,16 @@
<Window x:Class="Ehwrj.App.OverlayWindow"
xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:rendering="using:Ehwrj.App.Rendering"
Background="Transparent"
SystemDecorations="None"
ShowInTaskbar="False"
CanResize="False"
Topmost="True"
Width="560"
Height="560"
TransparencyLevelHint="Transparent">
<rendering:OverlayCanvas Snapshot="{Binding Snapshot}"
OverlaySettings="{Binding Settings.Overlay}"
Ui="{Binding Ui}" />
</Window>

View File

@@ -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();
}
}

21
src/Ehwrj.App/Program.cs Normal file
View File

@@ -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<App>()
.UsePlatformDetect()
.WithInterFont()
.LogToTrace();
}
}

View File

@@ -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<LiveSnapshot> SnapshotProperty = AvaloniaProperty.Register<MapCanvas, LiveSnapshot>(
nameof(Snapshot),
LiveSnapshot.Empty);
public static readonly StyledProperty<OverlaySettings> OverlaySettingsProperty = AvaloniaProperty.Register<MapCanvas, OverlaySettings>(
nameof(OverlaySettings),
new OverlaySettings());
public static readonly StyledProperty<MapSettings> MapSettingsProperty = AvaloniaProperty.Register<MapCanvas, MapSettings>(
nameof(MapSettings),
new MapSettings());
public static readonly StyledProperty<UiText> UiProperty = AvaloniaProperty.Register<MapCanvas, UiText>(
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<MapCanvas>(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);

View File

@@ -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);
}
}

View File

@@ -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<LiveSnapshot>? 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<EndpointHealth>();
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<T?> ReadRequiredAsync<T>(
string path,
Func<CancellationToken, Task<T>> read,
List<EndpointHealth> 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<string?> ReadOptionalAsync(
string path,
Func<CancellationToken, Task<string?>> read,
List<EndpointHealth> 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> endpointHealth, string status)
{
SnapshotUpdated?.Invoke(
this,
new LiveSnapshot(
MapInfo.Empty,
Array.Empty<MapObject>(),
FlightState.Empty,
Array.Empty<BattleMessage>(),
_lastImage,
DateTimeOffset.Now,
gameRunning,
status,
CompleteEndpointHealth(endpointHealth)));
}
private static IReadOnlyList<EndpointHealth> CompleteEndpointHealth(IReadOnlyList<EndpointHealth> health)
{
var completed = new List<EndpointHealth>(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);

View File

@@ -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<AppSettings>(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);
}
}

View File

@@ -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<string> _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<string> BattleLogEntries { get; } = new();
public ObservableCollection<string> EndpointHealthEntries { get; } = new();
public IReadOnlyList<LanguageOption> 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<string> { 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();
}
}

View File

@@ -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<T>(ref T field, T value, [CallerMemberName] string? propertyName = null)
{
if (EqualityComparer<T>.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));
}
}

View File

@@ -0,0 +1,33 @@
using System.Windows.Input;
namespace Ehwrj.App.ViewModels;
public sealed class RelayCommand : ICommand
{
private readonly Action _execute;
private readonly Func<bool>? _canExecute;
public RelayCommand(Action execute, Func<bool>? 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);
}
}

View File

@@ -0,0 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<RootNamespace>Ehwrj.Core</RootNamespace>
</PropertyGroup>
</Project>

View File

@@ -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);
}
}

View File

@@ -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;
}

View File

@@ -0,0 +1,4 @@
namespace Ehwrj.Core.Geometry;
public readonly record struct ProjectedPoint(double X, double Y);

View File

@@ -0,0 +1,132 @@
using System.Text.Json;
namespace Ehwrj.Core.Models;
public sealed record BattleMessage(string Text, bool? Enemy)
{
public static IReadOnlyList<BattleMessage> FromJson(string? json)
{
if (string.IsNullOrWhiteSpace(json))
{
return Array.Empty<BattleMessage>();
}
try
{
using var document = JsonDocument.Parse(json);
var messages = new List<BattleMessage>();
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<BattleMessage>();
}
}
private static void Collect(JsonElement element, List<BattleMessage> 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();
}
}

View File

@@ -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<string, string>(StringComparer.Ordinal), "{}");
public FlightState(IReadOnlyDictionary<string, string> values, string rawJson)
{
Values = values;
RawJson = rawJson;
}
public IReadOnlyDictionary<string, string> 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<string, string>(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<string, string> 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;
}
}
}

View File

@@ -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;
}
}

View File

@@ -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<MapObject> 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<JsonElement>().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;
}
}

View File

@@ -0,0 +1,12 @@
namespace Ehwrj.Core.Models;
public enum MapObjectKind
{
Unknown,
Ally,
Squad,
Enemy,
Player,
Objective
}

View File

@@ -0,0 +1,38 @@
namespace Ehwrj.Core.Models;
public sealed class ObjectTracker
{
private readonly Dictionary<string, TrackPoint> _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);
}

View File

@@ -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<string>();
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<string> 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<MapObject> ReadObjects(string directory, FlightState flightState, List<string> warnings)
{
var path = Path.Combine(directory, "map_obj.json");
if (!File.Exists(path))
{
return Array.Empty<MapObject>();
}
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<MapObject>();
}
}
private static FlightState ReadFlightState(string directory, List<string> 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<BattleMessage> ReadMessages(string directory, string fileName, List<string> warnings)
{
var path = Path.Combine(directory, fileName);
if (!File.Exists(path))
{
return Array.Empty<BattleMessage>();
}
try
{
return BattleMessage.FromJson(File.ReadAllText(path));
}
catch (IOException ex)
{
warnings.Add($"{fileName} could not be read: {ex.Message}");
return Array.Empty<BattleMessage>();
}
}
private static long? ReadMapImageBytes(string directory, List<string> 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<CaptureFieldStat> CollectObjectFieldStats(IReadOnlyList<MapObject> objects, List<string> warnings)
{
var counts = new Dictionary<string, int>(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<string> CollectUnknownObjectSamples(IReadOnlyList<MapObject> 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<string> { $"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<string> 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<string, bool> 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<CaptureFieldStat> objectFields,
IReadOnlyList<string> unknownObjectSamples,
IReadOnlyList<string> stateFieldNames,
IReadOnlyList<string> 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<string, bool> 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<CaptureFieldStat> ObjectFields { get; }
public IReadOnlyList<string> UnknownObjectSamples { get; }
public IReadOnlyList<string> StateFieldNames { get; }
public IReadOnlyList<string> 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<string>
{
"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";
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}
}
}

View File

@@ -0,0 +1,104 @@
namespace Ehwrj.Core.Services;
public interface IWarThunderClient
{
Task<string> GetMapInfoAsync(CancellationToken cancellationToken);
Task<string> GetObjectsAsync(CancellationToken cancellationToken);
Task<byte[]> GetMapImageAsync(CancellationToken cancellationToken);
Task<string?> TryGetStateAsync(CancellationToken cancellationToken);
Task<string?> TryGetHudMessagesAsync(CancellationToken cancellationToken);
Task<string?> 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<string> GetMapInfoAsync(CancellationToken cancellationToken)
{
return _httpClient.GetStringAsync("map_info.json", cancellationToken);
}
public Task<string> GetObjectsAsync(CancellationToken cancellationToken)
{
return _httpClient.GetStringAsync("map_obj.json", cancellationToken);
}
public Task<byte[]> GetMapImageAsync(CancellationToken cancellationToken)
{
return _httpClient.GetByteArrayAsync("map.img", cancellationToken);
}
public Task<string?> TryGetStateAsync(CancellationToken cancellationToken)
{
return TryGetStringAsync("state", cancellationToken);
}
public Task<string?> TryGetHudMessagesAsync(CancellationToken cancellationToken)
{
return TryGetStringAsync("hudmsg", cancellationToken);
}
public Task<string?> TryGetGameChatAsync(CancellationToken cancellationToken)
{
return TryGetStringAsync("gamechat", cancellationToken);
}
private async Task<string?> 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();
}
}
}

View File

@@ -0,0 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<RootNamespace>Ehwrj.Tests</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../../src/Ehwrj.App/Ehwrj.App.csproj" />
<ProjectReference Include="../../src/Ehwrj.Core/Ehwrj.Core.csproj" />
</ItemGroup>
</Project>

View File

@@ -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":"<b>Enemy spotted</b>","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}");
}
}
}

View File

@@ -0,0 +1,11 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<RootNamespace>Ehwrj.Tools.Capture</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../../src/Ehwrj.Core/Ehwrj.Core.csproj" />
</ItemGroup>
</Project>

View File

@@ -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<int> 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<bool> 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<string> 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}/");
}
}

View File

@@ -0,0 +1,11 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<RootNamespace>Ehwrj.Tools.LocalApiStub</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../../src/Ehwrj.Core/Ehwrj.Core.csproj" />
</ItemGroup>
</Project>

View File

@@ -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<int> 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<string, object>
{
["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<bool> 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<string> 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);
}
}