Add Ehwrj clean-room live map
Some checks failed
build / build-test-publish (push) Has been cancelled
Some checks failed
build / build-test-publish (push) Has been cancelled
This commit is contained in:
27
.editorconfig
Normal file
27
.editorconfig
Normal 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
59
.github/workflows/build.yml
vendored
Normal 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
13
.gitignore
vendored
Normal 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
58
CONTRIBUTING.md
Normal 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
13
Directory.Build.props
Normal 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
59
Ehwrj.sln
Normal 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
25
LICENSE
@@ -1,11 +1,22 @@
|
||||
DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
|
||||
Version 2, December 2004
|
||||
MIT License
|
||||
|
||||
Copyright (C) 2004 Sam Hocevar <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
|
||||
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
0. You just DO WHAT THE FUCK YOU WANT TO.
|
||||
|
||||
207
README.md
207
README.md
@@ -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
23
SECURITY.md
Normal 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
BIN
artifacts/ehwrj-win-x64.zip
Normal file
Binary file not shown.
BIN
artifacts/ehwrj-win-x64/Ehwrj.exe
Executable file
BIN
artifacts/ehwrj-win-x64/Ehwrj.exe
Executable file
Binary file not shown.
22
artifacts/ehwrj-win-x64/LICENSE
Normal file
22
artifacts/ehwrj-win-x64/LICENSE
Normal 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.
|
||||
|
||||
206
artifacts/ehwrj-win-x64/README.md
Normal file
206
artifacts/ehwrj-win-x64/README.md
Normal 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.
|
||||
13
artifacts/ehwrj-win-x64/RUNNING.txt
Normal file
13
artifacts/ehwrj-win-x64/RUNNING.txt
Normal 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.
|
||||
23
artifacts/ehwrj-win-x64/SECURITY.md
Normal file
23
artifacts/ehwrj-win-x64/SECURITY.md
Normal 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.
|
||||
|
||||
8
artifacts/ehwrj-win-x64/SHA256SUMS.txt
Normal file
8
artifacts/ehwrj-win-x64/SHA256SUMS.txt
Normal 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
|
||||
BIN
artifacts/ehwrj-win-x64/av_libglesv2.dll
Executable file
BIN
artifacts/ehwrj-win-x64/av_libglesv2.dll
Executable file
Binary file not shown.
BIN
artifacts/ehwrj-win-x64/libHarfBuzzSharp.dll
Executable file
BIN
artifacts/ehwrj-win-x64/libHarfBuzzSharp.dll
Executable file
Binary file not shown.
BIN
artifacts/ehwrj-win-x64/libSkiaSharp.dll
Executable file
BIN
artifacts/ehwrj-win-x64/libSkiaSharp.dll
Executable file
Binary file not shown.
84
docs/architecture.md
Normal file
84
docs/architecture.md
Normal 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
31
docs/feature-matrix.md
Normal 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
116
scripts/bootstrap-ubuntu.sh
Executable 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
12
scripts/capture-local-api.sh
Executable 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
80
scripts/package-win-x64.sh
Executable 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
18
scripts/publish-win-x64.sh
Executable 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
12
scripts/run-local-api-stub.sh
Executable 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
7
scripts/validate-capture.sh
Executable 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
33
scripts/verify-safety.sh
Executable 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
34
src/Ehwrj.App/App.axaml
Normal 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>
|
||||
|
||||
24
src/Ehwrj.App/App.axaml.cs
Normal file
24
src/Ehwrj.App/App.axaml.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
|
||||
20
src/Ehwrj.App/Ehwrj.App.csproj
Normal file
20
src/Ehwrj.App/Ehwrj.App.csproj
Normal 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>
|
||||
30
src/Ehwrj.App/Infrastructure/Win32.cs
Normal file
30
src/Ehwrj.App/Infrastructure/Win32.cs
Normal 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);
|
||||
}
|
||||
227
src/Ehwrj.App/MainWindow.axaml
Normal file
227
src/Ehwrj.App/MainWindow.axaml
Normal 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>
|
||||
74
src/Ehwrj.App/MainWindow.axaml.cs
Normal file
74
src/Ehwrj.App/MainWindow.axaml.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
9
src/Ehwrj.App/Models/AppSettings.cs
Normal file
9
src/Ehwrj.App/Models/AppSettings.cs
Normal 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;
|
||||
}
|
||||
15
src/Ehwrj.App/Models/EndpointHealth.cs
Normal file
15
src/Ehwrj.App/Models/EndpointHealth.cs
Normal 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);
|
||||
3
src/Ehwrj.App/Models/LanguageOption.cs
Normal file
3
src/Ehwrj.App/Models/LanguageOption.cs
Normal file
@@ -0,0 +1,3 @@
|
||||
namespace Ehwrj.App.Models;
|
||||
|
||||
public sealed record LanguageOption(string Code, string DisplayName);
|
||||
61
src/Ehwrj.App/Models/LiveSnapshot.cs
Normal file
61
src/Ehwrj.App/Models/LiveSnapshot.cs
Normal 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);
|
||||
}
|
||||
76
src/Ehwrj.App/Models/MapSettings.cs
Normal file
76
src/Ehwrj.App/Models/MapSettings.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
283
src/Ehwrj.App/Models/OverlaySettings.cs
Normal file
283
src/Ehwrj.App/Models/OverlaySettings.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
311
src/Ehwrj.App/Models/UiText.cs
Normal file
311
src/Ehwrj.App/Models/UiText.cs
Normal 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
|
||||
};
|
||||
}
|
||||
}
|
||||
16
src/Ehwrj.App/OverlayWindow.axaml
Normal file
16
src/Ehwrj.App/OverlayWindow.axaml
Normal 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>
|
||||
45
src/Ehwrj.App/OverlayWindow.axaml.cs
Normal file
45
src/Ehwrj.App/OverlayWindow.axaml.cs
Normal 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
21
src/Ehwrj.App/Program.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
|
||||
463
src/Ehwrj.App/Rendering/MapCanvas.cs
Normal file
463
src/Ehwrj.App/Rendering/MapCanvas.cs
Normal 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);
|
||||
155
src/Ehwrj.App/Rendering/OverlayCanvas.cs
Normal file
155
src/Ehwrj.App/Rendering/OverlayCanvas.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
215
src/Ehwrj.App/Services/LiveMapService.cs
Normal file
215
src/Ehwrj.App/Services/LiveMapService.cs
Normal 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);
|
||||
45
src/Ehwrj.App/Services/SettingsStore.cs
Normal file
45
src/Ehwrj.App/Services/SettingsStore.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
||||
274
src/Ehwrj.App/ViewModels/MainViewModel.cs
Normal file
274
src/Ehwrj.App/ViewModels/MainViewModel.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
27
src/Ehwrj.App/ViewModels/ObservableObject.cs
Normal file
27
src/Ehwrj.App/ViewModels/ObservableObject.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
|
||||
33
src/Ehwrj.App/ViewModels/RelayCommand.cs
Normal file
33
src/Ehwrj.App/ViewModels/RelayCommand.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
||||
7
src/Ehwrj.Core/Ehwrj.Core.csproj
Normal file
7
src/Ehwrj.Core/Ehwrj.Core.csproj
Normal file
@@ -0,0 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<RootNamespace>Ehwrj.Core</RootNamespace>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
|
||||
49
src/Ehwrj.Core/Geometry/CoordinateProjector.cs
Normal file
49
src/Ehwrj.Core/Geometry/CoordinateProjector.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
||||
8
src/Ehwrj.Core/Geometry/MapViewport.cs
Normal file
8
src/Ehwrj.Core/Geometry/MapViewport.cs
Normal 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;
|
||||
}
|
||||
|
||||
4
src/Ehwrj.Core/Geometry/ProjectedPoint.cs
Normal file
4
src/Ehwrj.Core/Geometry/ProjectedPoint.cs
Normal file
@@ -0,0 +1,4 @@
|
||||
namespace Ehwrj.Core.Geometry;
|
||||
|
||||
public readonly record struct ProjectedPoint(double X, double Y);
|
||||
|
||||
132
src/Ehwrj.Core/Models/BattleMessage.cs
Normal file
132
src/Ehwrj.Core/Models/BattleMessage.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
|
||||
124
src/Ehwrj.Core/Models/FlightState.cs
Normal file
124
src/Ehwrj.Core/Models/FlightState.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
138
src/Ehwrj.Core/Models/MapInfo.cs
Normal file
138
src/Ehwrj.Core/Models/MapInfo.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
296
src/Ehwrj.Core/Models/MapObject.cs
Normal file
296
src/Ehwrj.Core/Models/MapObject.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
12
src/Ehwrj.Core/Models/MapObjectKind.cs
Normal file
12
src/Ehwrj.Core/Models/MapObjectKind.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
namespace Ehwrj.Core.Models;
|
||||
|
||||
public enum MapObjectKind
|
||||
{
|
||||
Unknown,
|
||||
Ally,
|
||||
Squad,
|
||||
Enemy,
|
||||
Player,
|
||||
Objective
|
||||
}
|
||||
|
||||
38
src/Ehwrj.Core/Models/ObjectTracker.cs
Normal file
38
src/Ehwrj.Core/Models/ObjectTracker.cs
Normal 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);
|
||||
}
|
||||
452
src/Ehwrj.Core/Services/CaptureFixtureAnalyzer.cs
Normal file
452
src/Ehwrj.Core/Services/CaptureFixtureAnalyzer.cs
Normal 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";
|
||||
}
|
||||
}
|
||||
37
src/Ehwrj.Core/Services/LoopbackGuard.cs
Normal file
37
src/Ehwrj.Core/Services/LoopbackGuard.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
||||
18
src/Ehwrj.Core/Services/ProcessProbe.cs
Normal file
18
src/Ehwrj.Core/Services/ProcessProbe.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
104
src/Ehwrj.Core/Services/WarThunderClient.cs
Normal file
104
src/Ehwrj.Core/Services/WarThunderClient.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
12
tests/Ehwrj.Tests/Ehwrj.Tests.csproj
Normal file
12
tests/Ehwrj.Tests/Ehwrj.Tests.csproj
Normal 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>
|
||||
322
tests/Ehwrj.Tests/Program.cs
Normal file
322
tests/Ehwrj.Tests/Program.cs
Normal 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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
11
tools/Ehwrj.Tools.Capture/Ehwrj.Tools.Capture.csproj
Normal file
11
tools/Ehwrj.Tools.Capture/Ehwrj.Tools.Capture.csproj
Normal 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>
|
||||
202
tools/Ehwrj.Tools.Capture/Program.cs
Normal file
202
tools/Ehwrj.Tools.Capture/Program.cs
Normal 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}/");
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
412
tools/Ehwrj.Tools.LocalApiStub/Program.cs
Normal file
412
tools/Ehwrj.Tools.LocalApiStub/Program.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user