├── .cargo └── config.toml ├── .gitattributes ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .luacheckrc ├── .protolint.yaml ├── CHANGELOG.md ├── CONTRIBUTING.md ├── Cargo.lock ├── Cargo.toml ├── LICENSE ├── Makefile ├── README.md ├── STATUS.md ├── build.rs ├── lua ├── DCS-gRPC │ ├── exporters │ │ └── object.lua │ ├── grpc-hook.lua │ ├── grpc-mission.lua │ ├── grpc.lua │ ├── methods │ │ ├── atmosphere.lua │ │ ├── coalitions.lua │ │ ├── controllers.lua │ │ ├── custom.lua │ │ ├── group.lua │ │ ├── hook.lua │ │ ├── mission.lua │ │ ├── net.lua │ │ ├── timer.lua │ │ ├── trigger.lua │ │ ├── unit.lua │ │ └── world.lua │ └── version.lua └── Hooks │ └── DCS-gRPC.lua ├── lua_files.rs ├── protos └── dcs │ ├── atmosphere │ └── v0 │ │ └── atmosphere.proto │ ├── coalition │ └── v0 │ │ └── coalition.proto │ ├── common │ └── v0 │ │ └── common.proto │ ├── controller │ └── v0 │ │ └── controller.proto │ ├── custom │ └── v0 │ │ └── custom.proto │ ├── dcs.proto │ ├── group │ └── v0 │ │ └── group.proto │ ├── hook │ └── v0 │ │ └── hook.proto │ ├── metadata │ └── v0 │ │ └── metadata.proto │ ├── mission │ └── v0 │ │ └── mission.proto │ ├── net │ └── v0 │ │ └── net.proto │ ├── srs │ └── v0 │ │ └── srs.proto │ ├── timer │ └── v0 │ │ └── timer.proto │ ├── trigger │ └── v0 │ │ └── trigger.proto │ ├── unit │ └── v0 │ │ └── unit.proto │ └── world │ └── v0 │ └── world.proto ├── repl ├── Cargo.toml └── src │ └── main.rs ├── src ├── authentication.rs ├── config.rs ├── fps.rs ├── hot_reload.rs ├── integrity.rs ├── lib.rs ├── lua5.1 │ ├── include │ │ ├── LICENCE.txt │ │ ├── lauxlib.h │ │ ├── lua.h │ │ ├── lua.hpp │ │ ├── luaconf.h │ │ └── lualib.h │ └── lua.lib ├── rpc.rs ├── rpc │ ├── atmosphere.rs │ ├── coalition.rs │ ├── controller.rs │ ├── custom.rs │ ├── group.rs │ ├── hook.rs │ ├── metadata.rs │ ├── mission.rs │ ├── net.rs │ ├── srs.rs │ ├── timer.rs │ ├── trigger.rs │ ├── unit.rs │ └── world.rs ├── server.rs ├── shutdown.rs ├── srs.rs ├── stats.rs └── stream.rs ├── srs ├── Cargo.toml └── src │ ├── client.rs │ ├── lib.rs │ ├── message.rs │ ├── messages_codec.rs │ ├── stream.rs │ └── voice_codec.rs ├── stubs ├── Cargo.toml ├── build.rs └── src │ ├── atmosphere.rs │ ├── coalition.rs │ ├── common.rs │ ├── controller.rs │ ├── custom.rs │ ├── group.rs │ ├── hook.rs │ ├── lib.rs │ ├── metadata.rs │ ├── mission.rs │ ├── net.rs │ ├── srs.rs │ ├── timer.rs │ ├── trigger.rs │ ├── unit.rs │ ├── utils.rs │ └── world.rs └── tts ├── Cargo.toml └── src ├── aws.rs ├── azure.rs ├── gcloud.rs ├── lib.rs └── win.rs /.cargo/config.toml: -------------------------------------------------------------------------------- 1 | [env] 2 | LUA_LIB_NAME = "lua" 3 | LUA_LIB = { value = "src/lua5.1/", relative = true } 4 | LUA_INC = { value = "src/lua5.1/include", relative = true } 5 | 6 | # This env var must be set so that `prost-build` doesn't try to build protoc (as this would require 7 | # `cmake` to be installed). The value here doesn't matter, as it will be overriden to a valid path 8 | # (pointing to a bundled protoc from `protoc-bundled`) by `build.rs` later on. 9 | PROTOC = { value = "protoc.exe", relative = true } 10 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | src/lua5.1/** linguist-vendored -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: ["*"] 6 | pull_request: 7 | branches: [main] 8 | 9 | jobs: 10 | rust: 11 | name: Rust 12 | runs-on: windows-latest 13 | 14 | env: 15 | CARGO_TERM_COLOR: always 16 | 17 | steps: 18 | - name: Checkout code 19 | uses: actions/checkout@v4 20 | 21 | - uses: actions/cache@v4 22 | with: 23 | path: | 24 | ~/.cargo/bin/ 25 | ~/.cargo/registry/index/ 26 | ~/.cargo/registry/cache/ 27 | ~/.cargo/git/db/ 28 | target/ 29 | key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} 30 | 31 | - name: Install stable toolchain 32 | uses: dtolnay/rust-toolchain@stable 33 | with: 34 | toolchain: stable 35 | components: rustfmt, clippy 36 | 37 | - name: Build 38 | run: cargo build 39 | 40 | - name: Build with hot-reload feature 41 | run: cargo build --features hot-reload 42 | 43 | - name: Test 44 | run: cargo test --workspace 45 | 46 | - name: Lint 47 | run: cargo clippy --workspace -- -D warnings 48 | 49 | - name: Check formatting 50 | run: cargo fmt -- --check 51 | 52 | lua: 53 | name: Lua 54 | runs-on: ubuntu-latest 55 | 56 | steps: 57 | - name: Install luacheck 58 | run: | 59 | sudo apt-get install -y luarocks 60 | sudo luarocks install luacheck 61 | 62 | - name: Checkout code 63 | uses: actions/checkout@v4 64 | 65 | - name: Lint 66 | run: luacheck ./lua 67 | 68 | proto: 69 | name: Proto 70 | runs-on: ubuntu-latest 71 | 72 | steps: 73 | - name: Install protolint 74 | run: | 75 | mkdir -p "$HOME/.local/bin" 76 | echo "$HOME/.local/bin" >> $GITHUB_PATH 77 | wget -c https://github.com/yoheimuta/protolint/releases/download/v0.35.2/protolint_0.35.2_Linux_x86_64.tar.gz -O - \ 78 | | tar -xz -C $HOME/.local/bin protolint 79 | 80 | - name: Checkout code 81 | uses: actions/checkout@v4 82 | 83 | - name: Lint 84 | run: protolint lint protos/. 85 | 86 | linux: 87 | name: Linux 88 | runs-on: ubuntu-latest 89 | 90 | env: 91 | CARGO_TERM_COLOR: always 92 | 93 | steps: 94 | - name: Checkout code 95 | uses: actions/checkout@v4 96 | 97 | - uses: actions/cache@v4 98 | with: 99 | path: | 100 | ~/.cargo/bin/ 101 | ~/.cargo/registry/index/ 102 | ~/.cargo/registry/cache/ 103 | ~/.cargo/git/db/ 104 | target/ 105 | key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} 106 | 107 | - name: Install stable toolchain 108 | uses: dtolnay/rust-toolchain@stable 109 | with: 110 | toolchain: stable 111 | components: rustfmt, clippy 112 | 113 | - name: Builds on Linux 114 | run: cargo clippy --workspace -- -D warnings 115 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .* 2 | !.gitignore 3 | !.github 4 | !.cargo 5 | !.luacheckrc 6 | !.protolint.yaml 7 | /target 8 | scratchpad.txt 9 | grpcurl.exe 10 | /doc 11 | *.exe 12 | protos/google 13 | releases/ -------------------------------------------------------------------------------- /.luacheckrc: -------------------------------------------------------------------------------- 1 | std = "lua51" 2 | globals = { 3 | "GRPC", 4 | "grpc", 5 | } 6 | read_globals = { 7 | "AI", 8 | "atmosphere", 9 | "coalition", 10 | "net", 11 | "coord", 12 | "DCS", 13 | "env", 14 | "Group", 15 | "land", 16 | "lfs", 17 | "log", 18 | "Object", 19 | "StaticObject", 20 | "timer", 21 | "trigger", 22 | "Unit", 23 | "world", 24 | "missionCommands", 25 | "Export" 26 | } -------------------------------------------------------------------------------- /.protolint.yaml: -------------------------------------------------------------------------------- 1 | lint: 2 | rules_option: 3 | # MAX_LINE_LENGTH rule option. 4 | max_line_length: 5 | # Enforces a maximum line length 6 | max_chars: 100 7 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Introduction 2 | 3 | Thank you for contributing to DCS-gRPC. Before submitting your PR please read 4 | through the following guide. 5 | 6 | ## Communication 7 | 8 | This project uses Github for issue tracking and discussions however discussions 9 | can also take place on Discord for a quicker feedback cycle. We recommend 10 | joining https://discord.gg/9RqyTJt if you plan to make contributions. There is 11 | a `DCS-gRPC` category of channels and discussion takes place in the #developers 12 | channel. 13 | 14 | # Tenets 15 | 16 | ## KISS (Keep It Simple, Stupid) for clients 17 | 18 | DCS-gRPC clients can be written in a wide variety of languages, each with their 19 | own idiosyncracies. Therefore we will focus on keeping the gRPC interface as 20 | simple as possible even if this means some extra verbosity or complexity inside 21 | the DCS-gRPC server. 22 | 23 | ## Maintain consistency across the DCS-gRPC APIs 24 | 25 | Try and maintain consistency in call patterns and field names in the DCS-gRPC 26 | API. This may mean breaking from the conventions in the underlying ED APIs 27 | (See the next Tenet) 28 | 29 | ## Follow ED API conventions by default but do not be slaves to them. 30 | 31 | We will follow the ED API conventions by default but this is a guide rather 32 | than a rule. Renaming fields and APIs to make more sense is fine for example. 33 | 34 | # Contributing Guidelines 35 | 36 | ## Document the gRPC interface 37 | 38 | Add documentation to the gRPC .proto files using the proto-gen-doc format 39 | detailed at https://github.com/pseudomuto/protoc-gen-doc#writing-documentation 40 | 41 | ## Follow git commit message best practices 42 | 43 | Do not create 1 line commit messages for anything but the most trivial of commits. 44 | Follow the recommendations in this template by default. 45 | 46 | ```plain 47 | Capitalized, short (50 chars or less) summary 48 | 49 | More detailed explanatory text, if necessary. Wrap it to about 72 50 | characters or so. In some contexts, the first line is treated as the 51 | subject of an email and the rest of the text as the body. The blank 52 | line separating the summary from the body is critical (unless you omit 53 | the body entirely); tools like rebase can get confused if you run the 54 | two together. 55 | 56 | Try to pre-emptively answer any foreseeable "Why?" questions a reader 57 | may have. There is no size limit on commit messages. 58 | 59 | Write your commit message in the imperative: "Fix bug" and not "Fixed bug" 60 | or "Fixes bug." This convention matches up with commit messages generated 61 | by commands like git merge and git revert. 62 | 63 | Further paragraphs come after blank lines. 64 | 65 | - Bullet points are okay, too 66 | 67 | - Typically a hyphen or asterisk is used for the bullet, followed by a 68 | single space, with blank lines in between, but conventions vary here 69 | 70 | - Use a hanging indent 71 | 72 | If you use an issue tracker, add a reference(s) to them at the bottom, 73 | like so: 74 | 75 | Resolves: #123 76 | ``` 77 | 78 | ## Don't forget to update the `CHANGELOG.md` 79 | 80 | Don't forget to update the `CHANGELOG.md` file with your change in the 81 | `Unreleased` section 82 | 83 | ## Squash commits and rebase before merging 84 | 85 | When you are ready to merge then squash your commits so that they form a 86 | series of logical Atomic commits. Depending on the size of the change this 87 | might mean having one or a small series of commits. 88 | 89 | ## Use of linters 90 | 91 | This project makes use of the following tools to lint the lua and .proto files 92 | 93 | * [protolint](https://github.com/yoheimuta/protolint) 94 | * [luacheck](https://github.com/mpeterv/luacheck) 95 | 96 | It is not mandatory to run these yourself on your local machine however they 97 | are run as part of the automated checks when you create a pull request so it 98 | may save you time to run them yourself before-hand. 99 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | members = ["repl", "srs", "stubs", "tts"] 3 | resolver = "2" 4 | 5 | [workspace.package] 6 | version = "0.8.1" 7 | license = "AGPL-3.0-or-later" 8 | authors = ["Markus Ast "] 9 | rust-version = "1.85" 10 | edition = "2024" 11 | 12 | [workspace.dependencies] 13 | base64 = "0.22" 14 | bytes = "1.6" 15 | futures-util = { version = "0.3", features = ["sink"] } 16 | log = "0.4" 17 | serde = { version = "1.0", features = ["derive"] } 18 | serde_json = "1.0" 19 | thiserror = "2.0" 20 | tokio = { version = "1.37", features = [ 21 | "rt-multi-thread", 22 | "io-util", 23 | "net", 24 | "sync", 25 | "time", 26 | "parking_lot", 27 | "macros", 28 | ] } 29 | tokio-stream = { version = "0.1", features = ["sync"] } 30 | tonic = "0.13" 31 | 32 | [package] 33 | name = "dcs-grpc" 34 | version.workspace = true 35 | rust-version.workspace = true 36 | authors.workspace = true 37 | license.workspace = true 38 | edition.workspace = true 39 | 40 | [lib] 41 | crate-type = ["cdylib"] 42 | 43 | [dependencies] 44 | backoff = { version = "0.4", features = ["tokio"] } 45 | dcs-module-ipc = "0.9" 46 | futures-util.workspace = true 47 | igrf = "0.2" 48 | libloading = { version = "0.8", optional = true } 49 | log4rs = "1.0" 50 | log.workspace = true 51 | mlua = { version = "0.10", default-features = false, features = [ 52 | "lua51", 53 | "module", 54 | "serialize", 55 | ] } 56 | once_cell = "1.4.0" 57 | pin-project = "1.0" 58 | serde.workspace = true 59 | serde_json.workspace = true 60 | srs = { package = "dcs-grpc-srs", path = "./srs" } 61 | stubs = { package = "dcs-grpc-stubs", path = "./stubs", features = ["server"] } 62 | thiserror.workspace = true 63 | tts = { package = "dcs-grpc-tts", path = "./tts" } 64 | time = { version = "0.3", features = ["formatting", "parsing"] } 65 | tokio.workspace = true 66 | tokio-stream.workspace = true 67 | tonic.workspace = true 68 | tonic-middleware = "0.3" 69 | 70 | [build-dependencies] 71 | walkdir = "2.3" 72 | 73 | [features] 74 | default = [] 75 | hot-reload = ["libloading"] 76 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | build: 2 | cargo build --features hot-reload 3 | powershell copy target/debug/dcs_grpc.dll target/debug/dcs_grpc_hot_reload.dll 4 | 5 | watch: 6 | cargo watch --ignore version.lua -x "check --features hot-reload" 7 | 8 | test: 9 | cargo test 10 | -------------------------------------------------------------------------------- /build.rs: -------------------------------------------------------------------------------- 1 | use std::collections::hash_map::DefaultHasher; 2 | use std::fs::File; 3 | use std::hash::Hasher; 4 | use std::io::{BufReader, Read, Write}; 5 | use std::path::{Path, PathBuf}; 6 | 7 | use walkdir::WalkDir; 8 | 9 | fn main() { 10 | write_version_to_lua(); 11 | embed_lua_file_hashes(); 12 | } 13 | 14 | /// Write the current version into `lua/DCS-gRPC/version.lua` to be picked up by the Lua side of the 15 | /// server. 16 | fn write_version_to_lua() { 17 | println!("cargo:rerun-if-env-changed=CARGO_PKG_VERSION"); 18 | 19 | let path = PathBuf::from("./lua/DCS-gRPC/version.lua"); 20 | let mut out = File::create(path).unwrap(); 21 | writeln!(out, r#"-- this file is auto-generated on `cargo build`"#).unwrap(); 22 | writeln!(out, r#"GRPC.version = "{}""#, env!("CARGO_PKG_VERSION")).unwrap(); 23 | } 24 | 25 | /// Embed the hash of each Lua file into the binary to allow a runtime integrity check. 26 | fn embed_lua_file_hashes() { 27 | println!("cargo:rerun-if-changed=lua/DCS-gRPC"); 28 | println!("cargo:rerun-if-changed=lua/Hooks"); 29 | 30 | let path = PathBuf::from(std::env::var("OUT_DIR").unwrap()).join("lua_files.rs"); 31 | let mut out = File::create(path).unwrap(); 32 | 33 | for (ident, base_path) in [("DCS_GRPC", "./lua/DCS-gRPC"), ("HOOKS", "./lua/Hooks")] { 34 | writeln!(out, "#[allow(clippy::needless_raw_string_hashes)]").unwrap(); 35 | writeln!(out, "const {ident}: &[(&str, u64)] = &[").unwrap(); 36 | 37 | for entry in WalkDir::new(base_path) { 38 | let entry = entry.unwrap(); 39 | if !entry.metadata().unwrap().is_file() { 40 | continue; 41 | } 42 | 43 | let path = entry 44 | .path() 45 | .strip_prefix(base_path) 46 | .unwrap() 47 | .to_str() 48 | .expect("non-utf8 path"); 49 | let hash = file_hash(entry.path()); 50 | writeln!(out, r##" (r#"{path}"#, {hash}),"##).unwrap(); 51 | eprintln!("{}", entry.path().display()); 52 | } 53 | 54 | writeln!(out, "];").unwrap(); 55 | } 56 | } 57 | 58 | fn file_hash(path: &Path) -> u64 { 59 | // Not a cryptographic hasher, but good enough for our use-case. 60 | let mut hasher = DefaultHasher::new(); 61 | let mut buffer = [0; 1024]; 62 | let file = File::open(path).unwrap(); 63 | let mut reader = BufReader::new(file); 64 | 65 | loop { 66 | let count = reader.read(&mut buffer).unwrap(); 67 | if count == 0 { 68 | break; 69 | } 70 | hasher.write(&buffer[..count]); 71 | } 72 | 73 | hasher.finish() 74 | } 75 | -------------------------------------------------------------------------------- /lua/DCS-gRPC/exporters/object.lua: -------------------------------------------------------------------------------- 1 | -- 2 | -- Converts DCS tables in the Object hierarchy into tables suitable for 3 | -- serialization into GRPC responses 4 | -- Each exporter has an equivalent .proto Message defined and they must 5 | -- be kept in sync 6 | -- 7 | 8 | GRPC.exporters.position = function(pos) 9 | local lat, lon, alt = coord.LOtoLL(pos) 10 | return { 11 | lat = lat, 12 | lon = lon, 13 | alt = alt, 14 | u = pos.z, 15 | v = pos.x, 16 | } 17 | end 18 | 19 | GRPC.exporters.unit = function(unit) 20 | return { 21 | id = tonumber(unit:getID()), 22 | name = unit:getName(), 23 | callsign = unit:getCallsign(), 24 | coalition = unit:getCoalition() + 1, -- Increment for non zero-indexed gRPC enum 25 | type = unit:getTypeName(), 26 | playerName = Unit.getPlayerName(unit), 27 | group = GRPC.exporters.group(Unit.getGroup(unit)), 28 | numberInGroup = unit:getNumber(), 29 | rawTransform = GRPC.exporters.rawTransform(unit), 30 | } 31 | end 32 | 33 | -- Data used to calculate position/orientation/velocity on the Rust side. 34 | GRPC.exporters.rawTransform = function(object) 35 | local p = object:getPosition() 36 | local position = GRPC.exporters.position(p.p) 37 | return { 38 | position = position, 39 | positionNorth = coord.LLtoLO(position.lat + 1, position.lon), 40 | forward = p.x, 41 | right = p.z, 42 | up = p.y, 43 | velocity = object:getVelocity(), 44 | } 45 | end 46 | 47 | GRPC.exporters.group = function(group) 48 | return { 49 | id = tonumber(group:getID()), 50 | name = group:getName(), 51 | coalition = group:getCoalition() + 1, -- Increment for non zero-indexed gRPC enum 52 | category = group:getCategory() + 1, -- Increment for non zero-indexed gRPC enum 53 | } 54 | end 55 | 56 | GRPC.exporters.weapon = function(weapon) 57 | return { 58 | id = tonumber(weapon:getName()), 59 | type = weapon:getTypeName(), 60 | rawTransform = GRPC.exporters.rawTransform(weapon), 61 | } 62 | end 63 | 64 | GRPC.exporters.static = function(static) 65 | return { 66 | id = tonumber(static:getID()), 67 | type = static:getTypeName(), 68 | name = static:getName(), 69 | coalition = static:getCoalition() + 1, -- Increment for non zero-indexed gRPC enum 70 | position = GRPC.exporters.position(static:getPoint()), 71 | } 72 | end 73 | 74 | GRPC.exporters.airbase = function(airbase) 75 | local a = { 76 | name = airbase:getName(), 77 | callsign = airbase:getCallsign(), 78 | coalition = airbase:getCoalition() + 1, -- Increment for non zero-indexed gRPC enum 79 | category = airbase:getDesc()['category'] + 1, -- Increment for non zero-indexed gRPC enum 80 | displayName = airbase:getDesc()['displayName'], 81 | position = GRPC.exporters.position(airbase:getPoint()) 82 | } 83 | 84 | local unit = airbase:getUnit() 85 | if unit then 86 | a.unit = GRPC.exporters.unit(unit) 87 | end 88 | 89 | return a 90 | end 91 | 92 | GRPC.exporters.scenery = function(scenery) 93 | return { 94 | id = tonumber(scenery:getName()), 95 | type = scenery:getTypeName(), 96 | position = GRPC.exporters.position(scenery:getPoint()), 97 | } 98 | end 99 | 100 | GRPC.exporters.cargo = function() 101 | return {} 102 | end 103 | 104 | -- every object, even an unknown one, should at least have getName implemented as it is 105 | -- in the base object of the hierarchy 106 | -- https://wiki.hoggitworld.com/view/DCS_Class_Object 107 | GRPC.exporters.unknown = function(object) 108 | return { 109 | name = tostring(object:getName()), 110 | } 111 | end 112 | 113 | GRPC.exporters.markPanel = function(markPanel) 114 | local mp = { 115 | id = markPanel.idx, 116 | time = markPanel.time, 117 | text = markPanel.text, 118 | position = GRPC.exporters.position(markPanel.pos), 119 | } 120 | 121 | if markPanel.initiator then 122 | mp.initiator = GRPC.exporters.unit(markPanel.initiator) 123 | end 124 | 125 | if (markPanel.coalition >= 0 and markPanel.coalition <= 2) then 126 | mp.coalition = markPanel.coalition + 1; -- Increment for non zero-indexed gRPC enum 127 | end 128 | 129 | if (markPanel.groupID > 0) then 130 | mp.groupId = markPanel.groupID; 131 | end 132 | 133 | return mp 134 | end 135 | -------------------------------------------------------------------------------- /lua/DCS-gRPC/grpc-hook.lua: -------------------------------------------------------------------------------- 1 | -- note: the hook's load will only fire after the mission loaded. 2 | local function load() 3 | log.write("[GRPC-Hook]", log.INFO, "mission loaded, setting up gRPC listener ...") 4 | 5 | -- Let DCS know where to find the DLLs 6 | if not string.find(package.cpath, GRPC.dllPath) then 7 | package.cpath = package.cpath .. [[;]] .. GRPC.dllPath .. [[?.dll;]] 8 | end 9 | 10 | local ok, grpc = pcall(require, "dcs_grpc_hot_reload") 11 | if ok then 12 | log.write("[GRPC-Hook]", log.INFO, "loaded hot reload version") 13 | else 14 | grpc = require("dcs_grpc") 15 | end 16 | 17 | _G.grpc = grpc 18 | assert(pcall(assert(loadfile(GRPC.luaPath .. [[grpc.lua]])))) 19 | 20 | log.write("[GRPC-Hook]", log.INFO, "gRPC listener set up.") 21 | end 22 | 23 | local handler = {} 24 | 25 | function handler.onMissionLoadEnd() 26 | local ok, err = pcall(load) 27 | if not ok then 28 | log.write("[GRPC-Hook]", log.ERROR, "Failed to set up gRPC listener: "..tostring(err)) 29 | end 30 | end 31 | 32 | function handler.onSimulationFrame() 33 | if GRPC.onSimulationFrame then 34 | GRPC.onSimulationFrame() 35 | end 36 | end 37 | 38 | function handler.onSimulationStop() 39 | log.write("[GRPC-Hook]", log.INFO, "simulation stopped, shutting down gRPC listener ...") 40 | 41 | GRPC.stop() 42 | grpc = nil 43 | end 44 | 45 | -- None of these methods should return anything as doing so breaks other scripts attempting to 46 | -- react to the hook as well. 47 | 48 | function handler.onPlayerTrySendChat(playerID, msg) 49 | -- note: currently `all` (third parameter) will always `=true` regardless if the target is to the coalition/team 50 | -- or to everybody. When ED fixes this, implementation should determine the dcs.common.v0.Coalition 51 | 52 | grpc.event({ 53 | time = DCS.getModelTime(), 54 | event = { 55 | type = "playerSendChat", 56 | playerId = playerID, 57 | message = msg 58 | }, 59 | }) 60 | 61 | end 62 | 63 | function handler.onPlayerTryConnect(addr, name, ucid, id) 64 | grpc.event({ 65 | time = DCS.getModelTime(), 66 | event = { 67 | type = "connect", 68 | addr = addr, 69 | name = name, 70 | ucid = ucid, 71 | id = id, 72 | }, 73 | }) 74 | end 75 | 76 | function handler.onPlayerDisconnect(id, reason) 77 | grpc.event({ 78 | time = DCS.getModelTime(), 79 | event = { 80 | type = "disconnect", 81 | id = id, 82 | reason = reason + 1, -- Increment for non zero-indexed gRPC enum 83 | }, 84 | }) 85 | end 86 | 87 | function handler.onPlayerChangeSlot(playerId) 88 | local playerInfo = net.get_player_info(playerId) 89 | 90 | grpc.event({ 91 | time = DCS.getModelTime(), 92 | event = { 93 | type = "playerChangeSlot", 94 | playerId = playerId, 95 | coalition = playerInfo.side + 1, -- offsetting for grpc COALITION enum 96 | slotId = playerInfo.slot 97 | }, 98 | }) 99 | end 100 | 101 | DCS.setUserCallbacks(handler) 102 | -------------------------------------------------------------------------------- /lua/DCS-gRPC/grpc-mission.lua: -------------------------------------------------------------------------------- 1 | if not GRPC then 2 | GRPC = { 3 | -- scaffold nested tables to allow direct assignment in config file 4 | tts = { provider = { gcloud = {}, aws = {}, azure = {}, win = {} } }, 5 | srs = {}, 6 | auth = { tokens = {} } 7 | } 8 | end 9 | 10 | -- load settings from `Saved Games/DCS/Config/dcs-grpc.lua` 11 | do 12 | env.info("[GRPC] Checking optional config at `Config/dcs-grpc.lua` ...") 13 | local file, err = io.open(lfs.writedir() .. [[Config\dcs-grpc.lua]], "r") 14 | if file then 15 | local f = assert(loadstring(file:read("*all"))) 16 | setfenv(f, GRPC) 17 | f() 18 | env.info("[GRPC] `Config/dcs-grpc.lua` successfully read") 19 | else 20 | env.info("[GRPC] `Config/dcs-grpc.lua` not found (" .. tostring(err) .. ")") 21 | end 22 | end 23 | 24 | -- Set default settings. 25 | if not GRPC.luaPath then 26 | GRPC.luaPath = lfs.writedir() .. [[Scripts\DCS-gRPC\]] 27 | end 28 | if not GRPC.dllPath then 29 | GRPC.dllPath = lfs.writedir() .. [[Mods\tech\DCS-gRPC\]] 30 | end 31 | if GRPC.throughputLimit == nil or GRPC.throughputLimit == 0 or type(GRPC.throughputLimit) ~= "number" then 32 | GRPC.throughputLimit = 600 33 | end 34 | 35 | -- load version 36 | dofile(GRPC.luaPath .. [[version.lua]]) 37 | 38 | -- Let DCS know where to find the DLLs 39 | if not string.find(package.cpath, GRPC.dllPath) then 40 | package.cpath = package.cpath .. [[;]] .. GRPC.dllPath .. [[?.dll;]] 41 | end 42 | 43 | -- Load DLL before `require` gets sanitized. 44 | local ok, grpc = pcall(require, "dcs_grpc_hot_reload") 45 | if ok then 46 | env.info("[GRPC] loaded hot reload version") 47 | else 48 | grpc = require("dcs_grpc") 49 | end 50 | 51 | -- Keep a reference to `lfs` before it gets sanitized 52 | local lfs = _G.lfs 53 | 54 | local loaded = false 55 | function GRPC.load() 56 | if loaded then 57 | env.info("[GRPC] already loaded") 58 | return 59 | end 60 | 61 | local env = setmetatable({grpc = grpc, lfs = lfs}, {__index = _G}) 62 | local f = setfenv(assert(loadfile(GRPC.luaPath .. [[grpc.lua]])), env) 63 | f() 64 | 65 | loaded = true 66 | end 67 | 68 | if GRPC.autostart == true then 69 | env.info("[GRPC] auto starting") 70 | GRPC.load() 71 | end 72 | -------------------------------------------------------------------------------- /lua/DCS-gRPC/grpc.lua: -------------------------------------------------------------------------------- 1 | local isMissionEnv = DCS == nil 2 | 3 | if isMissionEnv then 4 | env.info("[GRPC] mission loading ...") 5 | end 6 | 7 | -- 8 | -- load and start RPC 9 | -- 10 | 11 | if isMissionEnv then 12 | assert(grpc.start({ 13 | version = GRPC.version, 14 | writeDir = lfs.writedir(), 15 | dllPath = GRPC.dllPath, 16 | luaPath = GRPC.luaPath, 17 | host = GRPC.host, 18 | port = GRPC.port, 19 | debug = GRPC.debug, 20 | evalEnabled = GRPC.evalEnabled, 21 | integrityCheckDisabled = GRPC.integrityCheckDisabled, 22 | tts = GRPC.tts, 23 | srs = GRPC.srs, 24 | auth = GRPC.auth 25 | })) 26 | end 27 | 28 | 29 | -- 30 | -- Export methods 31 | -- 32 | 33 | GRPC.exporters = {} 34 | dofile(GRPC.luaPath .. [[exporters\object.lua]]) 35 | 36 | -- 37 | -- Helper methods 38 | -- 39 | 40 | GRPC.success = function(result) 41 | return { 42 | result = result 43 | } 44 | end 45 | 46 | GRPC.error = function(msg) 47 | return { 48 | error = { 49 | message = msg, 50 | } 51 | } 52 | end 53 | 54 | -- 55 | -- APIs exposed to Lua 56 | -- 57 | GRPC.tts = grpc.tts 58 | 59 | -- 60 | -- Logging methods 61 | -- 62 | 63 | GRPC.logError = function(msg) 64 | grpc.logError(msg) 65 | 66 | if isMissionEnv then 67 | env.error("[GRPC] "..msg) 68 | else 69 | log.write("[GRPC-Hook]", log.ERROR, msg) 70 | end 71 | end 72 | 73 | GRPC.logWarning = function(msg) 74 | grpc.logWarning(msg) 75 | 76 | if isMissionEnv then 77 | env.info("[GRPC] "..msg) 78 | else 79 | log.write("[GRPC-Hook]", log.WARNING, msg) 80 | end 81 | end 82 | 83 | GRPC.logInfo = function(msg) 84 | grpc.logInfo(msg) 85 | if isMissionEnv then 86 | env.info("[GRPC] "..msg) 87 | else 88 | log.write("[GRPC-Hook]", log.INFO, msg) 89 | end 90 | end 91 | 92 | GRPC.logDebug = function(msg) 93 | grpc.logDebug(msg) 94 | end 95 | 96 | --- The client specified an invalid argument 97 | GRPC.errorInvalidArgument = function(msg) 98 | return { 99 | error = { 100 | type = "INVALID_ARGUMENT", 101 | message = msg, 102 | } 103 | } 104 | end 105 | 106 | --- Some requested entity was not found. 107 | GRPC.errorNotFound = function(msg) 108 | return { 109 | error = { 110 | type = "NOT_FOUND", 111 | message = msg, 112 | } 113 | } 114 | end 115 | 116 | --- The entity that a client attempted to create already exists. 117 | GRPC.errorAlreadyExists = function(msg) 118 | return { 119 | error = { 120 | type = "ALREADY_EXISTS", 121 | message = msg, 122 | } 123 | } 124 | end 125 | 126 | --- The operation is not implemented or is not supported/enabled in this service. 127 | GRPC.errorUnimplemented = function(msg) 128 | return { 129 | error = { 130 | type = "UNIMPLEMENTED", 131 | message = msg, 132 | } 133 | } 134 | end 135 | 136 | --- The caller does not have permission to execute the specified operation. 137 | GRPC.errorPermissionDenied = function(msg) 138 | return { 139 | error = { 140 | type = "PERMISSION_DENIED", 141 | message = msg, 142 | } 143 | } 144 | end 145 | 146 | GRPC.event = grpc.event 147 | -- 148 | -- RPC methods 149 | -- 150 | 151 | GRPC.methods = {} 152 | dofile(GRPC.luaPath .. [[methods\atmosphere.lua]]) 153 | dofile(GRPC.luaPath .. [[methods\coalitions.lua]]) 154 | dofile(GRPC.luaPath .. [[methods\controllers.lua]]) 155 | dofile(GRPC.luaPath .. [[methods\custom.lua]]) 156 | dofile(GRPC.luaPath .. [[methods\group.lua]]) 157 | dofile(GRPC.luaPath .. [[methods\hook.lua]]) 158 | dofile(GRPC.luaPath .. [[methods\mission.lua]]) 159 | dofile(GRPC.luaPath .. [[methods\net.lua]]) 160 | dofile(GRPC.luaPath .. [[methods\timer.lua]]) 161 | dofile(GRPC.luaPath .. [[methods\trigger.lua]]) 162 | dofile(GRPC.luaPath .. [[methods\unit.lua]]) 163 | dofile(GRPC.luaPath .. [[methods\world.lua]]) 164 | 165 | -- 166 | -- RPC request handler 167 | -- 168 | 169 | local stopped = false 170 | GRPC.stop = function() 171 | grpc.stop() 172 | stopped = true 173 | end 174 | 175 | local function handleRequest(method, params) 176 | local fn = GRPC.methods[method] 177 | 178 | if type(fn) == "function" then 179 | local ok, result = xpcall(function() return fn(params) end, debug.traceback) 180 | if ok then 181 | return result 182 | else 183 | GRPC.logError("error executing "..method..": "..tostring(result)) 184 | return { 185 | error = tostring(result) 186 | } 187 | end 188 | else 189 | return { 190 | error = "unsupported method "..method 191 | } 192 | end 193 | end 194 | 195 | local MISSION_ENV = 1 196 | local HOOK_ENV = 2 197 | 198 | -- Adjust the interval at which the gRPC server is polled for requests based on the throughput 199 | -- limit. The higher the throughput limit, the more often the gRPC is polled per second. 200 | local interval = math.max(0.03, math.min(1.0, 16 / GRPC.throughputLimit)) 201 | local callsPerTick = math.ceil(GRPC.throughputLimit * interval) 202 | 203 | if isMissionEnv then 204 | GRPC.logInfo( 205 | "Limit request execution at max. " .. tostring(callsPerTick) .. " calls every " .. 206 | tostring(interval) .. "s (≙ throughput of " .. tostring(GRPC.throughputLimit) .. ")" 207 | ) 208 | 209 | -- execute gRPC requests 210 | local function next() 211 | local i = 0 212 | while grpc.next(MISSION_ENV, handleRequest) do 213 | i = i + 1 214 | if i >= callsPerTick then 215 | break 216 | end 217 | end 218 | end 219 | 220 | -- scheduel gRPC request execution 221 | timer.scheduleFunction(function() 222 | if not stopped then 223 | local ok, err = pcall(next) 224 | if not ok then 225 | GRPC.logError("Error retrieving next command: "..tostring(err)) 226 | end 227 | 228 | return timer.getTime() + interval -- return time of next call 229 | end 230 | end, nil, timer.getTime() + interval) 231 | 232 | -- listen for events 233 | local eventHandler = {} 234 | function eventHandler:onEvent(event) 235 | local _ = self -- make linter happy 236 | 237 | if not stopped then 238 | local ok, result = xpcall(function() return GRPC.onDcsEvent(event) end, debug.traceback) 239 | if ok then 240 | if result ~= nil then 241 | grpc.event(result) 242 | if result.event.type == "missionEnd" then 243 | GRPC.stop() 244 | end 245 | end 246 | else 247 | GRPC.logError("Error in event handler: "..tostring(result)) 248 | end 249 | end 250 | end 251 | world.addEventHandler(eventHandler) 252 | else -- hook env 253 | -- execute gRPC requests 254 | local function next() 255 | local i = 0 256 | while grpc.next(HOOK_ENV, handleRequest) do 257 | i = i + 1 258 | if i > callsPerTick then 259 | break 260 | end 261 | end 262 | end 263 | 264 | -- scheduel gRPC request execution 265 | local skipFrames = math.ceil(interval / 0.016) -- 0.016 = 16ms = 1 frame at 60fps 266 | local frame = 0 267 | function GRPC.onSimulationFrame() 268 | grpc.simulationFrame(DCS.getModelTime()) 269 | 270 | frame = frame + 1 271 | if frame >= skipFrames then 272 | frame = 0 273 | local ok, err = pcall(next) 274 | if not ok then 275 | GRPC.logError("Error retrieving next command: "..tostring(err)) 276 | end 277 | end 278 | end 279 | end 280 | 281 | if isMissionEnv then 282 | env.info("[GRPC] loaded ...") 283 | end 284 | -------------------------------------------------------------------------------- /lua/DCS-gRPC/methods/atmosphere.lua: -------------------------------------------------------------------------------- 1 | -- 2 | -- RPC atmosphere actions 3 | -- https://wiki.hoggitworld.com/view/DCS_singleton_atmosphere 4 | -- 5 | 6 | GRPC.methods.getWind = function(params) 7 | local point = coord.LLtoLO(params.position.lat, params.position.lon, params.position.alt) 8 | 9 | return GRPC.success(atmosphere.getWind(point)) 10 | end 11 | 12 | GRPC.methods.getWindWithTurbulence = function(params) 13 | local point = coord.LLtoLO(params.position.lat, params.position.lon, params.position.alt) 14 | 15 | return GRPC.success(atmosphere.getWindWithTurbulence(point)) 16 | end 17 | 18 | GRPC.methods.getTemperatureAndPressure = function(params) 19 | local point = coord.LLtoLO(params.position.lat, params.position.lon, params.position.alt) 20 | 21 | local temperature, pressure = atmosphere.getTemperatureAndPressure(point) 22 | 23 | return GRPC.success( 24 | { 25 | temperature = temperature, -- Kelvin 26 | pressure = pressure -- Pascals 27 | } 28 | ) 29 | end -------------------------------------------------------------------------------- /lua/DCS-gRPC/methods/controllers.lua: -------------------------------------------------------------------------------- 1 | local group_option_category = {} 2 | group_option_category[1] = "Air" 3 | group_option_category[2] = "Ground" 4 | group_option_category[3] = "Naval" 5 | 6 | GRPC.methods.setAlarmState = function(params) 7 | if params.alarmState == 0 then 8 | return GRPC.errorInvalidArgument("alarm_state cannot be unspecified (0)") 9 | end 10 | 11 | local obj 12 | if params.name.groupName then 13 | obj = Group.getByName(params.name.groupName) 14 | elseif params.name.unitName then 15 | obj = Unit.getByName(params.name.unitName) 16 | else 17 | return GRPC.errorInvalidArgument("No Group or Unit name provided") 18 | end 19 | 20 | if obj == nil then 21 | return GRPC.errorNotFound("Could not find group or unit with provided name") 22 | end 23 | 24 | local controller = obj:getController() 25 | local category_id = obj:getCategory() 26 | 27 | local state_id = AI['Option'][group_option_category[category_id]]['id']['ALARM_STATE'] 28 | 29 | controller:setOption(state_id, params.alarmState - 1) 30 | 31 | return GRPC.success({}) 32 | end 33 | 34 | GRPC.methods.getDetectedTargets = function(params) 35 | local unit = Unit.getByName(params.unitName) 36 | if unit == nil then 37 | return GRPC.errorNotFound("Could not find radar unit with name '" .. params.unitName .. "'") 38 | end 39 | 40 | local controller = Unit.getController(unit) 41 | local targets 42 | if params.detectionType == 0 or params.detectionType == nil then 43 | targets = controller:getDetectedTargets() 44 | else 45 | -- int value from https://wiki.hoggitworld.com/view/DCS_func_getDetectedTargets 46 | targets = controller:getDetectedTargets(params.detectionType) 47 | end 48 | 49 | if targets == nil then 50 | return GRPC.success({ 51 | contacts = targets 52 | }) 53 | end 54 | 55 | local results = {} 56 | 57 | for i, contact in ipairs(targets) do 58 | local category = Object.getCategory(contact.object) 59 | 60 | if category == nil then 61 | return GRPC.errorNotFound("Could not find target with id '" .. contact.object:getID() .. "'") 62 | end 63 | 64 | local result = { 65 | distance = contact.distance, 66 | id = contact.object.id_, 67 | visible = contact.visible, 68 | target = {} 69 | } 70 | 71 | --If target is a unit 72 | if category == 1 then 73 | if params.includeObject == true then 74 | result.target.unit = GRPC.exporters.unit( contact.object ) 75 | else 76 | result.target.object = GRPC.exporters.unknown( contact.object ) 77 | end 78 | end 79 | --If target is a weapon 80 | if category == 2 then 81 | if params.includeObject == true then 82 | result.target.weapon = GRPC.exporters.weapon( contact.object ) 83 | else 84 | result.target.object = GRPC.exporters.unknown( contact.object ) 85 | end 86 | end 87 | 88 | results[i] = result 89 | end 90 | 91 | return GRPC.success({ 92 | contacts = results 93 | }) 94 | end -------------------------------------------------------------------------------- /lua/DCS-gRPC/methods/custom.lua: -------------------------------------------------------------------------------- 1 | -- 2 | -- APIs for functions that are not built-in to the DCS Mission Scripting Environment 3 | -- 4 | 5 | GRPC.methods.requestMissionAssignment = function() 6 | return GRPC.errorUnimplemented("This method is not implemented") 7 | end 8 | 9 | GRPC.methods.joinMission = function() 10 | return GRPC.errorUnimplemented("This method is not implemented") 11 | end 12 | 13 | GRPC.methods.abortMission = function() 14 | return GRPC.errorUnimplemented("This method is not implemented") 15 | end 16 | 17 | GRPC.methods.getMissionStatus = function() 18 | return GRPC.errorUnimplemented("This method is not implemented") 19 | end 20 | 21 | GRPC.methods.missionEval = function(params) 22 | local fn, err = loadstring(params.lua) 23 | if not fn then 24 | return GRPC.error("Failed to load Lua code: "..err) 25 | end 26 | 27 | local ok, result = pcall(fn) 28 | if not ok then 29 | return GRPC.error("Failed to execute Lua code: "..result) 30 | end 31 | 32 | return GRPC.success(net.lua2json(result)) 33 | end 34 | -------------------------------------------------------------------------------- /lua/DCS-gRPC/methods/group.lua: -------------------------------------------------------------------------------- 1 | -- 2 | -- RPC unit actions 3 | -- https://wiki.hoggitworld.com/view/DCS_Class_Group 4 | -- 5 | 6 | local GRPC = GRPC 7 | 8 | GRPC.methods.getUnits = function(params) 9 | -- https://wiki.hoggitworld.com/view/DCS_func_getByName 10 | local group = Group.getByName(params.groupName) 11 | if group == nil then 12 | return GRPC.errorNotFound("group does not exist") 13 | end 14 | 15 | -- https://wiki.hoggitworld.com/view/DCS_func_getUnits 16 | local units = group:getUnits() 17 | 18 | local result = {} 19 | for i, unit in ipairs(units) do 20 | if params.active == nil or params.active == unit:isActive() then 21 | result[i] = GRPC.exporters.unit(unit) 22 | end 23 | end 24 | 25 | return GRPC.success({units = result}) 26 | end 27 | 28 | GRPC.methods.groupActivate = function(params) 29 | -- https://wiki.hoggitworld.com/view/DCS_func_activate 30 | local group = Group.getByName(params.groupName) 31 | if group == nil then 32 | return GRPC.errorNotFound("group does not exist") 33 | end 34 | 35 | group:activate() 36 | 37 | return GRPC.success({}) 38 | end 39 | 40 | GRPC.methods.groupDestroy = function(params) 41 | -- https://wiki.hoggitworld.com/view/DCS_func_destroy 42 | local group = Group.getByName(params.groupName) 43 | if group == nil then 44 | return GRPC.errorNotFound("group does not exist") 45 | end 46 | 47 | group:destroy() 48 | 49 | return GRPC.success({}) 50 | end -------------------------------------------------------------------------------- /lua/DCS-gRPC/methods/hook.lua: -------------------------------------------------------------------------------- 1 | -- 2 | -- Hook actions 3 | -- Docs: /DCS World/API/DCS_ControlAPI.html 4 | -- 5 | 6 | local DCS = DCS 7 | local GRPC = GRPC 8 | local net = net 9 | local Export = Export 10 | 11 | GRPC.methods.getMissionName = function() 12 | return GRPC.success({name = DCS.getMissionName()}) 13 | end 14 | 15 | GRPC.methods.getMissionFilename = function() 16 | return GRPC.success({name = DCS.getMissionFilename()}) 17 | end 18 | 19 | GRPC.methods.getMissionDescription = function() 20 | return GRPC.success({description = DCS.getMissionDescription()}) 21 | end 22 | 23 | GRPC.methods.reloadCurrentMission = function() 24 | net.load_mission(DCS.getMissionFilename()) 25 | return GRPC.success({}) 26 | end 27 | 28 | GRPC.methods.loadNextMission = function() 29 | return GRPC.success({loaded = net.load_next_mission()}) 30 | end 31 | 32 | GRPC.methods.loadMission = function(params) 33 | return GRPC.success({loaded = net.load_mission(params.fileName)}) 34 | end 35 | 36 | GRPC.methods.getPaused = function() 37 | return GRPC.success({paused = DCS.getPause()}) 38 | end 39 | 40 | GRPC.methods.setPaused = function(params) 41 | DCS.setPause(params.paused) 42 | return GRPC.success({}) 43 | end 44 | 45 | GRPC.methods.stopMission = function() 46 | DCS.stopMission() 47 | return GRPC.success({}) 48 | end 49 | 50 | GRPC.methods.exitProcess = function() 51 | DCS.exitProcess() 52 | return GRPC.success({}) 53 | end 54 | 55 | GRPC.methods.hookEval = function(params) 56 | local fn, err = loadstring(params.lua) 57 | if not fn then 58 | return GRPC.error("Failed to load Lua code: "..err) 59 | end 60 | 61 | local ok, result = pcall(fn) 62 | if not ok then 63 | return GRPC.error("Failed to execute Lua code: "..result) 64 | end 65 | 66 | return GRPC.success(net.lua2json(result)) 67 | end 68 | 69 | GRPC.methods.isMultiplayer = function() 70 | return GRPC.success({multiplayer = DCS.isMultiplayer()}) 71 | end 72 | 73 | GRPC.methods.isServer = function() 74 | return GRPC.success({server = DCS.isServer()}) 75 | end 76 | 77 | GRPC.methods.banPlayer = function(params) 78 | if params.id == 1 then 79 | return GRPC.errorInvalidArgument("Cannot ban the server user") 80 | end 81 | 82 | local player_id = net.get_player_info(params.id, "id") 83 | 84 | if not player_id then 85 | return GRPC.errorNotFound("Could not find player with the ID of " .. params.id) 86 | end 87 | 88 | return GRPC.success({banned = net.banlist_add(params.id, params.period, params.reason)}) 89 | end 90 | 91 | GRPC.methods.unbanPlayer = function(params) 92 | return GRPC.success({unbanned = net.banlist_remove(params.ucid)}) 93 | end 94 | 95 | GRPC.methods.getBannedPlayers = function() 96 | local result = {} 97 | 98 | for i, detail in ipairs(net.banlist_get()) do 99 | result[i] = { 100 | ucid = detail.ucid, 101 | ipAddress = detail.ipaddr, 102 | playerName = detail.name, 103 | reason = detail.reason, 104 | bannedFrom = detail.banned_from, 105 | bannedUntil = detail.banned_until 106 | } 107 | end 108 | 109 | return GRPC.success({bans = result}) 110 | end 111 | 112 | GRPC.methods.getUnitType = function(params) 113 | -- https://wiki.hoggitworld.com/view/DCS_func_getUnitType 114 | local unit_type = DCS.getUnitType(params.id) 115 | -- getUnitType returns an empty string if the unit doesn't exist, ensure we catch eventual nils too 116 | if unit_type == nil or unit_type == "" then 117 | return GRPC.errorNotFound("unit `" .. tostring(params.id) .. "` does not exist") 118 | end 119 | 120 | return GRPC.success({type = unit_type}) 121 | end 122 | 123 | GRPC.methods.getRealTime = function() 124 | -- https://wiki.hoggitworld.com/view/DCS_func_getRealTime 125 | return GRPC.success({time = DCS.getRealTime()}) 126 | end 127 | 128 | GRPC.methods.getBallisticsCount = function() 129 | local ballistics = Export.LoGetWorldObjects("ballistic") 130 | local count = 0 131 | for _ in pairs(ballistics) do count = count + 1 end 132 | return GRPC.success({count = count}) 133 | end 134 | 135 | -------------------------------------------------------------------------------- /lua/DCS-gRPC/methods/net.lua: -------------------------------------------------------------------------------- 1 | -- 2 | -- RPC net actions 3 | -- https://wiki.hoggitworld.com/view/DCS_singleton_net 4 | -- 5 | 6 | GRPC.methods.sendChatTo = function(params) 7 | -- note: it was explicitly decided not to place "from player id" parameter 8 | -- due to the magnitude of a social attack vector. 9 | -- https://github.com/DCS-gRPC/rust-server/pull/94#discussion_r780777794 10 | net.send_chat_to(params.message, params.targetPlayerId) 11 | return GRPC.success({}) 12 | end 13 | 14 | GRPC.methods.sendChat = function(params) 15 | if params.coalition > 1 then 16 | return GRPC.errorInvalidArgument("Chat messages can only be sent to all or neutral/spectators") 17 | end 18 | 19 | local toAll = params.coalition ~= 1 20 | net.send_chat(params.message, toAll) 21 | return GRPC.success({}) 22 | end 23 | 24 | GRPC.methods.getPlayers = function() 25 | local players = {}; 26 | 27 | for _,v in pairs(net.get_player_list()) do 28 | local playerInfo = net.get_player_info(v); 29 | 30 | table.insert(players, { 31 | id = playerInfo.id, 32 | name = playerInfo.name, 33 | coalition = playerInfo.side + 1, -- common.Coalition enum offset 34 | slot = playerInfo.slot, 35 | ping = playerInfo.ping, 36 | remoteAddress = playerInfo.ipaddr, 37 | ucid = playerInfo.ucid, 38 | locale = playerInfo.lang 39 | }) 40 | end 41 | 42 | return GRPC.success({players = players}) 43 | end 44 | 45 | GRPC.methods.forcePlayerSlot = function(params) 46 | if params.coalition == 0 then 47 | return GRPC.errorInvalidArgument("Cannot force a player into the COALITION_ALL") 48 | end 49 | 50 | local normalizedCoalition = params.coalition - 1; -- adjusted for grpc offset 51 | net.force_player_slot(params.playerId, normalizedCoalition, params.slotId) 52 | 53 | return GRPC.success({}) 54 | end 55 | 56 | GRPC.methods.kickPlayer = function(params) 57 | if params.id == 1 then 58 | return GRPC.errorInvalidArgument("Cannot kick the server user") 59 | end 60 | 61 | local player_id = net.get_player_info(params.id, "id") 62 | 63 | if not player_id then 64 | return GRPC.errorNotFound("Could not find player with the ID of " .. params.id) 65 | end 66 | 67 | net.kick(params.id, params.message) 68 | return GRPC.success({}) 69 | end 70 | -------------------------------------------------------------------------------- /lua/DCS-gRPC/methods/timer.lua: -------------------------------------------------------------------------------- 1 | -- 2 | -- RPC timer functions 3 | -- https://wiki.hoggitworld.com/view/DCS_singleton_timer 4 | -- 5 | 6 | -- https://wiki.hoggitworld.com/view/DCS_func_getTime 7 | GRPC.methods.getTime = function() 8 | return GRPC.success( 9 | { 10 | time = timer.getTime() 11 | } 12 | ) 13 | end 14 | 15 | -- https://wiki.hoggitworld.com/view/DCS_func_getAbsTime 16 | GRPC.methods.getAbsoluteTime = function() 17 | return GRPC.success( 18 | { 19 | time = timer.getAbsTime(), 20 | day = env.mission.date.Day, 21 | month = env.mission.date.Month, 22 | year = env.mission.date.Year, 23 | } 24 | ) 25 | end 26 | 27 | -- https://wiki.hoggitworld.com/view/DCS_func_getTime0 28 | GRPC.methods.getTimeZero = function() 29 | return GRPC.success( 30 | { 31 | time = timer.getTime0(), 32 | day = env.mission.date.Day, 33 | month = env.mission.date.Month, 34 | year = env.mission.date.Year, 35 | } 36 | ) 37 | end -------------------------------------------------------------------------------- /lua/DCS-gRPC/methods/trigger.lua: -------------------------------------------------------------------------------- 1 | -- 2 | -- RPC trigger actions 3 | -- https://wiki.hoggitworld.com/view/DCS_singleton_trigger 4 | -- 5 | 6 | -- All MarkPanels must have a unique ID but there is no way of 7 | -- delegating the creationg of this ID to the game, so we have 8 | -- to have the following code to make sure we always get a new 9 | -- unique id 10 | local MarkId = 0 11 | 12 | local function getMarkId() 13 | local panels = world.getMarkPanels() 14 | local idx = MarkId 15 | if panels then 16 | local l_max = math.max 17 | for _,panel in ipairs(panels) do 18 | idx = l_max(panel.idx, idx) 19 | end 20 | end 21 | idx = idx + 1 22 | MarkId = idx 23 | return idx 24 | end 25 | 26 | GRPC.methods.outText = function(params) 27 | trigger.action.outText(params.text, params.displayTime, params.clearView) 28 | 29 | return GRPC.success({}) 30 | end 31 | 32 | GRPC.methods.outTextForCoalition = function(params) 33 | if params.coalition == 0 then 34 | return GRPC.errorInvalidArgument("a specific coalition must be chosen") 35 | end 36 | 37 | -- Decrement for non zero-indexed gRPC enum 38 | trigger.action.outTextForCoalition(params.coalition - 1, params.text, params.displayTime, params.clearView) 39 | 40 | return GRPC.success({}) 41 | end 42 | 43 | GRPC.methods.outTextForGroup = function(params) 44 | trigger.action.outTextForGroup(params.groupId, params.text, params.displayTime, params.clearView) 45 | 46 | return GRPC.success({}) 47 | end 48 | 49 | GRPC.methods.outTextForUnit = function(params) 50 | trigger.action.outTextForUnit(params.unitId, params.text, params.displayTime, params.clearView) 51 | 52 | return GRPC.success({}) 53 | end 54 | 55 | GRPC.methods.getUserFlag = function(params) 56 | return GRPC.success({ 57 | value = trigger.misc.getUserFlag(params.flag), 58 | }) 59 | end 60 | 61 | GRPC.methods.setUserFlag = function(params) 62 | trigger.action.setUserFlag(params.flag, params.value) 63 | return GRPC.success({}) 64 | end 65 | 66 | GRPC.methods.markToAll = function(params) 67 | local point = coord.LLtoLO(params.position.lat, params.position.lon, params.position.alt) 68 | local idx = getMarkId() 69 | 70 | trigger.action.markToAll(idx, params.text, point, params.readOnly, params.message) 71 | 72 | return GRPC.success({ 73 | id = idx 74 | }) 75 | end 76 | 77 | GRPC.methods.markToCoalition = function(params) 78 | local point = coord.LLtoLO(params.position.lat, params.position.lon, params.position.alt) 79 | local idx = getMarkId() 80 | 81 | local coalition = params.coalition - 1 -- Decrement for non zero-indexed gRPC enum 82 | trigger.action.markToCoalition(idx, params.text, point, coalition, params.readOnly, params.message) 83 | 84 | return GRPC.success({ 85 | id = idx 86 | }) 87 | end 88 | 89 | GRPC.methods.markToGroup = function(params) 90 | local point = coord.LLtoLO(params.position.lat, params.position.lon, params.position.alt) 91 | local idx = getMarkId() 92 | 93 | trigger.action.markToGroup(idx, params.text, point, params.groupId, params.readOnly, params.message) 94 | 95 | return GRPC.success({ 96 | id = idx 97 | }) 98 | end 99 | 100 | GRPC.methods.removeMark = function(params) 101 | trigger.action.removeMark(params.id) 102 | 103 | return GRPC.success({}) 104 | end 105 | 106 | GRPC.methods.markupToAll = function(params) 107 | local idx = getMarkId() 108 | local coalition = params.coalition or -1 109 | 110 | -- Number of points is variable so we need to make a table that we unpack 111 | -- later and add all parameters after the points into it as well 112 | local packedParams = {} 113 | for _, value in ipairs(params.points) do 114 | table.insert(packedParams, coord.LLtoLO(value.lat, value.lon, value.alt)) 115 | end 116 | 117 | table.insert(packedParams, { 118 | params.borderColor.red, 119 | params.borderColor.green, 120 | params.borderColor.blue, 121 | params.borderColor.alpha 122 | }) 123 | table.insert(packedParams, { 124 | params.fillColor.red, 125 | params.fillColor.green, 126 | params.fillColor.blue, 127 | params.fillColor.alpha 128 | }) 129 | table.insert(packedParams, params.lineType) 130 | table.insert(packedParams, params.readOnly) 131 | table.insert(packedParams, params.message) 132 | 133 | trigger.action.markupToAll(params.shape, coalition, idx, unpack(packedParams)) 134 | 135 | return GRPC.success({ 136 | id = idx 137 | }) 138 | end 139 | 140 | GRPC.methods.markupToCoalition = function(params) 141 | if params.coalition == 0 then 142 | return GRPC.errorInvalidArgument("a specific coalition must be chosen") 143 | end 144 | 145 | params.coalition = params.coalition - 1 -- Decrement for non zero-indexed gRPC enum 146 | 147 | return GRPC.methods.markupToAll(params) 148 | 149 | end 150 | 151 | 152 | GRPC.methods.explosion = function(params) 153 | local point = coord.LLtoLO(params.position.lat, params.position.lon, params.position.alt) 154 | 155 | trigger.action.explosion(point, params.power) 156 | 157 | return GRPC.success({}) 158 | end 159 | 160 | -- gRPC enums should avoid 0 so we increment it there and then subtract by 1 161 | -- here since this enum is zero indexed. 162 | GRPC.methods.smoke = function(params) 163 | if params.color == 0 then 164 | return GRPC.errorInvalidArgument("color cannot be unspecified (0)") 165 | end 166 | local point = coord.LLtoLO(params.position.lat, params.position.lon, 0) 167 | local groundPoint = { 168 | x = point.x, 169 | y = land.getHeight({x = point.x, y = point.z}), 170 | z = point.z 171 | } 172 | 173 | trigger.action.smoke(groundPoint, params.color - 1) 174 | 175 | return GRPC.success({}) 176 | end 177 | 178 | GRPC.methods.illuminationBomb = function(params) 179 | local point = coord.LLtoLO(params.position.lat, params.position.lon, 0) 180 | local groundOffsetPoint = { 181 | x = point.x, 182 | y = land.getHeight({x = point.x, y = point.z}) + params.position.alt, 183 | z = point.z 184 | } 185 | 186 | trigger.action.illuminationBomb(groundOffsetPoint, params.power) 187 | 188 | return GRPC.success({}) 189 | end 190 | 191 | -- gRPC enums should avoid 0 so we increment it there and then subtract by 1 192 | -- here since this enum is zero indexed. 193 | GRPC.methods.signalFlare = function(params) 194 | if params.color == 0 then 195 | return GRPC.errorInvalidArgument("color cannot be unspecified (0)") 196 | end 197 | local point = coord.LLtoLO(params.position.lat, params.position.lon, 0) 198 | local groundPoint = { 199 | x = point.x, 200 | y = land.getHeight({x = point.x, y = point.z}), 201 | z= point.z} 202 | 203 | trigger.action.signalFlare(groundPoint, params.color - 1, params.azimuth) 204 | 205 | return GRPC.success({}) 206 | end -------------------------------------------------------------------------------- /lua/DCS-gRPC/methods/unit.lua: -------------------------------------------------------------------------------- 1 | -- 2 | -- RPC unit actions 3 | -- https://wiki.hoggitworld.com/view/DCS_Class_Unit 4 | -- 5 | 6 | GRPC.methods.getRadar = function(params) 7 | local unit = Unit.getByName(params.name) 8 | if unit == nil then 9 | return GRPC.errorNotFound("Could not find unit with name '" .. params.name .. "'") 10 | end 11 | 12 | local active, object = unit:getRadar() 13 | 14 | if object == nil then 15 | return GRPC.success({ 16 | active = active 17 | }) 18 | end 19 | 20 | local category = Object.getCategory(object)-- change for DCS API fixes in getcategory() 21 | local grpcTable = {} 22 | 23 | if(category == Object.Category.UNIT) then 24 | grpcTable.unit = GRPC.exporters.unit(object) 25 | elseif(category == Object.Category.WEAPON) then 26 | grpcTable.weapon = GRPC.exporters.weapon(object) 27 | elseif(category == Object.Category.STATIC) then 28 | grpcTable.static = GRPC.exporters.static(object) 29 | elseif(category == Object.Category.BASE) then 30 | grpcTable.airbase = GRPC.exporters.airbase(object) 31 | elseif(category == Object.Category.SCENERY) then 32 | grpcTable.scenery = GRPC.exporters.scenery(object) 33 | elseif(category == Object.Category.Cargo) then 34 | grpcTable.cargo = GRPC.exporters.cargo(object) 35 | else 36 | GRPC.logWarning( 37 | "Could not determine object category of object with ID: " .. object:getID() 38 | .. ", Category: " .. category 39 | ) 40 | grpcTable.object = GRPC.exporters.object(object) 41 | end 42 | 43 | return GRPC.success({ 44 | active = active, 45 | target = grpcTable 46 | }) 47 | end 48 | 49 | GRPC.methods.getDrawArgumentValue = function (params) 50 | -- https://wiki.hoggitworld.com/view/DCS_func_getDrawArgumentValue 51 | local unit = Unit.getByName(params.name) 52 | if unit == nil then 53 | return GRPC.errorNotFound("unit does not exist") 54 | end 55 | 56 | return GRPC.success({ 57 | value = unit:getDrawArgumentValue(params.argument) 58 | }) 59 | end 60 | 61 | GRPC.methods.getUnitPosition = function(params) 62 | -- https://wiki.hoggitworld.com/view/DCS_func_getByName 63 | local unit = Unit.getByName(params.name) 64 | if unit == nil then 65 | return GRPC.errorNotFound("unit does not exist") 66 | end 67 | 68 | return GRPC.success({ 69 | -- https://wiki.hoggitworld.com/view/DCS_func_getPoint 70 | position = GRPC.exporters.position(unit:getPoint()), 71 | }) 72 | end 73 | 74 | GRPC.methods.getUnitTransform = function(params) 75 | -- https://wiki.hoggitworld.com/view/DCS_func_getByName 76 | local unit = Unit.getByName(params.name) 77 | if unit == nil then 78 | return GRPC.errorNotFound("unit does not exist") 79 | end 80 | 81 | return GRPC.success({ 82 | time = timer.getTime(), 83 | rawTransform = GRPC.exporters.rawTransform(unit), 84 | }) 85 | end 86 | 87 | GRPC.methods.getUnitPlayerName = function(params) 88 | -- https://wiki.hoggitworld.com/view/DCS_func_getByName 89 | local unit = Unit.getByName(params.name) 90 | if unit == nil then 91 | return GRPC.errorNotFound("unit does not exist") 92 | end 93 | 94 | return GRPC.success({ 95 | -- https://wiki.hoggitworld.com/view/DCS_func_getPlayerName 96 | playerName = unit:getPlayerName(), 97 | }) 98 | end 99 | 100 | GRPC.methods.getUnitDescriptor = function(params) 101 | local unit = Unit.getByName(params.name) 102 | if unit == nil then 103 | return GRPC.errorNotFound("unit does not exist") 104 | end 105 | 106 | local desc = unit:getDesc() 107 | local attrs = {} 108 | for i in pairs(desc.attributes) do 109 | table.insert(attrs, i) 110 | end 111 | 112 | return GRPC.success({ 113 | attributes = attrs 114 | }) 115 | end 116 | 117 | GRPC.methods.setEmission = function(params) 118 | local unit = Unit.getByName(params.name) 119 | if unit == nil then 120 | return GRPC.errorNotFound("unit does not exist") 121 | end unit:enableEmission(params.emitting) 122 | return GRPC.success({}) 123 | end 124 | 125 | GRPC.methods.getUnit = function(params) 126 | local unit = Unit.getByName(params.name) 127 | if unit == nil then 128 | return GRPC.errorNotFound("unit `" .. tostring(params.name) .. "` does not exist") 129 | end 130 | 131 | return GRPC.success({unit = GRPC.exporters.unit(unit)}) 132 | end 133 | 134 | GRPC.methods.getUnitById = function(params) 135 | local unit = Unit.getByName(Unit.getName({ id_ = params.id })) 136 | if unit == nil then 137 | return GRPC.errorNotFound("unit with id `" .. tostring(params.id) .. "` does not exist") 138 | end 139 | 140 | return GRPC.success({unit = GRPC.exporters.unit(unit)}) 141 | end 142 | 143 | GRPC.methods.unitDestroy = function(params) 144 | local unit = Unit.getByName(params.name) 145 | if unit == nil then 146 | return GRPC.errorNotFound("unit `" .. tostring(params.name) .. "` does not exist") 147 | end 148 | 149 | unit:destroy() 150 | return GRPC.success({}) 151 | end 152 | -------------------------------------------------------------------------------- /lua/DCS-gRPC/methods/world.lua: -------------------------------------------------------------------------------- 1 | -- 2 | -- RPC world actions 3 | -- https://wiki.hoggitworld.com/view/DCS_singleton_world 4 | -- 5 | 6 | local world = world 7 | local coalition = coalition 8 | local GRPC = GRPC 9 | 10 | GRPC.methods.getAirbases = function(params) 11 | local data 12 | 13 | if params.coalition == 0 then 14 | data = world.getAirbases() 15 | else 16 | -- Yes, yes, this is in the world file but uses coalition. I plan 17 | -- to completely rejigger the organisation of these files when we 18 | -- have more APIs implemented and amore sane pattern presents 19 | -- itself. For the moment we are mostly following DCS organisation 20 | data = coalition.getAirbases(params.coalition - 1) -- Decrement for non zero-indexed gRPC enum 21 | end 22 | 23 | local result = {} 24 | local unit 25 | 26 | for _, airbase in pairs(data) do 27 | if airbase:getDesc()['category'] == 2 then -- SHIP 28 | unit = airbase:getUnit() 29 | if unit then -- Unit object 30 | if unit:isExist() then -- Extant object 31 | if unit:getGroup() then -- Unit in group so can be exported 32 | result[#result+1] = GRPC.exporters.airbase(airbase) 33 | end -- no group for unit, move to next object 34 | end -- unit no longer exists, move to next object 35 | end -- no unit, move to next object 36 | else -- Aerodrome or Helipad, so can be exported 37 | result[#result+1] = GRPC.exporters.airbase(airbase) 38 | end 39 | end 40 | return GRPC.success({airbases = result}) 41 | end 42 | 43 | GRPC.methods.getMarkPanels = function() 44 | local markPanels = world.getMarkPanels() 45 | local result = {} 46 | 47 | for i, markPanel in ipairs(markPanels) do 48 | result[i] = GRPC.exporters.markPanel(markPanel) 49 | end 50 | 51 | return GRPC.success({markPanels = result}) 52 | end 53 | 54 | GRPC.methods.getTheatre = function() 55 | return GRPC.success({theatre = env.mission.theatre}) 56 | end -------------------------------------------------------------------------------- /lua/DCS-gRPC/version.lua: -------------------------------------------------------------------------------- 1 | -- this file is auto-generated on `cargo build` 2 | GRPC.version = "0.8.1" 3 | -------------------------------------------------------------------------------- /lua/Hooks/DCS-gRPC.lua: -------------------------------------------------------------------------------- 1 | -- This file is only responsible for loading the config and executing `lua\DCS-gRPC\grpc-hook.lua`, 2 | -- where the main logic of the hook is implemented. 3 | 4 | local function init() 5 | log.write("[GRPC-Hook]", log.INFO, "Initializing ...") 6 | 7 | if not GRPC then 8 | _G.GRPC = { 9 | -- scaffold nested tables to allow direct assignment in config file 10 | tts = { provider = { gcloud = {}, aws = {}, azure = {}, win = {} } }, 11 | srs = {}, 12 | auth = { tokens = {} } 13 | } 14 | end 15 | 16 | -- load settings from `Saved Games/DCS/Config/dcs-grpc.lua` 17 | do 18 | log.write("[GRPC-Hook]", log.INFO, "Checking optional config at `Config/dcs-grpc.lua` ...") 19 | local file, err = io.open(lfs.writedir() .. [[Config\dcs-grpc.lua]], "r") 20 | if file then 21 | local f = assert(loadstring(file:read("*all"))) 22 | setfenv(f, GRPC) 23 | f() 24 | io.close(file) 25 | log.write("[GRPC-Hook]", log.INFO, "`Config/dcs-grpc.lua` successfully read") 26 | else 27 | log.write("[GRPC-Hook]", log.INFO, "`Config/dcs-grpc.lua` not found (" .. tostring(err) .. ")") 28 | end 29 | end 30 | 31 | -- Set default settings. 32 | if not GRPC.luaPath then 33 | GRPC.luaPath = lfs.writedir() .. [[Scripts\DCS-gRPC\]] 34 | end 35 | if not GRPC.dllPath then 36 | GRPC.dllPath = lfs.writedir() .. [[Mods\tech\DCS-gRPC\]] 37 | end 38 | if GRPC.throughputLimit == nil or GRPC.throughputLimit == 0 or type(GRPC.throughputLimit) ~= "number" then 39 | GRPC.throughputLimit = 600 40 | end 41 | 42 | dofile(GRPC.luaPath .. [[grpc-hook.lua]]) 43 | 44 | log.write("[GRPC-Hook]", log.INFO, "Initialized ...") 45 | end 46 | 47 | local ok, err = pcall(init) 48 | if not ok then 49 | log.write("[GRPC-Hook]", log.ERROR, "Failed to Initialize: "..tostring(err)) 50 | end 51 | -------------------------------------------------------------------------------- /lua_files.rs: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DCS-gRPC/rust-server/844b07086dd36433658dcf65b3313baf7338700e/lua_files.rs -------------------------------------------------------------------------------- /protos/dcs/atmosphere/v0/atmosphere.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | package dcs.atmosphere.v0; 3 | import "dcs/common/v0/common.proto"; 4 | option csharp_namespace = "RurouniJones.Dcs.Grpc.V0.Atmosphere"; 5 | option go_package = "github.com/DCS-gRPC/go-bindings/dcs/v0/atmosphere"; 6 | 7 | // https://wiki.hoggitworld.com/view/DCS_singleton_atmosphere 8 | service AtmosphereService { 9 | // https://wiki.hoggitworld.com/view/DCS_func_getWind 10 | rpc GetWind(GetWindRequest) returns (GetWindResponse) {} 11 | 12 | // https://wiki.hoggitworld.com/view/DCS_func_getWindWithTurbulence 13 | rpc GetWindWithTurbulence(GetWindWithTurbulenceRequest) 14 | returns (GetWindWithTurbulenceResponse) {} 15 | 16 | // https://wiki.hoggitworld.com/view/DCS_func_getWindWithTurbulence 17 | rpc GetTemperatureAndPressure(GetTemperatureAndPressureRequest) 18 | returns (GetTemperatureAndPressureResponse) {} 19 | } 20 | 21 | message GetWindRequest { 22 | // The position on the map we want the wind information for. 23 | // Requires lat/lon/alt fields to be populated, there are 24 | // no default values 25 | dcs.common.v0.InputPosition position = 1; 26 | } 27 | 28 | message GetWindResponse { 29 | // The heading the wind is coming from. 30 | float heading = 1; 31 | // The strength of the wind in meters per second 32 | float strength = 2; 33 | } 34 | 35 | message GetWindWithTurbulenceRequest { 36 | // The position on the map we want the wind information for. 37 | // Requires lat/lon/alt fields to be populated, there are 38 | // no default values 39 | dcs.common.v0.InputPosition position = 1; 40 | } 41 | 42 | message GetWindWithTurbulenceResponse { 43 | // The heading the wind is coming from. 44 | float heading = 1; 45 | // The strength of the wind in meters per second. 46 | float strength = 2; 47 | } 48 | 49 | message GetTemperatureAndPressureRequest { 50 | // The position on the map we want the wind information for. 51 | // Requires lat/lon/alt fields to be populated, there are 52 | // no default values 53 | dcs.common.v0.InputPosition position = 1; 54 | } 55 | 56 | message GetTemperatureAndPressureResponse { 57 | // The temperature in Kelvin 58 | float temperature = 1; 59 | // The pressure in Pascals 60 | float pressure = 2; 61 | } -------------------------------------------------------------------------------- /protos/dcs/coalition/v0/coalition.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | package dcs.coalition.v0; 3 | import "dcs/common/v0/common.proto"; 4 | option csharp_namespace = "RurouniJones.Dcs.Grpc.V0.Coalition"; 5 | option go_package = "github.com/DCS-gRPC/go-bindings/dcs/v0/coalition"; 6 | 7 | // https://wiki.hoggitworld.com/view/DCS_singleton_coalition 8 | service CoalitionService { 9 | // https://wiki.hoggitworld.com/view/DCS_func_addGroup 10 | rpc AddGroup(AddGroupRequest) returns (AddGroupResponse) {} 11 | 12 | // https://wiki.hoggitworld.com/view/DCS_func_getStaticObjects 13 | rpc GetStaticObjects(GetStaticObjectsRequest) 14 | returns (GetStaticObjectsResponse) {} 15 | 16 | // Focussed on statics (linked statics - see `AddLinkedStatic`) 17 | // https://wiki.hoggitworld.com/view/DCS_func_addStaticObject 18 | rpc AddStaticObject(AddStaticObjectRequest) 19 | returns (AddStaticObjectResponse) {} 20 | 21 | // Focussed on properties relevant to linked static objects 22 | // https://wiki.hoggitworld.com/view/DCS_func_addStaticObject 23 | rpc AddLinkedStatic(AddLinkedStaticRequest) 24 | returns (AddLinkedStaticResponse) {} 25 | 26 | // https://wiki.hoggitworld.com/view/DCS_func_getGroups 27 | rpc GetGroups(GetGroupsRequest) returns (GetGroupsResponse) {} 28 | 29 | /* 30 | * Get the Bullseye for the coalition 31 | * 32 | * This position is set at mission start and does not change for the duration 33 | * of the mission. 34 | * 35 | * See https://wiki.hoggitworld.com/view/DCS_func_getMainRefPoint for more 36 | * details 37 | */ 38 | rpc GetBullseye(GetBullseyeRequest) returns (GetBullseyeResponse) {} 39 | 40 | // https://wiki.hoggitworld.com/view/DCS_func_getPlayers 41 | rpc GetPlayerUnits(GetPlayerUnitsRequest) returns (GetPlayerUnitsResponse) {} 42 | } 43 | 44 | message AddGroupRequest { 45 | // The coalition is determined by the provided Country 46 | // and the coalition setup of the mission 47 | dcs.common.v0.Country country = 2; 48 | dcs.common.v0.GroupCategory group_category = 3; 49 | oneof template { 50 | GroundGroupTemplate ground_template = 4; 51 | ShipGroupTemplate ship_template = 5; 52 | HelicopterGroupTemplate helicopter_template = 6; 53 | PlaneGroupTemplate plane_template = 7; 54 | } 55 | 56 | message GroundGroupTemplate { 57 | optional uint32 group_id = 1; 58 | bool hidden = 2; 59 | bool late_activation = 3; 60 | string name = 4; 61 | dcs.common.v0.InputPosition position = 5; 62 | repeated Point waypoints = 6; 63 | uint32 start_time = 7; 64 | string task = 8; 65 | bool task_selected = 9; 66 | repeated Task tasks = 10; 67 | bool uncontrollable = 11; 68 | repeated GroundUnitTemplate units = 12; 69 | bool visible = 13; 70 | } 71 | message GroundUnitTemplate { 72 | string name = 1; 73 | string type = 2; 74 | dcs.common.v0.InputPosition position = 3; 75 | optional uint32 unit_id = 4; 76 | optional uint32 heading = 5; 77 | Skill skill = 6; 78 | } 79 | 80 | message ShipGroupTemplate { 81 | } 82 | message ShipUnitTemplate { 83 | } 84 | 85 | message HelicopterGroupTemplate { 86 | } 87 | message HelicopterUnitTemplate { 88 | } 89 | 90 | message PlaneGroupTemplate { 91 | } 92 | message PlaneUnitTemplate { 93 | } 94 | 95 | message Point { 96 | enum AltitudeType { 97 | ALTITUDE_TYPE_UNSPECIFIED = 0; 98 | ALTITUDE_TYPE_BAROMETRIC = 1; 99 | ALTITUDE_TYPE_RADIO = 2; 100 | } 101 | 102 | enum PointType { 103 | // protolint:disable:next ENUM_FIELD_NAMES_ZERO_VALUE_END_WITH 104 | POINT_TYPE_RANDOM = 0; 105 | POINT_TYPE_TAKEOFF = 1; 106 | POINT_TYPE_TAKEOFF_PARKING = 2; 107 | POINT_TYPE_TURNING_POINT = 3; 108 | POINT_TYPE_TAKEOFF_PARKING_HOT = 4; 109 | POINT_TYPE_LAND = 5; 110 | } 111 | 112 | dcs.common.v0.InputPosition position = 1; 113 | AltitudeType altitude_type = 2; 114 | PointType type = 3; 115 | string action = 4; 116 | string form = 5; 117 | double speed = 6; 118 | } 119 | 120 | enum Skill { 121 | // protolint:disable:next ENUM_FIELD_NAMES_ZERO_VALUE_END_WITH 122 | SKILL_RANDOM = 0; 123 | SKILL_AVERAGE = 1; 124 | SKILL_GOOD = 2; 125 | SKILL_HIGH = 3; 126 | SKILL_EXCELLENT = 4; 127 | SKILL_PLAYER = 5; 128 | } 129 | 130 | message Task { 131 | } 132 | } 133 | 134 | message AddGroupResponse { 135 | dcs.common.v0.Group group = 1; 136 | } 137 | 138 | message GetStaticObjectsRequest { 139 | // the coalition which the statics belong to 140 | dcs.common.v0.Coalition coalition = 1; 141 | } 142 | 143 | message GetStaticObjectsResponse { 144 | // the list of statics 145 | repeated dcs.common.v0.Static statics = 1; 146 | } 147 | 148 | message AddStaticObjectRequest { 149 | // the name of the static; must be unique or would destroy previous object 150 | string name = 1; 151 | // country the unit belongs to 152 | dcs.common.v0.Country country = 2; 153 | // type of the static object (e.g. "Farm A", "AS32-31A") 154 | string type = 3; 155 | // string name of the livery for the aircraft 156 | string livery = 4; 157 | // boolean for whether or not the object will appear as a wreck 158 | bool dead = 5; 159 | // number value for the "score" of the object when it is killed 160 | optional uint32 rate = 6; 161 | 162 | double heading = 7; 163 | dcs.common.v0.InputPosition position = 8; 164 | // cargo mass in kilograms 165 | uint32 cargo_mass = 9; 166 | } 167 | 168 | message AddStaticObjectResponse { 169 | string name = 1; 170 | } 171 | 172 | message AddLinkedStaticRequest { 173 | // the name of the static; must be unique or would destroy previous object 174 | string name = 1; 175 | // country the unit belongs to 176 | dcs.common.v0.Country country = 2; 177 | // type of the static object (e.g. "Farm A", "AS32-31A") 178 | string type = 3; 179 | // string name of the livery for the aircraft 180 | string livery = 4; 181 | // boolean for whether or not the object will appear as a wreck 182 | bool dead = 5; 183 | // number value for the "score" of the object when it is killed 184 | optional uint32 rate = 6; 185 | // the name of the unit to offset from 186 | string unit = 7; 187 | // the angle to relative to the linked unit, in a clockwise direction. 188 | // negative values are anti-clockwise 189 | double angle = 8; 190 | // x offset from linked unit center (positive is forward; negative is aft) 191 | double x = 9; 192 | // y offset from linked unit center (positive is starboard-side; 193 | // negative is port-side) 194 | double y = 10; 195 | } 196 | 197 | message AddLinkedStaticResponse { 198 | string name = 1; 199 | } 200 | 201 | message GetGroupsRequest { 202 | dcs.common.v0.Coalition coalition = 1; 203 | dcs.common.v0.GroupCategory category = 2; 204 | } 205 | 206 | message GetGroupsResponse { 207 | repeated dcs.common.v0.Group groups = 1; 208 | } 209 | 210 | message GetBullseyeRequest { 211 | // A specific coalition must be used for this API call. Do not use 212 | // `COALITION_ALL` 213 | dcs.common.v0.Coalition coalition = 1; 214 | } 215 | 216 | message GetBullseyeResponse { 217 | dcs.common.v0.Position position = 1; 218 | } 219 | 220 | message GetPlayerUnitsRequest { 221 | dcs.common.v0.Coalition coalition = 1; 222 | } 223 | 224 | message GetPlayerUnitsResponse { 225 | repeated dcs.common.v0.Unit units = 1; 226 | } 227 | -------------------------------------------------------------------------------- /protos/dcs/controller/v0/controller.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | package dcs.controller.v0; 3 | import "dcs/common/v0/common.proto"; 4 | option csharp_namespace = "RurouniJones.Dcs.Grpc.V0.Controller"; 5 | option go_package = "github.com/DCS-gRPC/go-bindings/dcs/v0/controller"; 6 | 7 | service ControllerService { 8 | // https://wiki.hoggitworld.com/view/DCS_option_alarmState 9 | rpc SetAlarmState(SetAlarmStateRequest) returns (SetAlarmStateResponse) {} 10 | 11 | // https://wiki.hoggitworld.com/view/DCS_func_getDetectedTargets 12 | rpc GetDetectedTargets(GetDetectedTargetsRequest) 13 | returns (GetDetectedTargetsResponse) {} 14 | } 15 | 16 | message SetAlarmStateRequest { 17 | enum AlarmState { 18 | ALARM_STATE_UNSPECIFIED = 0; 19 | ALARM_STATE_AUTO = 1; 20 | ALARM_STATE_GREEN = 2; 21 | ALARM_STATE_RED = 3; 22 | } 23 | 24 | oneof name { 25 | string group_name = 1; 26 | string unit_name = 2; 27 | } 28 | AlarmState alarm_state = 3; 29 | } 30 | 31 | message SetAlarmStateResponse { 32 | } 33 | 34 | message GetDetectedTargetsRequest { 35 | enum DetectionType { 36 | DETECTION_TYPE_UNSPECIFIED = 0; 37 | DETECTION_TYPE_VISUAL = 1; 38 | DETECTION_TYPE_OPTIC = 2; 39 | DETECTION_TYPE_RADAR = 4; 40 | DETECTION_TYPE_IRST = 8; 41 | DETECTION_TYPE_RWR = 16; 42 | DETECTION_TYPE_DLINK = 32; 43 | } 44 | string unit_name = 1; 45 | optional bool include_object = 2; 46 | optional DetectionType detection_type = 3; 47 | } 48 | 49 | message GetDetectedTargetsResponse { 50 | repeated dcs.common.v0.Contact contacts = 1; 51 | } -------------------------------------------------------------------------------- /protos/dcs/custom/v0/custom.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | package dcs.custom.v0; 3 | option csharp_namespace = "RurouniJones.Dcs.Grpc.V0.Custom"; 4 | option go_package = "github.com/DCS-gRPC/go-bindings/dcs/v0/custom"; 5 | 6 | // The Custom service is for APIs that do not map to the "standard library" of 7 | // DCS APIs provided by Eagle Dynamics. 8 | // 9 | // Expect to find APIs here that may be useful for mission frameworks etc. 10 | service CustomService { 11 | // DCT Function 12 | rpc RequestMissionAssignment(RequestMissionAssignmentRequest) 13 | returns (RequestMissionAssignmentResponse) {} 14 | 15 | // DCT Function 16 | rpc JoinMission(JoinMissionRequest) returns (JoinMissionResponse) {} 17 | 18 | // DCT Function 19 | rpc AbortMission(AbortMissionRequest) returns (AbortMissionResponse) {} 20 | 21 | // DCT Function 22 | rpc GetMissionStatus(GetMissionStatusRequest) 23 | returns (GetMissionStatusResponse) {} 24 | 25 | // Evaluate some Lua inside of the mission and return the result as a JSON 26 | // string. Disabled by default. 27 | rpc Eval(EvalRequest) returns (EvalResponse) {} 28 | 29 | /** 30 | * Calculates the magnetic declination at the given position using the 31 | * International Geomagnetic Reference Field (IGRF) model. The result is not 32 | * always exactly the same as what DCS seem to use, but it is very close (DCS 33 | * doesn't expose its declination). 34 | */ 35 | rpc GetMagneticDeclination(GetMagneticDeclinationRequest) 36 | returns (GetMagneticDeclinationResponse) {} 37 | } 38 | 39 | message RequestMissionAssignmentRequest { 40 | string unit_name = 1; 41 | string mission_type = 2; 42 | } 43 | 44 | message RequestMissionAssignmentResponse { 45 | } 46 | 47 | message JoinMissionRequest { 48 | string unit_name = 1; 49 | int32 mission_code = 2; 50 | } 51 | 52 | message JoinMissionResponse { 53 | } 54 | 55 | message AbortMissionRequest { 56 | string unit_name = 1; 57 | } 58 | 59 | message AbortMissionResponse { 60 | } 61 | 62 | message GetMissionStatusRequest { 63 | string unit_name = 1; 64 | } 65 | 66 | message GetMissionStatusResponse { 67 | } 68 | 69 | message EvalRequest { 70 | string lua = 1; 71 | } 72 | 73 | message EvalResponse { 74 | string json = 1; 75 | } 76 | 77 | message GetMagneticDeclinationRequest { 78 | /// Latitude in Decimal Degrees format 79 | double lat = 1; 80 | /// Longitude in Decimal Degrees format 81 | double lon = 2; 82 | /// Altitude in Meters above Mean Sea Level (MSL) 83 | double alt = 3; 84 | } 85 | 86 | message GetMagneticDeclinationResponse { 87 | /// Magnetic declination in degrees. A negative value is an westerly 88 | /// declination, while a positive value is a easterly declination. `True 89 | /// North` + `declination` = `Magnetic North` 90 | double declination = 1; 91 | } -------------------------------------------------------------------------------- /protos/dcs/dcs.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package dcs; 4 | 5 | import "dcs/atmosphere/v0/atmosphere.proto"; 6 | import "dcs/coalition/v0/coalition.proto"; 7 | import "dcs/common/v0/common.proto"; 8 | import "dcs/controller/v0/controller.proto"; 9 | import "dcs/custom/v0/custom.proto"; 10 | import "dcs/group/v0/group.proto"; 11 | import "dcs/hook/v0/hook.proto"; 12 | import "dcs/metadata/v0/metadata.proto"; 13 | import "dcs/mission/v0/mission.proto"; 14 | import "dcs/net/v0/net.proto"; 15 | import "dcs/srs/v0/srs.proto"; 16 | import "dcs/timer/v0/timer.proto"; 17 | import "dcs/trigger/v0/trigger.proto"; 18 | import "dcs/unit/v0/unit.proto"; 19 | import "dcs/world/v0/world.proto"; 20 | -------------------------------------------------------------------------------- /protos/dcs/group/v0/group.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | package dcs.group.v0; 3 | import "dcs/common/v0/common.proto"; 4 | option csharp_namespace = "RurouniJones.Dcs.Grpc.V0.Group"; 5 | option go_package = "github.com/DCS-gRPC/go-bindings/dcs/v0/group"; 6 | 7 | // https://wiki.hoggitworld.com/view/DCS_Class_Group 8 | service GroupService { 9 | // https://wiki.hoggitworld.com/view/DCS_func_getUnits 10 | rpc GetUnits(GetUnitsRequest) returns (GetUnitsResponse) {} 11 | 12 | // https://wiki.hoggitworld.com/view/DCS_func_activate 13 | rpc Activate(ActivateRequest) returns (ActivateResponse) {} 14 | 15 | // https://wiki.hoggitworld.com/view/DCS_func_destroy 16 | rpc Destroy(DestroyRequest) returns (DestroyResponse) {} 17 | } 18 | 19 | message GetUnitsRequest { 20 | string group_name = 1; 21 | // Whether the response should include only active units (`true`), only 22 | // inactive units (`false`), or all units (`nil`). 23 | optional bool active = 2; 24 | } 25 | 26 | message GetUnitsResponse { 27 | repeated dcs.common.v0.Unit units = 1; 28 | } 29 | 30 | message ActivateRequest { 31 | string group_name = 1; 32 | } 33 | 34 | message ActivateResponse { 35 | } 36 | 37 | message DestroyRequest { 38 | string group_name = 1; 39 | } 40 | 41 | message DestroyResponse { 42 | } -------------------------------------------------------------------------------- /protos/dcs/hook/v0/hook.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | package dcs.hook.v0; 3 | option csharp_namespace = "RurouniJones.Dcs.Grpc.V0.Hook"; 4 | option go_package = "github.com/DCS-gRPC/go-bindings/dcs/v0/hook"; 5 | 6 | // APis that are part of the hook environment 7 | service HookService { 8 | // https://wiki.hoggitworld.com/view/DCS_func_getMissionName 9 | rpc GetMissionName(GetMissionNameRequest) returns (GetMissionNameResponse) {} 10 | 11 | // https://wiki.hoggitworld.com/view/DCS_func_getMissionFilename 12 | rpc GetMissionFilename(GetMissionFilenameRequest) 13 | returns (GetMissionFilenameResponse) {} 14 | 15 | // https://wiki.hoggitworld.com/view/DCS_func_getMissionDescription 16 | rpc GetMissionDescription(GetMissionDescriptionRequest) 17 | returns (GetMissionDescriptionResponse) {} 18 | 19 | // https://wiki.hoggitworld.com/view/DCS_func_getPause 20 | rpc GetPaused(GetPausedRequest) returns (GetPausedResponse) {} 21 | 22 | // https://wiki.hoggitworld.com/view/DCS_func_setPause 23 | rpc SetPaused(SetPausedRequest) returns (SetPausedResponse) {} 24 | 25 | // https://wiki.hoggitworld.com/view/DCS_func_stopMission 26 | rpc StopMission(StopMissionRequest) returns (StopMissionResponse) {} 27 | 28 | // Reload the currently running mission 29 | rpc ReloadCurrentMission(ReloadCurrentMissionRequest) 30 | returns (ReloadCurrentMissionResponse) {} 31 | 32 | // Load the next mission in the server mission list. Note that it does 33 | // not loop back to the first mission once the end of the mission list 34 | // has been reached 35 | rpc LoadNextMission(LoadNextMissionRequest) 36 | returns (LoadNextMissionResponse) {} 37 | 38 | // Load a specific mission file. This does not need to be in the mission 39 | // list. 40 | rpc LoadMission(LoadMissionRequest) 41 | returns (LoadMissionResponse) {} 42 | 43 | // Evaluate some Lua inside of the hook environment and return the result as a 44 | // JSON string. Disabled by default. 45 | rpc Eval(EvalRequest) returns (EvalResponse) {} 46 | 47 | // https://wiki.hoggitworld.com/view/DCS_func_exitProcess 48 | rpc ExitProcess(ExitProcessRequest) returns (ExitProcessResponse) {} 49 | 50 | // https://wiki.hoggitworld.com/view/DCS_func_isMultiplayer 51 | rpc IsMultiplayer(IsMultiplayerRequest) returns (IsMultiplayerResponse) {} 52 | 53 | // https://wiki.hoggitworld.com/view/DCS_func_isServer 54 | rpc IsServer(IsServerRequest) returns (IsServerResponse) {} 55 | 56 | // Bans a player that is currently connected to the server 57 | rpc BanPlayer(BanPlayerRequest) returns (BanPlayerResponse) {} 58 | 59 | // Unbans a player via their globally unique ID 60 | rpc UnbanPlayer(UnbanPlayerRequest) returns (UnbanPlayerResponse) {} 61 | 62 | // Get a list of all the banned players 63 | rpc GetBannedPlayers(GetBannedPlayersRequest) 64 | returns (GetBannedPlayersResponse) {} 65 | 66 | // https://wiki.hoggitworld.com/view/DCS_func_getUnitType 67 | rpc GetUnitType(GetUnitTypeRequest) returns (GetUnitTypeResponse) {} 68 | 69 | // https://wiki.hoggitworld.com/view/DCS_func_getRealTime 70 | rpc GetRealTime(GetRealTimeRequest) returns (GetRealTimeResponse) {} 71 | 72 | // Get a count of ballistics objects 73 | rpc GetBallisticsCount(GetBallisticsCountRequest) 74 | returns (GetBallisticsCountResponse) {} 75 | } 76 | 77 | message GetMissionNameRequest { 78 | } 79 | 80 | message GetMissionNameResponse { 81 | string name = 1; 82 | } 83 | 84 | message GetMissionFilenameRequest { 85 | } 86 | 87 | message GetMissionFilenameResponse { 88 | string name = 1; 89 | } 90 | 91 | message GetMissionDescriptionRequest { 92 | } 93 | 94 | message GetMissionDescriptionResponse { 95 | string description = 1; 96 | } 97 | 98 | message GetPausedRequest { 99 | } 100 | 101 | message GetPausedResponse { 102 | bool paused = 1; 103 | } 104 | 105 | message SetPausedRequest { 106 | bool paused = 1; 107 | } 108 | 109 | message SetPausedResponse { 110 | } 111 | 112 | message ReloadCurrentMissionRequest { 113 | } 114 | 115 | message ReloadCurrentMissionResponse { 116 | } 117 | 118 | message LoadNextMissionRequest { 119 | } 120 | 121 | message LoadNextMissionResponse { 122 | // Was the next mission successfully loaded. SHOULD return false when the 123 | // end of the mission list has been reached but DCS appears to always 124 | // return true 125 | bool loaded = 1; 126 | } 127 | 128 | message LoadMissionRequest { 129 | // The full path to the .miz file to be loaded 130 | string file_name = 1; 131 | } 132 | 133 | message LoadMissionResponse { 134 | } 135 | 136 | message StopMissionRequest { 137 | } 138 | 139 | message StopMissionResponse { 140 | } 141 | 142 | message EvalRequest { 143 | string lua = 1; 144 | } 145 | 146 | message EvalResponse { 147 | string json = 1; 148 | } 149 | 150 | message ExitProcessRequest { 151 | } 152 | 153 | message ExitProcessResponse { 154 | } 155 | 156 | message IsMultiplayerRequest { 157 | } 158 | 159 | message IsMultiplayerResponse { 160 | bool multiplayer = 1; 161 | } 162 | 163 | message IsServerRequest { 164 | } 165 | 166 | message IsServerResponse { 167 | bool server = 1; 168 | } 169 | 170 | message BanPlayerRequest { 171 | // The session ID of the player 172 | uint32 id = 1; 173 | // The period of the ban in seconds 174 | uint32 period = 2; 175 | // The reason for the ban 176 | string reason = 3; 177 | } 178 | 179 | message BanPlayerResponse { 180 | // Was the player successfully banned 181 | bool banned = 1; 182 | } 183 | 184 | message UnbanPlayerRequest { 185 | // The globally unique ID of the player 186 | string ucid = 1; 187 | } 188 | 189 | message UnbanPlayerResponse { 190 | // Was the player successfully unbanned 191 | bool unbanned = 1; 192 | } 193 | 194 | message GetBannedPlayersRequest { 195 | } 196 | 197 | message GetBannedPlayersResponse { 198 | repeated BanDetails bans = 1; 199 | } 200 | 201 | message BanDetails { 202 | // The globally unique ID of the player 203 | string ucid = 1; 204 | // The IP address the user had when they were banned 205 | string ip_address = 2; 206 | // The Name of the player at the time of the ban 207 | string player_name = 3; 208 | // The reason given for the ban 209 | string reason = 4; 210 | // When the ban was issued in unixtime 211 | uint64 banned_from = 5; 212 | // When the ban will expire in unixtime 213 | uint64 banned_until = 6; 214 | } 215 | 216 | message GetUnitTypeRequest { 217 | // The slot or unit ID of the unit to retrieve the type of 218 | string id = 1; 219 | } 220 | 221 | message GetUnitTypeResponse { 222 | // Type of unit (e.g. "F-14B") 223 | string type = 1; 224 | } 225 | 226 | message GetRealTimeRequest { 227 | } 228 | 229 | message GetRealTimeResponse { 230 | // The current time in a mission relative to the DCS start time 231 | double time = 1; 232 | } 233 | 234 | message GetBallisticsCountRequest { 235 | } 236 | 237 | message GetBallisticsCountResponse { 238 | uint32 count = 1; 239 | } -------------------------------------------------------------------------------- /protos/dcs/metadata/v0/metadata.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | package dcs.metadata.v0; 3 | option csharp_namespace = "RurouniJones.Dcs.Grpc.V0.Metadata"; 4 | option go_package = "github.com/DCS-gRPC/go-bindings/dcs/v0/metadata"; 5 | 6 | //A service to get administrative/meta data like server health checks and version 7 | service MetadataService { 8 | 9 | rpc GetHealth(GetHealthRequest) returns (GetHealthResponse) {} 10 | 11 | rpc GetVersion(GetVersionRequest) returns (GetVersionResponse) {} 12 | } 13 | 14 | message GetHealthRequest { 15 | } 16 | 17 | message GetHealthResponse { 18 | bool alive = 1; 19 | } 20 | 21 | message GetVersionRequest { 22 | } 23 | 24 | message GetVersionResponse { 25 | string version = 1; 26 | } -------------------------------------------------------------------------------- /protos/dcs/net/v0/net.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | package dcs.net.v0; 3 | import "dcs/common/v0/common.proto"; 4 | option csharp_namespace = "RurouniJones.Dcs.Grpc.V0.Net"; 5 | option go_package = "github.com/DCS-gRPC/go-bindings/dcs/v0/net"; 6 | 7 | service NetService { 8 | // https://wiki.hoggitworld.com/view/DCS_func_send_chat_to 9 | rpc SendChatTo(SendChatToRequest) returns (SendChatToResponse) {} 10 | 11 | // https://wiki.hoggitworld.com/view/DCS_func_send_chat 12 | rpc SendChat(SendChatRequest) returns (SendChatResponse) {} 13 | 14 | // returns a list of all connected players. 15 | // https://wiki.hoggitworld.com/view/DCS_func_get_player_info 16 | rpc GetPlayers(GetPlayersRequest) returns (GetPlayersResponse) {} 17 | 18 | // Kick a specified player from the server with a message 19 | // https://wiki.hoggitworld.com/view/DCS_func_kick 20 | rpc KickPlayer(KickPlayerRequest) returns (KickPlayerResponse) {} 21 | 22 | // Force a player into a slot / coalition. 23 | // To move the player back into spectators, use the following pseudo: 24 | // `ForcePlayerSlot({ player_id: ..., coalition: NEUTRAL, slot_id: "" })` 25 | rpc ForcePlayerSlot(ForcePlayerSlotRequest) 26 | returns (ForcePlayerSlotResponse) {} 27 | } 28 | 29 | message SendChatToRequest { 30 | // the message to send in the chat 31 | string message = 1; 32 | // the target player of the direct message 33 | uint32 target_player_id = 2; 34 | } 35 | 36 | message SendChatToResponse { 37 | } 38 | 39 | message SendChatRequest { 40 | // the message to send in the chat 41 | string message = 1; 42 | // which coalition? DCS only supports ALL or NEUTRAL 43 | // (only applicable to send_chat) 44 | dcs.common.v0.Coalition coalition = 2; 45 | } 46 | 47 | message SendChatResponse { 48 | } 49 | 50 | message GetPlayersRequest { 51 | } 52 | 53 | message GetPlayersResponse { 54 | message GetPlayerInfo { 55 | // the player id 56 | uint32 id = 1; 57 | // player's online name 58 | string name = 2; 59 | // coalition which player is slotted in 60 | dcs.common.v0.Coalition coalition = 3; 61 | // the slot identifier 62 | string slot = 4; 63 | // the ping of the player 64 | uint32 ping = 5; 65 | // the connection ip address and port the client 66 | // has established with the server 67 | string remote_address = 6; 68 | // the unique identifier for the player 69 | string ucid = 7; 70 | // abbreviated language (locale) e.g. "en" 71 | string locale = 8; 72 | } 73 | 74 | // list of all the players connected to the server 75 | repeated GetPlayerInfo players = 1; 76 | } 77 | 78 | message ForcePlayerSlotRequest { 79 | uint32 player_id = 1; 80 | dcs.common.v0.Coalition coalition = 2; 81 | string slot_id = 3; 82 | } 83 | 84 | message ForcePlayerSlotResponse { 85 | } 86 | 87 | message KickPlayerRequest { 88 | uint32 id = 1; 89 | string message = 2; 90 | } 91 | 92 | message KickPlayerResponse { 93 | } -------------------------------------------------------------------------------- /protos/dcs/srs/v0/srs.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | package dcs.srs.v0; 3 | import "dcs/common/v0/common.proto"; 4 | option csharp_namespace = "RurouniJones.Dcs.Grpc.V0.Srs"; 5 | option go_package = "github.com/DCS-gRPC/go-bindings/dcs/v0/srs"; 6 | 7 | service SrsService { 8 | // Synthesize text to speech and transmit it over SRS. By default, this blocks until a 9 | // transmission completed (unless `async` is set to `true`). This can be used to prevent 10 | // transmission to overlap each other, by not sending another transmission on the same frequency 11 | // until you've received the response from the previous transmission on that frequency. However, 12 | // it does not block or prevent any other client from transmitting over the same frequency at the 13 | // same time. 14 | rpc Transmit(TransmitRequest) returns (TransmitResponse) {} 15 | 16 | // Retrieve a list of units (players) and their active frequencies that are connected to SRS. 17 | rpc GetClients(GetClientsRequest) returns (GetClientsResponse) {} 18 | } 19 | 20 | message TransmitRequest { 21 | // The text that is synthesized to speech and transmitted to SRS. Supports SSML tags (you should 22 | // not wrap the text in the root `` tag though). 23 | string ssml = 1; 24 | 25 | // The plain text without any transformations made to it for the purpose of getting it spoken out 26 | // as desired (no SSML tags, no FOUR NINER instead of 49, ...). Even though this field is 27 | // optional, please consider providing it as it can be used to display the spoken text to players 28 | // with hearing impairments. 29 | optional string plaintext = 2; 30 | 31 | // The radio frequency in Hz the transmission is send to. Example: 251000000 32 | // for 251.00MHz. 33 | uint64 frequency = 3; 34 | 35 | // Name of the SRS client. Defaults to "DCS-gRPC". 36 | optional string srs_client_name = 4; 37 | 38 | // The origin of the transmission. Relevant if the SRS server has "Line of 39 | // Sight" and/or "Distance Limit" enabled. 40 | dcs.common.v0.InputPosition position = 5; 41 | 42 | // The coalition of the transmission. Relevant if the SRS server has "Secure 43 | // Coalition Radios" enabled. Only Blue and Red are supported, all other 44 | // values will fallback to Spectator. 45 | dcs.common.v0.Coalition coalition = 6; 46 | 47 | // Whether to keep the request open until the whole transmission was sent. If 48 | // enabled, you can send the next transmission after you've received the 49 | // response for the previous one and be sure that they don't overlap (talk 50 | // over each other). If disabled, you'll receive a response right away (kind 51 | // of fire and forget). You can use the returned duration as a spacing between 52 | // TTS requests to prevent the overlap of multiple playbacks yourself. 53 | bool async =7; 54 | 55 | message Aws { 56 | // The voice the text is synthesized in, see: 57 | // https://docs.aws.amazon.com/polly/latest/dg/voicelist.html 58 | optional string voice = 1; 59 | } 60 | 61 | message Azure { 62 | // The voice the text is synthesized in, see: 63 | // https://learn.microsoft.com/azure/cognitive-services/speech-service/language-support 64 | optional string voice = 1; 65 | } 66 | 67 | message GCloud { 68 | // The voice the text is synthesized in, see: 69 | // https://cloud.google.com/text-to-speech/docs/voices 70 | optional string voice = 1; 71 | } 72 | 73 | message Windows { 74 | // The voice the text is synthesized in. 75 | optional string voice = 1; 76 | } 77 | 78 | // Optional TTS provider to be use. Defaults to the one configured in your 79 | // config or to Windows' built-in TTS. 80 | oneof provider { 81 | Aws aws = 8; 82 | Azure azure = 9; 83 | GCloud gcloud = 10; 84 | Windows win = 11; 85 | } 86 | } 87 | 88 | message TransmitResponse { 89 | // The duration in milliseconds it roughly takes to speak the transmission. 90 | uint32 duration_ms = 1; 91 | } 92 | 93 | message GetClientsRequest { 94 | } 95 | 96 | message GetClientsResponse { 97 | message Client { 98 | // The unit that is connected to SRS. 99 | dcs.common.v0.Unit unit = 1; 100 | 101 | // The radio frequencies in Hz the unit is connected to. 102 | repeated uint64 frequencies = 2; 103 | } 104 | 105 | repeated Client clients = 1; 106 | } 107 | -------------------------------------------------------------------------------- /protos/dcs/timer/v0/timer.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | package dcs.timer.v0; 3 | option csharp_namespace = "RurouniJones.Dcs.Grpc.V0.Timer"; 4 | option go_package = "github.com/DCS-gRPC/go-bindings/dcs/v0/timer"; 5 | 6 | // https://wiki.hoggitworld.com/view/DCS_singleton_timer 7 | service TimerService { 8 | // https://wiki.hoggitworld.com/view/DCS_func_getTime 9 | rpc GetTime(GetTimeRequest) returns (GetTimeResponse) {} 10 | 11 | // https://wiki.hoggitworld.com/view/DCS_func_getAbsTime 12 | rpc GetAbsoluteTime(GetAbsoluteTimeRequest) 13 | returns (GetAbsoluteTimeResponse) {} 14 | 15 | // https://wiki.hoggitworld.com/view/DCS_func_getTime0 16 | rpc GetTimeZero(GetTimeZeroRequest) returns (GetTimeZeroResponse) {} 17 | } 18 | 19 | message GetTimeRequest { 20 | } 21 | 22 | message GetTimeResponse { 23 | double time = 1; 24 | } 25 | 26 | message GetAbsoluteTimeRequest { 27 | } 28 | 29 | message GetAbsoluteTimeResponse { 30 | // The current time in seconds since 00:00 of the start date of the mission. 31 | double time = 1; 32 | uint32 day = 2; 33 | uint32 month = 3; 34 | int32 year = 4; 35 | } 36 | 37 | message GetTimeZeroRequest { 38 | } 39 | 40 | message GetTimeZeroResponse { 41 | // The time in seconds since 00:00. 42 | double time = 1; 43 | uint32 day = 2; 44 | uint32 month = 3; 45 | int32 year = 4; 46 | } -------------------------------------------------------------------------------- /protos/dcs/trigger/v0/trigger.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | package dcs.trigger.v0; 3 | import "dcs/common/v0/common.proto"; 4 | option csharp_namespace = "RurouniJones.Dcs.Grpc.V0.Trigger"; 5 | option go_package = "github.com/DCS-gRPC/go-bindings/dcs/v0/trigger"; 6 | 7 | // https://wiki.hoggitworld.com/view/DCS_singleton_trigger 8 | service TriggerService { 9 | // https://wiki.hoggitworld.com/view/DCS_func_outText 10 | rpc OutText(OutTextRequest) returns (OutTextResponse) {} 11 | 12 | // https://wiki.hoggitworld.com/view/DCS_func_outTextForCoalition 13 | rpc OutTextForCoalition(OutTextForCoalitionRequest) 14 | returns (OutTextForCoalitionResponse) {} 15 | 16 | // https://wiki.hoggitworld.com/view/DCS_func_outTextForGroup 17 | rpc OutTextForGroup(OutTextForGroupRequest) 18 | returns (OutTextForGroupResponse) {} 19 | 20 | // https://wiki.hoggitworld.com/view/DCS_func_outTextForUnit 21 | rpc OutTextForUnit(OutTextForUnitRequest) 22 | returns (OutTextForUnitResponse) {} 23 | 24 | // https://wiki.hoggitworld.com/view/DCS_func_getUserFlag 25 | rpc GetUserFlag(GetUserFlagRequest) returns (GetUserFlagResponse) {} 26 | 27 | // https://wiki.hoggitworld.com/view/DCS_func_setUserFlag 28 | rpc SetUserFlag(SetUserFlagRequest) returns (SetUserFlagResponse) {} 29 | 30 | // https://wiki.hoggitworld.com/view/DCS_func_markToAll 31 | rpc MarkToAll(MarkToAllRequest) returns (MarkToAllResponse) {} 32 | 33 | // https://wiki.hoggitworld.com/view/DCS_func_markToCoalition 34 | rpc MarkToCoalition(MarkToCoalitionRequest) 35 | returns (MarkToCoalitionResponse) {} 36 | 37 | // https://wiki.hoggitworld.com/view/DCS_func_markToGroup 38 | rpc MarkToGroup(MarkToGroupRequest) returns (MarkToGroupResponse) {} 39 | 40 | // https://wiki.hoggitworld.com/view/DCS_func_markupToAll 41 | rpc MarkupToAll(MarkupToAllRequest) returns (MarkupToAllResponse) {} 42 | 43 | // Uses markupToAll under the hood but enforces a coalition to be specified 44 | // https://wiki.hoggitworld.com/view/DCS_func_markupToAll 45 | rpc MarkupToCoalition(MarkupToCoalitionRequest) 46 | returns (MarkupToCoalitionResponse) {} 47 | 48 | // https://wiki.hoggitworld.com/view/DCS_func_removeMark 49 | rpc RemoveMark(RemoveMarkRequest) returns (RemoveMarkResponse) {} 50 | 51 | // https://wiki.hoggitworld.com/view/DCS_func_explosion 52 | rpc Explosion(ExplosionRequest) returns (ExplosionResponse) {} 53 | 54 | // https://wiki.hoggitworld.com/view/DCS_func_smoke 55 | rpc Smoke(SmokeRequest) returns (SmokeResponse) {} 56 | 57 | // https://wiki.hoggitworld.com/view/DCS_func_illuminationBomb 58 | rpc IlluminationBomb(IlluminationBombRequest) 59 | returns (IlluminationBombResponse) {} 60 | 61 | // https://wiki.hoggitworld.com/view/DCS_func_signalFlare 62 | rpc SignalFlare(SignalFlareRequest) returns (SignalFlareResponse) {} 63 | } 64 | 65 | message OutTextRequest { 66 | string text = 1; 67 | int32 display_time = 2; 68 | bool clear_view = 3; 69 | } 70 | 71 | message OutTextResponse { 72 | } 73 | 74 | message OutTextForCoalitionRequest { 75 | string text = 1; 76 | int32 display_time = 2; 77 | bool clear_view = 3; 78 | dcs.common.v0.Coalition coalition = 4; 79 | } 80 | 81 | message OutTextForCoalitionResponse { 82 | } 83 | 84 | message OutTextForGroupRequest { 85 | string text = 1; 86 | int32 display_time = 2; 87 | bool clear_view = 3; 88 | uint32 group_id = 4; 89 | } 90 | 91 | message OutTextForGroupResponse { 92 | } 93 | 94 | message OutTextForUnitRequest { 95 | string text = 1; 96 | int32 display_time = 2; 97 | bool clear_view = 3; 98 | uint32 unit_id = 4; 99 | } 100 | 101 | message OutTextForUnitResponse { 102 | } 103 | 104 | message GetUserFlagRequest { 105 | string flag = 1; 106 | } 107 | 108 | message GetUserFlagResponse { 109 | uint32 value = 1; 110 | } 111 | 112 | message SetUserFlagRequest { 113 | string flag = 1; 114 | uint32 value = 2; 115 | } 116 | 117 | message SetUserFlagResponse { 118 | } 119 | 120 | message MarkToAllRequest { 121 | string text = 2; 122 | dcs.common.v0.InputPosition position = 3; 123 | bool read_only = 4; 124 | string message = 5; 125 | } 126 | 127 | message MarkToAllResponse { 128 | uint32 id = 1; 129 | } 130 | 131 | message MarkToCoalitionRequest { 132 | uint32 id = 1; 133 | string text = 2; 134 | dcs.common.v0.InputPosition position = 3; 135 | dcs.common.v0.Coalition coalition = 4; 136 | bool read_only = 5; 137 | string message = 6; 138 | } 139 | 140 | message MarkToCoalitionResponse { 141 | uint32 id = 1; 142 | } 143 | 144 | message MarkToGroupRequest { 145 | uint32 id = 1; 146 | string text = 2; 147 | dcs.common.v0.InputPosition position = 3; 148 | uint32 group_id = 4; 149 | bool read_only = 5; 150 | string message = 6; 151 | } 152 | 153 | message MarkToGroupResponse { 154 | uint32 id = 1; 155 | } 156 | 157 | message RemoveMarkRequest { 158 | uint32 id = 1; 159 | } 160 | 161 | message RemoveMarkResponse { 162 | } 163 | 164 | message ExplosionRequest { 165 | dcs.common.v0.InputPosition position = 1; 166 | uint32 power = 2; 167 | } 168 | 169 | message ExplosionResponse { 170 | } 171 | 172 | message SmokeRequest { 173 | enum SmokeColor { 174 | SMOKE_COLOR_UNSPECIFIED = 0; 175 | SMOKE_COLOR_GREEN = 1; 176 | SMOKE_COLOR_RED = 2; 177 | SMOKE_COLOR_WHITE = 3; 178 | SMOKE_COLOR_ORANGE = 4; 179 | SMOKE_COLOR_BLUE = 5; 180 | } 181 | 182 | // Altitude parameter will be ignored. Smoke always eminates from ground 183 | // level which will be calculated server-side 184 | dcs.common.v0.InputPosition position = 1; 185 | SmokeColor color = 2; 186 | } 187 | 188 | message SmokeResponse { 189 | } 190 | 191 | message IlluminationBombRequest { 192 | // The altitude of Illumination Bombs is meters above ground. Ground level 193 | // will be calculated server-side 194 | dcs.common.v0.InputPosition position = 1; 195 | uint32 power = 2; 196 | } 197 | 198 | message IlluminationBombResponse { 199 | } 200 | 201 | message SignalFlareRequest { 202 | enum FlareColor { 203 | FLARE_COLOR_UNSPECIFIED = 0; 204 | FLARE_COLOR_GREEN = 1; 205 | FLARE_COLOR_RED = 2; 206 | FLARE_COLOR_WHITE = 3; 207 | FLARE_COLOR_YELLOW = 4; 208 | } 209 | 210 | // Altitude parameter will be ignored. Signal flares always fire from 211 | // ground level which will be calculated server-side 212 | dcs.common.v0.InputPosition position = 1; 213 | FlareColor color = 2; 214 | uint32 azimuth = 3; 215 | } 216 | 217 | message SignalFlareResponse { 218 | } 219 | 220 | enum LineType { 221 | // protolint:disable:next ENUM_FIELD_NAMES_ZERO_VALUE_END_WITH 222 | LINE_TYPE_NO_LINE = 0; 223 | LINE_TYPE_SOLID = 1; 224 | LINE_TYPE_DASHED = 2; 225 | LINE_TYPE_DOTTED = 3; 226 | LINE_TYPE_DOT_DASH = 4; 227 | LINE_TYPE_LONG_DASH = 5; 228 | LINE_TYPE_TWO_DASH = 6; 229 | } 230 | 231 | // Represents an RGBA color but instead of using 0-255 as the color 232 | // values it uses 0 to 1. A red color with 50% transparency would be 233 | // RGBA of 1, 0, 0, 0.5 234 | message Color { 235 | double red = 1; 236 | double green = 2; 237 | double blue = 3; 238 | double alpha = 4; 239 | } 240 | 241 | enum Shape { 242 | SHAPE_UNSPECIFIED = 0; 243 | SHAPE_LINE = 1; 244 | SHAPE_CIRCLE = 2; 245 | SHAPE_RECT = 3; 246 | SHAPE_ARROW = 4; 247 | SHAPE_TEXT = 5; 248 | SHAPE_QUAD = 6; 249 | SHAPE_FREEFORM = 7; 250 | } 251 | 252 | message MarkupToAllRequest { 253 | Shape shape = 1; 254 | repeated dcs.common.v0.InputPosition points = 2; 255 | Color border_color = 3; 256 | Color fill_color = 4; 257 | LineType line_type = 5; 258 | bool read_only = 6; 259 | string message = 7; 260 | } 261 | 262 | message MarkupToAllResponse { 263 | uint32 id = 1; 264 | } 265 | 266 | message MarkupToCoalitionRequest { 267 | Shape shape = 1; 268 | dcs.common.v0.Coalition coalition = 2; 269 | repeated dcs.common.v0.InputPosition points = 3; 270 | Color border_color = 4; 271 | Color fill_color = 5; 272 | LineType line_type = 6; 273 | bool read_only = 7; 274 | string message = 8; 275 | } 276 | 277 | message MarkupToCoalitionResponse { 278 | uint32 id = 1; 279 | } -------------------------------------------------------------------------------- /protos/dcs/unit/v0/unit.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | package dcs.unit.v0; 3 | import "dcs/common/v0/common.proto"; 4 | option csharp_namespace = "RurouniJones.Dcs.Grpc.V0.Unit"; 5 | option go_package = "github.com/DCS-gRPC/go-bindings/dcs/v0/unit"; 6 | 7 | // https://wiki.hoggitworld.com/view/DCS_Class_Unit 8 | service UnitService { 9 | // https://wiki.hoggitworld.com/view/DCS_func_getRadar 10 | rpc GetRadar(GetRadarRequest) returns (GetRadarResponse) {} 11 | 12 | // https://wiki.hoggitworld.com/view/DCS_func_getPoint 13 | rpc GetPosition(GetPositionRequest) returns (GetPositionResponse) {} 14 | 15 | // https://wiki.hoggitworld.com/view/DCS_func_getPlayerName 16 | rpc GetPlayerName(GetPlayerNameRequest) returns (GetPlayerNameResponse) {} 17 | 18 | rpc GetDescriptor(GetDescriptorRequest) returns (GetDescriptorResponse) {} 19 | 20 | // https://wiki.hoggitworld.com/view/DCS_func_enableEmission 21 | rpc SetEmission(SetEmissionRequest) returns (SetEmissionResponse) {} 22 | 23 | // https://wiki.hoggitworld.com/view/DCS_func_getByName 24 | rpc Get(GetRequest) returns (GetResponse) {} 25 | 26 | /** 27 | * Get information about the unit in 3D space, including its position, 28 | * orientation and velocity. 29 | */ 30 | rpc GetTransform(GetTransformRequest) returns (GetTransformResponse) {} 31 | 32 | // https://wiki.hoggitworld.com/view/DCS_func_destroy 33 | rpc Destroy(DestroyRequest) returns (DestroyResponse) {} 34 | 35 | // https://wiki.hoggitworld.com/view/DCS_func_getDrawArgumentValue 36 | rpc GetDrawArgumentValue(GetDrawArgumentValueRequest) returns (GetDrawArgumentValueResponse) {} 37 | } 38 | 39 | message GetRadarRequest { 40 | string name = 1; 41 | } 42 | 43 | message GetRadarResponse { 44 | bool active = 1; 45 | dcs.common.v0.Target target = 2; 46 | } 47 | 48 | message GetPositionRequest { 49 | string name = 1; 50 | } 51 | 52 | message GetPositionResponse { 53 | dcs.common.v0.Position position = 1; 54 | } 55 | 56 | message GetDrawArgumentValueRequest { 57 | string name = 1; 58 | uint32 argument = 2; 59 | } 60 | 61 | message GetDrawArgumentValueResponse { 62 | double value = 1; 63 | } 64 | 65 | message GetTransformRequest { 66 | string name = 1; 67 | } 68 | 69 | message GetTransformResponse { 70 | // Time in seconds since the scenario started. 71 | double time = 1; 72 | // The position of the unit 73 | dcs.common.v0.Position position = 2; 74 | // The orientation of the unit in both 2D and 3D space 75 | dcs.common.v0.Orientation orientation = 3; 76 | // The velocity of the unit in both 2D and 3D space 77 | dcs.common.v0.Velocity velocity = 4; 78 | } 79 | 80 | message GetPlayerNameRequest { 81 | string name = 1; 82 | } 83 | 84 | message GetPlayerNameResponse { 85 | optional string player_name = 1; 86 | } 87 | 88 | message GetDescriptorRequest { 89 | string name = 1; 90 | } 91 | 92 | // TODO fill these in as and when we need em 93 | message GetDescriptorResponse { 94 | repeated string attributes = 1; 95 | } 96 | 97 | message SetEmissionRequest { 98 | string name = 1; 99 | bool emitting = 2; 100 | } 101 | 102 | message SetEmissionResponse { 103 | } 104 | 105 | message GetRequest { 106 | string name = 1; 107 | } 108 | 109 | message GetResponse { 110 | dcs.common.v0.Unit unit = 1; 111 | } 112 | 113 | message DestroyRequest { 114 | string name = 1; 115 | } 116 | 117 | message DestroyResponse { 118 | } -------------------------------------------------------------------------------- /protos/dcs/world/v0/world.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | package dcs.world.v0; 3 | import "dcs/common/v0/common.proto"; 4 | option csharp_namespace = "RurouniJones.Dcs.Grpc.V0.World"; 5 | option go_package = "github.com/DCS-gRPC/go-bindings/dcs/v0/world"; 6 | 7 | // https://wiki.hoggitworld.com/view/DCS_singleton_world 8 | service WorldService { 9 | // https://wiki.hoggitworld.com/view/DCS_func_getAirbases 10 | rpc GetAirbases(GetAirbasesRequest) returns (GetAirbasesResponse) {} 11 | 12 | // https://wiki.hoggitworld.com/view/DCS_func_getMarkPanels 13 | rpc GetMarkPanels(GetMarkPanelsRequest) returns (GetMarkPanelsResponse) {} 14 | 15 | // Returns the theatre (Map name) of the mission 16 | rpc GetTheatre(GetTheatreRequest) returns (GetTheatreResponse) {} 17 | } 18 | 19 | message GetAirbasesRequest { 20 | dcs.common.v0.Coalition coalition = 1; 21 | } 22 | 23 | message GetAirbasesResponse { 24 | repeated dcs.common.v0.Airbase airbases = 1; 25 | } 26 | 27 | message GetMarkPanelsRequest { 28 | } 29 | 30 | message GetMarkPanelsResponse { 31 | repeated dcs.common.v0.MarkPanel mark_panels = 1; 32 | } 33 | 34 | message GetTheatreRequest { 35 | } 36 | 37 | message GetTheatreResponse { 38 | string theatre = 1; 39 | } -------------------------------------------------------------------------------- /repl/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "dcs-grpc-repl" 3 | version.workspace = true 4 | rust-version.workspace = true 5 | authors.workspace = true 6 | license.workspace = true 7 | edition.workspace = true 8 | 9 | [dependencies] 10 | clap = { version = "4.5", features = ["derive"] } 11 | stubs = { package = "dcs-grpc-stubs", path = "../stubs", features = ["client"] } 12 | serde_json.workspace = true 13 | thiserror.workspace = true 14 | tokio = { version = "1.24", features = ["rt-multi-thread", "macros"] } 15 | tonic.workspace = true 16 | -------------------------------------------------------------------------------- /repl/src/main.rs: -------------------------------------------------------------------------------- 1 | use std::io::{self, BufRead}; 2 | 3 | use clap::Parser; 4 | use serde_json::Value; 5 | use stubs::custom::v0::custom_service_client::CustomServiceClient; 6 | use stubs::hook::v0::hook_service_client::HookServiceClient; 7 | use stubs::{custom, hook}; 8 | use tonic::{Code, Status, transport}; 9 | 10 | #[derive(Parser)] 11 | #[clap(name = "repl")] 12 | struct Opts { 13 | #[clap(short, long, default_value = "mission", 14 | value_parser = clap::builder::PossibleValuesParser::new(["mission", "hook"]))] 15 | env: String, 16 | } 17 | 18 | enum Client { 19 | Mission(CustomServiceClient), 20 | Hook(HookServiceClient), 21 | } 22 | 23 | #[tokio::main] 24 | async fn main() -> Result<(), Box> { 25 | let opts: Opts = Opts::parse(); 26 | let endpoint = 27 | transport::Endpoint::from_static("http://127.0.0.1:50051").keep_alive_while_idle(true); 28 | let mut client = match opts.env.as_str() { 29 | "mission" => Client::Mission(CustomServiceClient::connect(endpoint).await?), 30 | "hook" => Client::Hook(HookServiceClient::connect(endpoint).await?), 31 | _ => unreachable!("invalid --env value"), 32 | }; 33 | 34 | let stdin = io::stdin(); 35 | let mut lines = stdin.lock().lines(); 36 | loop { 37 | if let Some(line) = lines.next() { 38 | let lua = line?; 39 | let result = match &mut client { 40 | Client::Mission(client) => client 41 | .eval(custom::v0::EvalRequest { lua }) 42 | .await 43 | .map(|res| res.into_inner().json), 44 | Client::Hook(client) => client 45 | .eval(hook::v0::EvalRequest { lua }) 46 | .await 47 | .map(|res| res.into_inner().json), 48 | }; 49 | 50 | let json: Value = match handle_respone(result) { 51 | Ok(json) => json, 52 | Err(Error::Grpc(err)) if err.code() == Code::Unavailable => { 53 | return Err(err.into()); 54 | } 55 | Err(err) => { 56 | eprintln!("{err}"); 57 | continue; 58 | } 59 | }; 60 | 61 | if let Some(s) = json.as_str() { 62 | println!("= {s}"); 63 | } else { 64 | let json = serde_json::to_string_pretty(&json)?; 65 | println!("= {json}"); 66 | } 67 | } 68 | } 69 | } 70 | 71 | #[allow(clippy::result_large_err)] 72 | fn handle_respone(json: Result) -> Result { 73 | let json = json?; 74 | let json: Value = serde_json::from_str(&json)?; 75 | Ok(json) 76 | } 77 | 78 | #[derive(Debug, thiserror::Error)] 79 | enum Error { 80 | #[error(transparent)] 81 | Grpc(#[from] Status), 82 | #[error("failed to decode JSON result")] 83 | Json(#[from] serde_json::Error), 84 | } 85 | -------------------------------------------------------------------------------- /src/authentication.rs: -------------------------------------------------------------------------------- 1 | use tonic::body::Body; 2 | use tonic::codegen::http::Request; 3 | use tonic::{Status, async_trait}; 4 | use tonic_middleware::RequestInterceptor; 5 | 6 | use crate::config::AuthConfig; 7 | 8 | #[derive(Clone)] 9 | pub struct AuthInterceptor { 10 | pub auth_config: AuthConfig, 11 | } 12 | 13 | #[async_trait] 14 | impl RequestInterceptor for AuthInterceptor { 15 | async fn intercept(&self, req: Request) -> Result, Status> { 16 | if !self.auth_config.enabled { 17 | Ok(req) 18 | } else { 19 | match req.headers().get("X-API-Key").map(|v| v.to_str()) { 20 | Some(Ok(token)) => { 21 | let mut client: Option<&String> = None; 22 | for key in &self.auth_config.tokens { 23 | if key.token == token { 24 | client = Some(&key.client); 25 | break; 26 | } 27 | } 28 | 29 | if client.is_some() { 30 | log::debug!("Authenticated client: {}", client.unwrap()); 31 | Ok(req) 32 | } else { 33 | Err(Status::unauthenticated("Unauthenticated")) 34 | } 35 | } 36 | _ => Err(Status::unauthenticated("Unauthenticated")), 37 | } 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/config.rs: -------------------------------------------------------------------------------- 1 | use std::net::SocketAddr; 2 | 3 | use serde::{Deserialize, Serialize}; 4 | 5 | #[derive(Debug, Clone, Deserialize, Serialize)] 6 | #[serde(rename_all = "camelCase")] 7 | pub struct Config { 8 | pub version: String, 9 | pub write_dir: String, 10 | pub dll_path: String, 11 | pub lua_path: String, 12 | #[serde(default = "default_host")] 13 | pub host: String, 14 | #[serde(default = "default_port")] 15 | pub port: u16, 16 | #[serde(default)] 17 | pub debug: bool, 18 | #[serde(default)] 19 | pub eval_enabled: bool, 20 | #[serde(default)] 21 | pub integrity_check_disabled: bool, 22 | pub tts: Option, 23 | pub srs: Option, 24 | pub auth: Option, 25 | } 26 | 27 | #[derive(Debug, Clone, Default, Deserialize, Serialize)] 28 | #[serde(rename_all = "camelCase")] 29 | pub struct TtsConfig { 30 | #[serde(default)] 31 | pub default_provider: TtsProvider, 32 | pub provider: Option, 33 | } 34 | 35 | #[derive(Debug, Clone, Deserialize, Serialize)] 36 | #[serde(rename_all = "camelCase")] 37 | pub struct TtsProviderConfig { 38 | pub aws: Option, 39 | pub azure: Option, 40 | pub gcloud: Option, 41 | pub win: Option, 42 | } 43 | 44 | #[derive(Debug, Clone, Default, Deserialize, Serialize)] 45 | #[serde(rename_all = "lowercase")] 46 | pub enum TtsProvider { 47 | Aws, 48 | Azure, 49 | GCloud, 50 | #[default] 51 | Win, 52 | } 53 | 54 | #[derive(Clone, Deserialize, Serialize)] 55 | #[serde(rename_all = "camelCase")] 56 | pub struct AwsConfig { 57 | pub key: Option, 58 | pub secret: Option, 59 | pub region: Option, 60 | pub default_voice: Option, 61 | } 62 | 63 | #[derive(Clone, Deserialize, Serialize)] 64 | #[serde(rename_all = "camelCase")] 65 | pub struct AzureConfig { 66 | pub key: Option, 67 | pub region: Option, 68 | pub default_voice: Option, 69 | } 70 | 71 | #[derive(Clone, Deserialize, Serialize)] 72 | #[serde(rename_all = "camelCase")] 73 | pub struct GCloudConfig { 74 | pub key: Option, 75 | pub default_voice: Option, 76 | } 77 | 78 | #[derive(Debug, Clone, Deserialize, Serialize)] 79 | #[serde(rename_all = "camelCase")] 80 | pub struct WinConfig { 81 | pub default_voice: Option, 82 | } 83 | 84 | #[derive(Debug, Clone, Default, Deserialize, Serialize)] 85 | #[serde(rename_all = "camelCase")] 86 | pub struct SrsConfig { 87 | #[serde(default)] 88 | pub addr: Option, 89 | } 90 | 91 | #[derive(Debug, Clone, Default, Deserialize, Serialize)] 92 | #[serde(rename_all = "camelCase")] 93 | pub struct AuthConfig { 94 | #[serde(default)] 95 | pub enabled: bool, 96 | pub tokens: Vec, 97 | } 98 | 99 | #[derive(Debug, Clone, Default, Deserialize, Serialize)] 100 | #[serde(rename_all = "camelCase")] 101 | pub struct ApiKey { 102 | #[serde(default)] 103 | pub client: String, 104 | pub token: String, 105 | } 106 | 107 | fn default_host() -> String { 108 | String::from("127.0.0.1") 109 | } 110 | 111 | fn default_port() -> u16 { 112 | 50051 113 | } 114 | 115 | impl mlua::FromLua for Config { 116 | fn from_lua(lua_value: mlua::Value, lua: &mlua::Lua) -> mlua::Result { 117 | use mlua::LuaSerdeExt; 118 | let config: Config = lua.from_value(lua_value)?; 119 | Ok(config) 120 | } 121 | } 122 | 123 | impl std::fmt::Debug for AwsConfig { 124 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 125 | let AwsConfig { 126 | key, 127 | secret, 128 | region, 129 | default_voice, 130 | } = self; 131 | f.debug_struct("AwsConfig") 132 | .field("key", &key.as_ref().map(|_| "")) 133 | .field("secret", &secret.as_ref().map(|_| "")) 134 | .field("region", region) 135 | .field("default_voice", default_voice) 136 | .finish() 137 | } 138 | } 139 | 140 | impl std::fmt::Debug for AzureConfig { 141 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 142 | let AzureConfig { 143 | key, 144 | region, 145 | default_voice, 146 | } = self; 147 | f.debug_struct("AzureConfig") 148 | .field("key", &key.as_ref().map(|_| "")) 149 | .field("region", region) 150 | .field("default_voice", default_voice) 151 | .finish() 152 | } 153 | } 154 | 155 | impl std::fmt::Debug for GCloudConfig { 156 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 157 | let GCloudConfig { key, default_voice } = self; 158 | f.debug_struct("GCloudConfig") 159 | .field("key", &key.as_ref().map(|_| "")) 160 | .field("default_voice", default_voice) 161 | .finish() 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /src/fps.rs: -------------------------------------------------------------------------------- 1 | use std::future::Future; 2 | use std::sync::atomic::{AtomicU32, Ordering}; 3 | use std::time::Duration; 4 | 5 | use dcs_module_ipc::IPC; 6 | use stubs::mission::v0::StreamEventsResponse; 7 | use stubs::mission::v0::stream_events_response::{Event, SimulationFpsEvent}; 8 | use tokio::time::{MissedTickBehavior, interval}; 9 | 10 | static FPS: AtomicU32 = AtomicU32::new(0); 11 | static TIME: AtomicU32 = AtomicU32::new(0); 12 | 13 | pub fn frame(time: f64) { 14 | // Increase the frame count by one 15 | FPS.fetch_add(1, Ordering::Relaxed); 16 | 17 | // Update the DCS simulation time (convert it to an int as there are no atomic floats) 18 | TIME.store((time * 1000.0) as u32, Ordering::Relaxed); 19 | } 20 | 21 | pub async fn run_in_background( 22 | ipc: IPC, 23 | mut shutdown_signal: impl Future + Unpin, 24 | ) { 25 | let mut interval = interval(Duration::from_secs(1)); 26 | interval.set_missed_tick_behavior(MissedTickBehavior::Skip); 27 | 28 | // clear FPS counter when first being started 29 | let mut previous = interval.tick().await; 30 | FPS.store(0, Ordering::Relaxed); 31 | 32 | loop { 33 | // wait for either the shutdown signal or the next interval tick, whatever happens first 34 | let instant = tokio::select! { 35 | _ = &mut shutdown_signal => { 36 | break 37 | } 38 | instant = interval.tick() => instant 39 | }; 40 | 41 | // Technically, there could be a simulation frame between the read of `frame_count` and 42 | // `time` so that `time` already receives a newer value. However, this should be rare and 43 | // shouldn't really matter for the resolution measured here. 44 | let frame_count = FPS.swap(0, Ordering::Relaxed); 45 | 46 | let elapsed = instant - previous; 47 | previous = instant; 48 | let average = (frame_count as f64) / elapsed.as_secs_f64(); 49 | 50 | ipc.event(StreamEventsResponse { 51 | time: event_time(), 52 | event: Some(Event::SimulationFps(SimulationFpsEvent { average })), 53 | }) 54 | .await; 55 | } 56 | } 57 | 58 | pub fn event_time() -> f64 { 59 | let time = TIME.load(Ordering::Relaxed); 60 | f64::from(time) / 1000.0 61 | } 62 | -------------------------------------------------------------------------------- /src/hot_reload.rs: -------------------------------------------------------------------------------- 1 | #![allow(clippy::type_complexity)] 2 | ///! This module is a wrapper around all exposed Lua methods which are forwarded to a dynamically 3 | ///! loaded dcs_grpc.dll. Upon calling the `stop()` method, the library is unloaded, and re- 4 | ///! loaded during the next `start()` call. 5 | use std::path::PathBuf; 6 | use std::sync::{Arc, RwLock}; 7 | use std::{error, fmt}; 8 | 9 | use libloading::{Library, Symbol}; 10 | use mlua::prelude::*; 11 | use mlua::{Function, Value}; 12 | use once_cell::sync::Lazy; 13 | 14 | use crate::Config; 15 | use crate::server::TtsOptions; 16 | 17 | static LIBRARY: Lazy>> = Lazy::new(|| RwLock::new(None)); 18 | 19 | pub fn start(lua: &Lua, config: Config) -> LuaResult<(bool, Option)> { 20 | let lib_path = { 21 | let mut lib_path = PathBuf::from(&config.dll_path); 22 | lib_path.push("dcs_grpc.dll"); 23 | lib_path 24 | }; 25 | 26 | let new_lib = unsafe { Library::new(lib_path) }.map_err(|err| { 27 | log::error!("Load: {}", err); 28 | mlua::Error::ExternalError(Arc::new(err)) 29 | })?; 30 | let mut lib = LIBRARY.write().unwrap(); 31 | let lib = lib.get_or_insert(new_lib); 32 | 33 | let f: Symbol LuaResult<(bool, Option)>> = unsafe { 34 | lib.get(b"start") 35 | .map_err(|err| mlua::Error::ExternalError(Arc::new(err)))? 36 | }; 37 | f(lua, config).map_err(take_error_ownership) 38 | } 39 | 40 | pub fn stop(lua: &Lua, arg: ()) -> LuaResult<()> { 41 | if let Some(lib) = LIBRARY.write().unwrap().take() { 42 | let f: Symbol LuaResult<()>> = unsafe { 43 | lib.get(b"stop") 44 | .map_err(|err| mlua::Error::ExternalError(Arc::new(err)))? 45 | }; 46 | f(lua, arg).map_err(take_error_ownership) 47 | } else { 48 | Ok(()) 49 | } 50 | } 51 | 52 | pub fn next(lua: &Lua, arg: (i32, Function)) -> LuaResult { 53 | if let Some(ref lib) = *LIBRARY.read().unwrap() { 54 | let f: Symbol LuaResult> = unsafe { 55 | lib.get(b"next") 56 | .map_err(|err| mlua::Error::ExternalError(Arc::new(err)))? 57 | }; 58 | f(lua, arg).map_err(take_error_ownership) 59 | } else { 60 | Ok(false) 61 | } 62 | } 63 | 64 | pub fn tts(lua: &Lua, arg: (String, u64, Option)) -> LuaResult<()> { 65 | if let Some(ref lib) = *LIBRARY.read().unwrap() { 66 | let f: Symbol)) -> LuaResult<()>> = unsafe { 67 | lib.get(b"tts") 68 | .map_err(|err| mlua::Error::ExternalError(Arc::new(err)))? 69 | }; 70 | f(lua, arg).map_err(take_error_ownership) 71 | } else { 72 | Ok(()) 73 | } 74 | } 75 | 76 | pub fn event(lua: &Lua, event: Value) -> LuaResult<()> { 77 | if let Some(ref lib) = *LIBRARY.read().unwrap() { 78 | let f: Symbol LuaResult<()>> = unsafe { 79 | lib.get(b"event") 80 | .map_err(|err| mlua::Error::ExternalError(Arc::new(err)))? 81 | }; 82 | f(lua, event).map_err(take_error_ownership) 83 | } else { 84 | Ok(()) 85 | } 86 | } 87 | 88 | pub fn simulation_frame(lua: &Lua, time: f64) -> LuaResult<()> { 89 | if let Some(ref lib) = *LIBRARY.read().unwrap() { 90 | let f: Symbol LuaResult<()>> = unsafe { 91 | lib.get(b"simulation_frame") 92 | .map_err(|err| mlua::Error::ExternalError(Arc::new(err)))? 93 | }; 94 | f(lua, time).map_err(take_error_ownership) 95 | } else { 96 | Ok(()) 97 | } 98 | } 99 | 100 | pub fn log_error(lua: &Lua, err: String) -> LuaResult<()> { 101 | if let Some(ref lib) = *LIBRARY.read().unwrap() { 102 | let f: Symbol LuaResult<()>> = unsafe { 103 | lib.get(b"log_error") 104 | .map_err(|err| mlua::Error::ExternalError(Arc::new(err)))? 105 | }; 106 | f(lua, err).map_err(take_error_ownership) 107 | } else { 108 | Ok(()) 109 | } 110 | } 111 | 112 | pub fn log_warning(lua: &Lua, err: String) -> LuaResult<()> { 113 | if let Some(ref lib) = *LIBRARY.read().unwrap() { 114 | let f: Symbol LuaResult<()>> = unsafe { 115 | lib.get(b"log_warning") 116 | .map_err(|err| mlua::Error::ExternalError(Arc::new(err)))? 117 | }; 118 | f(lua, err).map_err(take_error_ownership) 119 | } else { 120 | Ok(()) 121 | } 122 | } 123 | 124 | pub fn log_info(lua: &Lua, msg: String) -> LuaResult<()> { 125 | if let Some(ref lib) = *LIBRARY.read().unwrap() { 126 | let f: Symbol LuaResult<()>> = unsafe { 127 | lib.get(b"log_info") 128 | .map_err(|err| mlua::Error::ExternalError(Arc::new(err)))? 129 | }; 130 | f(lua, msg).map_err(take_error_ownership) 131 | } else { 132 | Ok(()) 133 | } 134 | } 135 | 136 | pub fn log_debug(lua: &Lua, msg: String) -> LuaResult<()> { 137 | if let Some(ref lib) = *LIBRARY.read().unwrap() { 138 | let f: Symbol LuaResult<()>> = unsafe { 139 | lib.get(b"log_debug") 140 | .map_err(|err| mlua::Error::ExternalError(Arc::new(err)))? 141 | }; 142 | f(lua, msg).map_err(take_error_ownership) 143 | } else { 144 | Ok(()) 145 | } 146 | } 147 | 148 | // Forwarding an Arc received from the dynamically loaded lib causes a crash as soon as the DCS 149 | // mission is unloaded. This is most probably an ownership-issue. As a simple workaround, any 150 | // received Arc is simply converted to the string representation of its inner error and forwarded 151 | // in a new Arc which is owned by the hot-reload dll. 152 | fn take_error_ownership(err: mlua::Error) -> mlua::Error { 153 | match err { 154 | mlua::Error::ExternalError(arc) => { 155 | mlua::Error::ExternalError(Arc::new(StringError(arc.to_string()))) 156 | } 157 | err => err, 158 | } 159 | } 160 | 161 | #[derive(Debug)] 162 | struct StringError(String); 163 | 164 | impl fmt::Display for StringError { 165 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 166 | self.0.fmt(f) 167 | } 168 | } 169 | 170 | impl error::Error for StringError {} 171 | -------------------------------------------------------------------------------- /src/integrity.rs: -------------------------------------------------------------------------------- 1 | use std::collections::hash_map::DefaultHasher; 2 | use std::fs::File; 3 | use std::hash::Hasher; 4 | use std::io::{self, BufReader, Read}; 5 | use std::path::{Path, PathBuf}; 6 | use std::{error, fmt}; 7 | 8 | use crate::config::Config; 9 | 10 | include!(concat!(env!("OUT_DIR"), "/lua_files.rs")); 11 | 12 | /// Check the integrity (compare the file hashes) of all DCS-gRPC related Lua files. 13 | pub fn check(config: &Config) -> Result<(), IntegrityError> { 14 | let dcs_grpc_base_path = AsRef::::as_ref(&config.lua_path); 15 | for (path, expected_hash) in DCS_GRPC { 16 | let path = dcs_grpc_base_path.join(path); 17 | log::debug!("checking integrity of `{}`", path.display()); 18 | let file = File::open(&path).map_err(|err| IntegrityError::Read(path.clone(), err))?; 19 | let hash = file_hash(&file).map_err(|err| IntegrityError::Hash(path.clone(), err))?; 20 | if hash != *expected_hash { 21 | return Err(IntegrityError::HashMismatch(path)); 22 | } 23 | } 24 | 25 | let hooks_base_path = AsRef::::as_ref(&config.write_dir).join("Scripts/Hooks"); 26 | for (path, expected_hash) in HOOKS { 27 | let path = hooks_base_path.join(path); 28 | log::debug!("checking integrity of `{}`", path.display()); 29 | let file = File::open(&path).map_err(|err| IntegrityError::Read(path.clone(), err))?; 30 | let hash = file_hash(&file).map_err(|err| IntegrityError::Hash(path.clone(), err))?; 31 | if hash != *expected_hash { 32 | return Err(IntegrityError::HashMismatch(path)); 33 | } 34 | } 35 | 36 | Ok(()) 37 | } 38 | 39 | fn file_hash(file: &File) -> io::Result { 40 | // Not a cryptographic hasher, but good enough for our use-case. 41 | let mut hasher = DefaultHasher::new(); 42 | let mut buffer = [0; 1024]; 43 | let mut reader = BufReader::new(file); 44 | 45 | loop { 46 | let count = reader.read(&mut buffer)?; 47 | if count == 0 { 48 | break; 49 | } 50 | hasher.write(&buffer[..count]); 51 | } 52 | 53 | Ok(hasher.finish()) 54 | } 55 | 56 | #[derive(Debug)] 57 | pub enum IntegrityError { 58 | Read(PathBuf, io::Error), 59 | Hash(PathBuf, io::Error), 60 | HashMismatch(PathBuf), 61 | } 62 | 63 | impl error::Error for IntegrityError {} 64 | 65 | impl fmt::Display for IntegrityError { 66 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 67 | write!(f, "integrity check failed ")?; 68 | match self { 69 | IntegrityError::Read(path, err) => { 70 | write!(f, "(could not read `{}`: {err})", path.display()) 71 | } 72 | IntegrityError::Hash(path, err) => { 73 | write!(f, "(could not create hash for `{}`: {err})", path.display()) 74 | } 75 | IntegrityError::HashMismatch(path) => { 76 | write!(f, "(hash mismatch of `{}`)", path.display()) 77 | } 78 | }?; 79 | write!( 80 | f, 81 | ", DCS-gRPC is not started, please check your installation" 82 | )?; 83 | Ok(()) 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/lua5.1/include/LICENCE.txt: -------------------------------------------------------------------------------- 1 | 2 | Copyright © 1994–2019 Lua.org, PUC-Rio. 3 | 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: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | 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. -------------------------------------------------------------------------------- /src/lua5.1/include/lauxlib.h: -------------------------------------------------------------------------------- 1 | /* 2 | ** $Id: lauxlib.h,v 1.88.1.1 2007/12/27 13:02:25 roberto Exp $ 3 | ** Auxiliary functions for building Lua libraries 4 | ** See Copyright Notice in lua.h 5 | */ 6 | 7 | 8 | #ifndef lauxlib_h 9 | #define lauxlib_h 10 | 11 | 12 | #include 13 | #include 14 | 15 | #include "lua.h" 16 | 17 | 18 | #if defined(LUA_COMPAT_GETN) 19 | LUALIB_API int (luaL_getn) (lua_State *L, int t); 20 | LUALIB_API void (luaL_setn) (lua_State *L, int t, int n); 21 | #else 22 | #define luaL_getn(L,i) ((int)lua_objlen(L, i)) 23 | #define luaL_setn(L,i,j) ((void)0) /* no op! */ 24 | #endif 25 | 26 | #if defined(LUA_COMPAT_OPENLIB) 27 | #define luaI_openlib luaL_openlib 28 | #endif 29 | 30 | 31 | /* extra error code for `luaL_load' */ 32 | #define LUA_ERRFILE (LUA_ERRERR+1) 33 | 34 | 35 | typedef struct luaL_Reg { 36 | const char *name; 37 | lua_CFunction func; 38 | } luaL_Reg; 39 | 40 | 41 | 42 | LUALIB_API void (luaI_openlib) (lua_State *L, const char *libname, 43 | const luaL_Reg *l, int nup); 44 | LUALIB_API void (luaL_register) (lua_State *L, const char *libname, 45 | const luaL_Reg *l); 46 | LUALIB_API int (luaL_getmetafield) (lua_State *L, int obj, const char *e); 47 | LUALIB_API int (luaL_callmeta) (lua_State *L, int obj, const char *e); 48 | LUALIB_API int (luaL_typerror) (lua_State *L, int narg, const char *tname); 49 | LUALIB_API int (luaL_argerror) (lua_State *L, int numarg, const char *extramsg); 50 | LUALIB_API const char *(luaL_checklstring) (lua_State *L, int numArg, 51 | size_t *l); 52 | LUALIB_API const char *(luaL_optlstring) (lua_State *L, int numArg, 53 | const char *def, size_t *l); 54 | LUALIB_API lua_Number (luaL_checknumber) (lua_State *L, int numArg); 55 | LUALIB_API lua_Number (luaL_optnumber) (lua_State *L, int nArg, lua_Number def); 56 | 57 | LUALIB_API lua_Integer (luaL_checkinteger) (lua_State *L, int numArg); 58 | LUALIB_API lua_Integer (luaL_optinteger) (lua_State *L, int nArg, 59 | lua_Integer def); 60 | 61 | LUALIB_API void (luaL_checkstack) (lua_State *L, int sz, const char *msg); 62 | LUALIB_API void (luaL_checktype) (lua_State *L, int narg, int t); 63 | LUALIB_API void (luaL_checkany) (lua_State *L, int narg); 64 | 65 | LUALIB_API int (luaL_newmetatable) (lua_State *L, const char *tname); 66 | LUALIB_API void *(luaL_checkudata) (lua_State *L, int ud, const char *tname); 67 | 68 | LUALIB_API void (luaL_where) (lua_State *L, int lvl); 69 | LUALIB_API int (luaL_error) (lua_State *L, const char *fmt, ...); 70 | 71 | LUALIB_API int (luaL_checkoption) (lua_State *L, int narg, const char *def, 72 | const char *const lst[]); 73 | 74 | LUALIB_API int (luaL_ref) (lua_State *L, int t); 75 | LUALIB_API void (luaL_unref) (lua_State *L, int t, int ref); 76 | 77 | LUALIB_API int (luaL_loadfile) (lua_State *L, const char *filename); 78 | LUALIB_API int (luaL_loadbuffer) (lua_State *L, const char *buff, size_t sz, 79 | const char *name); 80 | LUALIB_API int (luaL_loadstring) (lua_State *L, const char *s); 81 | 82 | LUALIB_API lua_State *(luaL_newstate) (void); 83 | 84 | 85 | LUALIB_API const char *(luaL_gsub) (lua_State *L, const char *s, const char *p, 86 | const char *r); 87 | 88 | LUALIB_API const char *(luaL_findtable) (lua_State *L, int idx, 89 | const char *fname, int szhint); 90 | 91 | 92 | 93 | 94 | /* 95 | ** =============================================================== 96 | ** some useful macros 97 | ** =============================================================== 98 | */ 99 | 100 | #define luaL_argcheck(L, cond,numarg,extramsg) \ 101 | ((void)((cond) || luaL_argerror(L, (numarg), (extramsg)))) 102 | #define luaL_checkstring(L,n) (luaL_checklstring(L, (n), NULL)) 103 | #define luaL_optstring(L,n,d) (luaL_optlstring(L, (n), (d), NULL)) 104 | #define luaL_checkint(L,n) ((int)luaL_checkinteger(L, (n))) 105 | #define luaL_optint(L,n,d) ((int)luaL_optinteger(L, (n), (d))) 106 | #define luaL_checklong(L,n) ((long)luaL_checkinteger(L, (n))) 107 | #define luaL_optlong(L,n,d) ((long)luaL_optinteger(L, (n), (d))) 108 | 109 | #define luaL_typename(L,i) lua_typename(L, lua_type(L,(i))) 110 | 111 | #define luaL_dofile(L, fn) \ 112 | (luaL_loadfile(L, fn) || lua_pcall(L, 0, LUA_MULTRET, 0)) 113 | 114 | #define luaL_dostring(L, s) \ 115 | (luaL_loadstring(L, s) || lua_pcall(L, 0, LUA_MULTRET, 0)) 116 | 117 | #define luaL_getmetatable(L,n) (lua_getfield(L, LUA_REGISTRYINDEX, (n))) 118 | 119 | #define luaL_opt(L,f,n,d) (lua_isnoneornil(L,(n)) ? (d) : f(L,(n))) 120 | 121 | /* 122 | ** {====================================================== 123 | ** Generic Buffer manipulation 124 | ** ======================================================= 125 | */ 126 | 127 | 128 | 129 | typedef struct luaL_Buffer { 130 | char *p; /* current position in buffer */ 131 | int lvl; /* number of strings in the stack (level) */ 132 | lua_State *L; 133 | char buffer[LUAL_BUFFERSIZE]; 134 | } luaL_Buffer; 135 | 136 | #define luaL_addchar(B,c) \ 137 | ((void)((B)->p < ((B)->buffer+LUAL_BUFFERSIZE) || luaL_prepbuffer(B)), \ 138 | (*(B)->p++ = (char)(c))) 139 | 140 | /* compatibility only */ 141 | #define luaL_putchar(B,c) luaL_addchar(B,c) 142 | 143 | #define luaL_addsize(B,n) ((B)->p += (n)) 144 | 145 | LUALIB_API void (luaL_buffinit) (lua_State *L, luaL_Buffer *B); 146 | LUALIB_API char *(luaL_prepbuffer) (luaL_Buffer *B); 147 | LUALIB_API void (luaL_addlstring) (luaL_Buffer *B, const char *s, size_t l); 148 | LUALIB_API void (luaL_addstring) (luaL_Buffer *B, const char *s); 149 | LUALIB_API void (luaL_addvalue) (luaL_Buffer *B); 150 | LUALIB_API void (luaL_pushresult) (luaL_Buffer *B); 151 | 152 | 153 | /* }====================================================== */ 154 | 155 | 156 | /* compatibility with ref system */ 157 | 158 | /* pre-defined references */ 159 | #define LUA_NOREF (-2) 160 | #define LUA_REFNIL (-1) 161 | 162 | #define lua_ref(L,lock) ((lock) ? luaL_ref(L, LUA_REGISTRYINDEX) : \ 163 | (lua_pushstring(L, "unlocked references are obsolete"), lua_error(L), 0)) 164 | 165 | #define lua_unref(L,ref) luaL_unref(L, LUA_REGISTRYINDEX, (ref)) 166 | 167 | #define lua_getref(L,ref) lua_rawgeti(L, LUA_REGISTRYINDEX, (ref)) 168 | 169 | 170 | #define luaL_reg luaL_Reg 171 | 172 | #endif 173 | 174 | 175 | -------------------------------------------------------------------------------- /src/lua5.1/include/lua.hpp: -------------------------------------------------------------------------------- 1 | // lua.hpp 2 | // Lua header files for C++ 3 | // <> not supplied automatically because Lua also compiles as C++ 4 | 5 | extern "C" { 6 | #include "lua.h" 7 | #include "lualib.h" 8 | #include "lauxlib.h" 9 | } 10 | -------------------------------------------------------------------------------- /src/lua5.1/include/lualib.h: -------------------------------------------------------------------------------- 1 | /* 2 | ** $Id: lualib.h,v 1.36.1.1 2007/12/27 13:02:25 roberto Exp $ 3 | ** Lua standard libraries 4 | ** See Copyright Notice in lua.h 5 | */ 6 | 7 | 8 | #ifndef lualib_h 9 | #define lualib_h 10 | 11 | #include "lua.h" 12 | 13 | 14 | /* Key to file-handle type */ 15 | #define LUA_FILEHANDLE "FILE*" 16 | 17 | 18 | #define LUA_COLIBNAME "coroutine" 19 | LUALIB_API int (luaopen_base) (lua_State *L); 20 | 21 | #define LUA_TABLIBNAME "table" 22 | LUALIB_API int (luaopen_table) (lua_State *L); 23 | 24 | #define LUA_IOLIBNAME "io" 25 | LUALIB_API int (luaopen_io) (lua_State *L); 26 | 27 | #define LUA_OSLIBNAME "os" 28 | LUALIB_API int (luaopen_os) (lua_State *L); 29 | 30 | #define LUA_STRLIBNAME "string" 31 | LUALIB_API int (luaopen_string) (lua_State *L); 32 | 33 | #define LUA_MATHLIBNAME "math" 34 | LUALIB_API int (luaopen_math) (lua_State *L); 35 | 36 | #define LUA_DBLIBNAME "debug" 37 | LUALIB_API int (luaopen_debug) (lua_State *L); 38 | 39 | #define LUA_LOADLIBNAME "package" 40 | LUALIB_API int (luaopen_package) (lua_State *L); 41 | 42 | 43 | /* open all previous libraries */ 44 | LUALIB_API void (luaL_openlibs) (lua_State *L); 45 | 46 | 47 | 48 | #ifndef lua_assert 49 | #define lua_assert(x) ((void)0) 50 | #endif 51 | 52 | 53 | #endif 54 | -------------------------------------------------------------------------------- /src/lua5.1/lua.lib: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DCS-gRPC/rust-server/844b07086dd36433658dcf65b3313baf7338700e/src/lua5.1/lua.lib -------------------------------------------------------------------------------- /src/rpc.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Arc; 2 | 3 | use dcs_module_ipc::IPC; 4 | use futures_util::Stream; 5 | use stubs::mission::v0::StreamEventsResponse; 6 | use tokio::sync::RwLock; 7 | use tonic::{Request, Status}; 8 | 9 | pub use self::srs::Srs; 10 | use crate::shutdown::ShutdownHandle; 11 | use crate::stats::Stats; 12 | 13 | mod atmosphere; 14 | mod coalition; 15 | mod controller; 16 | mod custom; 17 | mod group; 18 | mod hook; 19 | mod metadata; 20 | mod mission; 21 | mod net; 22 | mod srs; 23 | mod timer; 24 | mod trigger; 25 | mod unit; 26 | mod world; 27 | 28 | #[derive(Clone)] 29 | pub struct MissionRpc { 30 | ipc: IPC, 31 | stats: Stats, 32 | eval_enabled: bool, 33 | shutdown_signal: ShutdownHandle, 34 | cache: Arc>, 35 | } 36 | 37 | #[derive(Default)] 38 | struct Cache { 39 | scenario_start_time: Option, 40 | } 41 | 42 | #[derive(Clone)] 43 | pub struct HookRpc { 44 | ipc: IPC<()>, 45 | stats: Stats, 46 | eval_enabled: bool, 47 | shutdown_signal: ShutdownHandle, 48 | } 49 | 50 | impl MissionRpc { 51 | pub fn new( 52 | ipc: IPC, 53 | stats: Stats, 54 | shutdown_signal: ShutdownHandle, 55 | ) -> Self { 56 | MissionRpc { 57 | ipc, 58 | stats, 59 | eval_enabled: false, 60 | shutdown_signal, 61 | cache: Default::default(), 62 | } 63 | } 64 | 65 | pub fn enable_eval(&mut self) { 66 | self.eval_enabled = true; 67 | } 68 | 69 | pub async fn request(&self, method: &str, request: Request) -> Result 70 | where 71 | I: serde::Serialize + Send + Sync + 'static, 72 | for<'de> O: serde::Deserialize<'de> + Send + Sync + std::fmt::Debug + 'static, 73 | { 74 | let _guard = self.stats.track_queue_size(); 75 | self.ipc 76 | .request(method, Some(request.into_inner())) 77 | .await 78 | .map_err(to_status) 79 | } 80 | 81 | pub async fn events(&self) -> impl Stream + use<> { 82 | let ipc = self.ipc.clone(); 83 | ipc.events().await 84 | } 85 | 86 | pub async fn event(&self, event: StreamEventsResponse) { 87 | log::debug!("Received event: {:#?}", event); 88 | self.ipc.event(event).await 89 | } 90 | } 91 | 92 | impl HookRpc { 93 | pub fn new(ipc: IPC<()>, stats: Stats, shutdown_signal: ShutdownHandle) -> Self { 94 | HookRpc { 95 | ipc, 96 | stats, 97 | eval_enabled: false, 98 | shutdown_signal, 99 | } 100 | } 101 | 102 | pub fn enable_eval(&mut self) { 103 | self.eval_enabled = true; 104 | } 105 | 106 | pub async fn request(&self, method: &str, request: Request) -> Result 107 | where 108 | I: serde::Serialize + Send + Sync + 'static, 109 | for<'de> O: serde::Deserialize<'de> + Send + Sync + std::fmt::Debug + 'static, 110 | { 111 | let _guard = self.stats.track_queue_size(); 112 | self.ipc 113 | .request(method, Some(request.into_inner())) 114 | .await 115 | .map_err(to_status) 116 | } 117 | } 118 | 119 | fn to_status(err: dcs_module_ipc::Error) -> Status { 120 | use dcs_module_ipc::Error; 121 | match err { 122 | Error::Script { kind, message } => match kind.as_deref() { 123 | Some("INVALID_ARGUMENT") => Status::invalid_argument(message), 124 | Some("NOT_FOUND") => Status::not_found(message), 125 | Some("ALREADY_EXISTS") => Status::already_exists(message), 126 | Some("UNIMPLEMENTED") => Status::unimplemented(message), 127 | _ => Status::internal(message), 128 | }, 129 | err => Status::internal(err.to_string()), 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /src/rpc/atmosphere.rs: -------------------------------------------------------------------------------- 1 | use stubs::atmosphere::v0::atmosphere_service_server::AtmosphereService; 2 | use stubs::*; 3 | use tonic::{Request, Response, Status}; 4 | 5 | use super::MissionRpc; 6 | 7 | #[tonic::async_trait] 8 | impl AtmosphereService for MissionRpc { 9 | async fn get_wind( 10 | &self, 11 | request: Request, 12 | ) -> Result, Status> { 13 | let velocity: common::v0::Vector = self.request("getWind", request).await?; 14 | let (heading, strength) = get_wind_heading_and_strength(&velocity); 15 | Ok(Response::new(atmosphere::v0::GetWindResponse { 16 | heading, 17 | strength, 18 | })) 19 | } 20 | 21 | async fn get_wind_with_turbulence( 22 | &self, 23 | request: Request, 24 | ) -> Result, Status> { 25 | let velocity: common::v0::Vector = self.request("getWindWithTurbulence", request).await?; 26 | let (heading, strength) = get_wind_heading_and_strength(&velocity); 27 | Ok(Response::new( 28 | atmosphere::v0::GetWindWithTurbulenceResponse { heading, strength }, 29 | )) 30 | } 31 | 32 | async fn get_temperature_and_pressure( 33 | &self, 34 | request: Request, 35 | ) -> Result, Status> { 36 | let res = self.request("getTemperatureAndPressure", request).await?; 37 | Ok(Response::new(res)) 38 | } 39 | } 40 | 41 | fn get_wind_heading_and_strength(v: &common::v0::Vector) -> (f32, f32) { 42 | let mut heading = v.x.atan2(v.z).to_degrees(); 43 | if heading < 0.0 { 44 | heading += 360.0; 45 | } 46 | 47 | // convert TO direction to FROM direction 48 | if heading > 180.0 { 49 | heading -= 180.0; 50 | } else { 51 | heading += 180.0; 52 | } 53 | 54 | // calc 2D strength 55 | let strength = (v.z.powi(2) + v.x.powi(2)).sqrt(); 56 | 57 | (heading as f32, strength as f32) 58 | } 59 | -------------------------------------------------------------------------------- /src/rpc/coalition.rs: -------------------------------------------------------------------------------- 1 | use stubs::coalition::v0::coalition_service_server::CoalitionService; 2 | use stubs::*; 3 | use tonic::{Request, Response, Status}; 4 | 5 | use super::MissionRpc; 6 | 7 | #[tonic::async_trait] 8 | impl CoalitionService for MissionRpc { 9 | async fn add_group( 10 | &self, 11 | request: Request, 12 | ) -> Result, Status> { 13 | let res = self.request("addGroup", request).await?; 14 | Ok(Response::new(res)) 15 | } 16 | 17 | async fn get_static_objects( 18 | &self, 19 | request: Request, 20 | ) -> Result, Status> { 21 | let res = self.request("getStaticObjects", request).await?; 22 | Ok(Response::new(res)) 23 | } 24 | 25 | async fn add_static_object( 26 | &self, 27 | request: Request, 28 | ) -> Result, Status> { 29 | let res = self.request("addStaticObject", request).await?; 30 | Ok(Response::new(res)) 31 | } 32 | 33 | async fn add_linked_static( 34 | &self, 35 | request: Request, 36 | ) -> Result, Status> { 37 | let res = self.request("addLinkedStatic", request).await?; 38 | Ok(Response::new(res)) 39 | } 40 | 41 | async fn get_groups( 42 | &self, 43 | request: Request, 44 | ) -> Result, Status> { 45 | let res = self.request("getGroups", request).await?; 46 | Ok(Response::new(res)) 47 | } 48 | 49 | async fn get_bullseye( 50 | &self, 51 | request: Request, 52 | ) -> Result, Status> { 53 | let res = self.request("getBullseye", request).await?; 54 | Ok(Response::new(res)) 55 | } 56 | 57 | async fn get_player_units( 58 | &self, 59 | request: Request, 60 | ) -> Result, Status> { 61 | let res = self.request("getPlayerUnits", request).await?; 62 | Ok(Response::new(res)) 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/rpc/controller.rs: -------------------------------------------------------------------------------- 1 | use stubs::controller; 2 | use stubs::controller::v0::controller_service_server::ControllerService; 3 | use tonic::{Request, Response, Status}; 4 | 5 | use super::MissionRpc; 6 | 7 | #[tonic::async_trait] 8 | impl ControllerService for MissionRpc { 9 | async fn set_alarm_state( 10 | &self, 11 | request: Request, 12 | ) -> Result, Status> { 13 | let res = self.request("setAlarmState", request).await?; 14 | Ok(Response::new(res)) 15 | } 16 | async fn get_detected_targets( 17 | &self, 18 | request: Request, 19 | ) -> Result, Status> { 20 | let res = self.request("getDetectedTargets", request).await?; 21 | Ok(Response::new(res)) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/rpc/custom.rs: -------------------------------------------------------------------------------- 1 | use std::ops::Neg; 2 | 3 | use stubs::custom::v0::custom_service_server::CustomService; 4 | use stubs::*; 5 | use tonic::{Request, Response, Status}; 6 | 7 | use super::MissionRpc; 8 | 9 | #[tonic::async_trait] 10 | impl CustomService for MissionRpc { 11 | async fn request_mission_assignment( 12 | &self, 13 | request: Request, 14 | ) -> Result, Status> { 15 | let res = self.request("requestMissionAssignment", request).await?; 16 | Ok(Response::new(res)) 17 | } 18 | 19 | async fn join_mission( 20 | &self, 21 | request: Request, 22 | ) -> Result, Status> { 23 | let res = self.request("joinMission", request).await?; 24 | Ok(Response::new(res)) 25 | } 26 | 27 | async fn abort_mission( 28 | &self, 29 | request: Request, 30 | ) -> Result, Status> { 31 | let res = self.request("abortMission", request).await?; 32 | Ok(Response::new(res)) 33 | } 34 | 35 | async fn get_mission_status( 36 | &self, 37 | request: Request, 38 | ) -> Result, Status> { 39 | let res = self.request("getMissionStatus", request).await?; 40 | Ok(Response::new(res)) 41 | } 42 | 43 | async fn eval( 44 | &self, 45 | request: Request, 46 | ) -> Result, Status> { 47 | if !self.eval_enabled { 48 | return Err(Status::permission_denied("eval operation is disabled")); 49 | } 50 | 51 | let json: String = self.request("missionEval", request).await?; 52 | Ok(Response::new(custom::v0::EvalResponse { json })) 53 | } 54 | 55 | async fn get_magnetic_declination( 56 | &self, 57 | request: Request, 58 | ) -> Result, Status> { 59 | let position = request.into_inner(); 60 | 61 | // As only the date is relevant, and a difference of some days don't really matter, it is 62 | // fine to just use the scenario's start time, especially since it is cached and thus 63 | // prevents unnecessary roundtrips to the MSE. 64 | let date = self.get_scenario_start_time().await?.date(); 65 | let declination = igrf::declination(position.lat, position.lon, position.alt as u32, date) 66 | .map(|f| f.d) 67 | .or_else(|err| match err { 68 | igrf::Error::DateOutOfRange(f) => Ok(f.d), 69 | err => Err(Status::internal(format!( 70 | "failed to estimate magnetic declination: {err}" 71 | ))), 72 | })?; 73 | 74 | // reduce precision to two decimal places 75 | let declination = ((declination * 100.0).round() / 100.0).neg(); 76 | 77 | Ok(Response::new(custom::v0::GetMagneticDeclinationResponse { 78 | declination, 79 | })) 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/rpc/group.rs: -------------------------------------------------------------------------------- 1 | use stubs::group::v0::group_service_server::GroupService; 2 | use stubs::*; 3 | use tonic::{Request, Response, Status}; 4 | 5 | use super::MissionRpc; 6 | 7 | #[tonic::async_trait] 8 | impl GroupService for MissionRpc { 9 | async fn get_units( 10 | &self, 11 | request: Request, 12 | ) -> Result, Status> { 13 | let res = self.request("getUnits", request).await?; 14 | Ok(Response::new(res)) 15 | } 16 | 17 | async fn activate( 18 | &self, 19 | request: Request, 20 | ) -> Result, Status> { 21 | let res = self.request("groupActivate", request).await?; 22 | Ok(Response::new(res)) 23 | } 24 | 25 | async fn destroy( 26 | &self, 27 | request: Request, 28 | ) -> Result, Status> { 29 | let res = self.request("groupDestroy", request).await?; 30 | Ok(Response::new(res)) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/rpc/hook.rs: -------------------------------------------------------------------------------- 1 | use stubs::hook::v0::hook_service_server::HookService; 2 | use stubs::*; 3 | use tonic::{Request, Response, Status}; 4 | 5 | use super::HookRpc; 6 | 7 | #[tonic::async_trait] 8 | impl HookService for HookRpc { 9 | async fn get_mission_name( 10 | &self, 11 | request: Request, 12 | ) -> Result, Status> { 13 | let res = self.request("getMissionName", request).await?; 14 | Ok(Response::new(res)) 15 | } 16 | 17 | async fn get_mission_description( 18 | &self, 19 | request: Request, 20 | ) -> Result, Status> { 21 | let res = self.request("getMissionDescription", request).await?; 22 | Ok(Response::new(res)) 23 | } 24 | 25 | async fn get_mission_filename( 26 | &self, 27 | request: Request, 28 | ) -> Result, Status> { 29 | let res = self.request("getMissionFilename", request).await?; 30 | Ok(Response::new(res)) 31 | } 32 | 33 | async fn get_paused( 34 | &self, 35 | request: Request, 36 | ) -> Result, Status> { 37 | let res = self.request("getPaused", request).await?; 38 | Ok(Response::new(res)) 39 | } 40 | 41 | async fn set_paused( 42 | &self, 43 | request: Request, 44 | ) -> Result, Status> { 45 | let res = self.request("setPaused", request).await?; 46 | Ok(Response::new(res)) 47 | } 48 | 49 | async fn reload_current_mission( 50 | &self, 51 | request: Request, 52 | ) -> Result, Status> { 53 | let res = self.request("reloadCurrentMission", request).await?; 54 | Ok(Response::new(res)) 55 | } 56 | 57 | async fn load_next_mission( 58 | &self, 59 | request: Request, 60 | ) -> Result, Status> { 61 | let res = self.request("loadNextMission", request).await?; 62 | Ok(Response::new(res)) 63 | } 64 | 65 | async fn load_mission( 66 | &self, 67 | request: Request, 68 | ) -> Result, Status> { 69 | let res = self.request("loadMission", request).await?; 70 | Ok(Response::new(res)) 71 | } 72 | 73 | async fn stop_mission( 74 | &self, 75 | request: Request, 76 | ) -> Result, Status> { 77 | let res = self.request("stopMission", request).await?; 78 | Ok(Response::new(res)) 79 | } 80 | 81 | async fn eval( 82 | &self, 83 | request: Request, 84 | ) -> Result, Status> { 85 | if !self.eval_enabled { 86 | return Err(Status::permission_denied("eval operation is disabled")); 87 | } 88 | 89 | let json: String = self.request("hookEval", request).await?; 90 | Ok(Response::new(hook::v0::EvalResponse { json })) 91 | } 92 | 93 | async fn exit_process( 94 | &self, 95 | request: Request, 96 | ) -> Result, Status> { 97 | let res = self.request("exitProcess", request).await?; 98 | Ok(Response::new(res)) 99 | } 100 | 101 | async fn is_multiplayer( 102 | &self, 103 | request: Request, 104 | ) -> Result, Status> { 105 | let res = self.request("isMultiplayer", request).await?; 106 | Ok(Response::new(res)) 107 | } 108 | 109 | async fn is_server( 110 | &self, 111 | request: Request, 112 | ) -> Result, Status> { 113 | let res = self.request("isServer", request).await?; 114 | Ok(Response::new(res)) 115 | } 116 | 117 | async fn ban_player( 118 | &self, 119 | request: Request, 120 | ) -> Result, Status> { 121 | let res = self.request("banPlayer", request).await?; 122 | Ok(Response::new(res)) 123 | } 124 | 125 | async fn unban_player( 126 | &self, 127 | request: Request, 128 | ) -> Result, Status> { 129 | let res = self.request("unbanPlayer", request).await?; 130 | Ok(Response::new(res)) 131 | } 132 | 133 | async fn get_banned_players( 134 | &self, 135 | request: Request, 136 | ) -> Result, Status> { 137 | let res = self.request("getBannedPlayers", request).await?; 138 | Ok(Response::new(res)) 139 | } 140 | 141 | async fn get_unit_type( 142 | &self, 143 | request: Request, 144 | ) -> Result, Status> { 145 | let res = self.request("getUnitType", request).await?; 146 | Ok(Response::new(res)) 147 | } 148 | 149 | async fn get_real_time( 150 | &self, 151 | request: Request, 152 | ) -> Result, Status> { 153 | let res = self.request("getRealTime", request).await?; 154 | Ok(Response::new(res)) 155 | } 156 | 157 | async fn get_ballistics_count( 158 | &self, 159 | request: Request, 160 | ) -> Result, Status> { 161 | let res = self.request("getBallisticsCount", request).await?; 162 | Ok(Response::new(res)) 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /src/rpc/metadata.rs: -------------------------------------------------------------------------------- 1 | use stubs::metadata::v0::metadata_service_server::MetadataService; 2 | use stubs::*; 3 | use tonic::{Request, Response, Status, async_trait}; 4 | 5 | use super::MissionRpc; 6 | 7 | #[async_trait] 8 | impl MetadataService for MissionRpc { 9 | async fn get_health( 10 | &self, 11 | _request: Request, 12 | ) -> Result, Status> { 13 | let alive: bool = true; 14 | return Ok(Response::new(metadata::v0::GetHealthResponse { alive })); 15 | } 16 | 17 | async fn get_version( 18 | &self, 19 | _request: Request, 20 | ) -> Result, Status> { 21 | const VERSION: Option<&str> = option_env!("CARGO_PKG_VERSION"); 22 | let version = VERSION.unwrap_or("unknown").to_string(); 23 | return Ok(Response::new(metadata::v0::GetVersionResponse { version })); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/rpc/net.rs: -------------------------------------------------------------------------------- 1 | use stubs::net::v0::net_service_server::NetService; 2 | use stubs::*; 3 | use tonic::{Request, Response, Status}; 4 | 5 | use super::MissionRpc; 6 | 7 | #[tonic::async_trait] 8 | impl NetService for MissionRpc { 9 | async fn send_chat_to( 10 | &self, 11 | request: Request, 12 | ) -> Result, Status> { 13 | let res = self.request("sendChatTo", request).await?; 14 | Ok(Response::new(res)) 15 | } 16 | 17 | async fn send_chat( 18 | &self, 19 | request: Request, 20 | ) -> Result, Status> { 21 | let res = self.request("sendChat", request).await?; 22 | Ok(Response::new(res)) 23 | } 24 | 25 | async fn get_players( 26 | &self, 27 | request: Request, 28 | ) -> Result, Status> { 29 | let res = self.request("getPlayers", request).await?; 30 | Ok(Response::new(res)) 31 | } 32 | 33 | async fn force_player_slot( 34 | &self, 35 | request: Request, 36 | ) -> Result, Status> { 37 | let res = self.request("forcePlayerSlot", request).await?; 38 | Ok(Response::new(res)) 39 | } 40 | 41 | async fn kick_player( 42 | &self, 43 | request: Request, 44 | ) -> Result, Status> { 45 | let res = self.request("kickPlayer", request).await?; 46 | Ok(Response::new(res)) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/rpc/timer.rs: -------------------------------------------------------------------------------- 1 | use stubs::timer::v0::timer_service_server::TimerService; 2 | use stubs::*; 3 | use tonic::{Request, Response, Status}; 4 | 5 | use super::MissionRpc; 6 | 7 | #[tonic::async_trait] 8 | impl TimerService for MissionRpc { 9 | async fn get_time( 10 | &self, 11 | request: Request, 12 | ) -> Result, Status> { 13 | let res = self.request("getTime", request).await?; 14 | Ok(Response::new(res)) 15 | } 16 | 17 | async fn get_absolute_time( 18 | &self, 19 | request: Request, 20 | ) -> Result, Status> { 21 | let res = self.request("getAbsoluteTime", request).await?; 22 | Ok(Response::new(res)) 23 | } 24 | 25 | async fn get_time_zero( 26 | &self, 27 | request: Request, 28 | ) -> Result, Status> { 29 | let res = self.request("getTimeZero", request).await?; 30 | Ok(Response::new(res)) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/rpc/trigger.rs: -------------------------------------------------------------------------------- 1 | use stubs::trigger; 2 | use stubs::trigger::v0::trigger_service_server::TriggerService; 3 | use tonic::{Request, Response, Status}; 4 | 5 | use super::MissionRpc; 6 | 7 | #[tonic::async_trait] 8 | impl TriggerService for MissionRpc { 9 | async fn out_text( 10 | &self, 11 | request: Request, 12 | ) -> Result, Status> { 13 | let res = self.request("outText", request).await?; 14 | Ok(Response::new(res)) 15 | } 16 | 17 | async fn out_text_for_coalition( 18 | &self, 19 | request: Request, 20 | ) -> Result, Status> { 21 | let res = self.request("outTextForCoalition", request).await?; 22 | Ok(Response::new(res)) 23 | } 24 | 25 | async fn out_text_for_group( 26 | &self, 27 | request: Request, 28 | ) -> Result, Status> { 29 | let res = self.request("outTextForGroup", request).await?; 30 | Ok(Response::new(res)) 31 | } 32 | 33 | async fn out_text_for_unit( 34 | &self, 35 | request: Request, 36 | ) -> Result, Status> { 37 | let res = self.request("outTextForUnit", request).await?; 38 | Ok(Response::new(res)) 39 | } 40 | 41 | async fn get_user_flag( 42 | &self, 43 | request: Request, 44 | ) -> Result, Status> { 45 | let res = self.request("getUserFlag", request).await?; 46 | Ok(Response::new(res)) 47 | } 48 | 49 | async fn set_user_flag( 50 | &self, 51 | request: Request, 52 | ) -> Result, Status> { 53 | let res = self.request("setUserFlag", request).await?; 54 | Ok(Response::new(res)) 55 | } 56 | 57 | async fn mark_to_all( 58 | &self, 59 | request: Request, 60 | ) -> Result, Status> { 61 | let res = self.request("markToAll", request).await?; 62 | Ok(Response::new(res)) 63 | } 64 | 65 | async fn mark_to_coalition( 66 | &self, 67 | request: Request, 68 | ) -> Result, Status> { 69 | let res = self.request("markToCoalition", request).await?; 70 | Ok(Response::new(res)) 71 | } 72 | 73 | async fn mark_to_group( 74 | &self, 75 | request: Request, 76 | ) -> Result, Status> { 77 | let res = self.request("markToGroup", request).await?; 78 | Ok(Response::new(res)) 79 | } 80 | 81 | async fn remove_mark( 82 | &self, 83 | request: Request, 84 | ) -> Result, Status> { 85 | let res = self.request("removeMark", request).await?; 86 | Ok(Response::new(res)) 87 | } 88 | 89 | async fn markup_to_all( 90 | &self, 91 | request: Request, 92 | ) -> Result, Status> { 93 | let res = self.request("markupToAll", request).await?; 94 | Ok(Response::new(res)) 95 | } 96 | 97 | async fn markup_to_coalition( 98 | &self, 99 | request: Request, 100 | ) -> Result, Status> { 101 | let res = self.request("markupToCoalition", request).await?; 102 | Ok(Response::new(res)) 103 | } 104 | 105 | async fn explosion( 106 | &self, 107 | request: Request, 108 | ) -> Result, Status> { 109 | let res = self.request("explosion", request).await?; 110 | Ok(Response::new(res)) 111 | } 112 | 113 | async fn smoke( 114 | &self, 115 | request: Request, 116 | ) -> Result, Status> { 117 | let res = self.request("smoke", request).await?; 118 | Ok(Response::new(res)) 119 | } 120 | 121 | async fn illumination_bomb( 122 | &self, 123 | request: Request, 124 | ) -> Result, Status> { 125 | let res = self.request("illuminationBomb", request).await?; 126 | Ok(Response::new(res)) 127 | } 128 | 129 | async fn signal_flare( 130 | &self, 131 | request: Request, 132 | ) -> Result, Status> { 133 | let res = self.request("signalFlare", request).await?; 134 | Ok(Response::new(res)) 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /src/rpc/unit.rs: -------------------------------------------------------------------------------- 1 | use stubs::unit; 2 | use stubs::unit::v0::unit_service_server::UnitService; 3 | use tonic::{Request, Response, Status}; 4 | 5 | use super::MissionRpc; 6 | 7 | #[tonic::async_trait] 8 | impl UnitService for MissionRpc { 9 | async fn get_radar( 10 | &self, 11 | request: Request, 12 | ) -> Result, Status> { 13 | let res = self.request("getRadar", request).await?; 14 | Ok(Response::new(res)) 15 | } 16 | 17 | async fn get_position( 18 | &self, 19 | request: Request, 20 | ) -> Result, Status> { 21 | let res = self.request("getUnitPosition", request).await?; 22 | Ok(Response::new(res)) 23 | } 24 | 25 | async fn get_player_name( 26 | &self, 27 | request: Request, 28 | ) -> Result, Status> { 29 | let res = self.request("getUnitPlayerName", request).await?; 30 | Ok(Response::new(res)) 31 | } 32 | 33 | async fn get_descriptor( 34 | &self, 35 | request: Request, 36 | ) -> Result, Status> { 37 | let res = self.request("getUnitDescriptor", request).await?; 38 | Ok(Response::new(res)) 39 | } 40 | 41 | async fn set_emission( 42 | &self, 43 | request: Request, 44 | ) -> Result, Status> { 45 | let res = self.request("setEmission", request).await?; 46 | Ok(Response::new(res)) 47 | } 48 | 49 | async fn get_draw_argument_value( 50 | &self, 51 | request: Request, 52 | ) -> Result, Status> { 53 | let res = self.request("getDrawArgumentValue", request).await?; 54 | Ok(Response::new(res)) 55 | } 56 | 57 | async fn get( 58 | &self, 59 | request: Request, 60 | ) -> Result, Status> { 61 | let res = self.request("getUnit", request).await?; 62 | Ok(Response::new(res)) 63 | } 64 | 65 | async fn get_transform( 66 | &self, 67 | request: Request, 68 | ) -> Result, Status> { 69 | let res = self.request("getUnitTransform", request).await?; 70 | Ok(Response::new(res)) 71 | } 72 | 73 | async fn destroy( 74 | &self, 75 | request: Request, 76 | ) -> Result, Status> { 77 | let res = self.request("unitDestroy", request).await?; 78 | Ok(Response::new(res)) 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/rpc/world.rs: -------------------------------------------------------------------------------- 1 | use stubs::world::v0::world_service_server::WorldService; 2 | use stubs::*; 3 | use tonic::{Request, Response, Status}; 4 | 5 | use super::MissionRpc; 6 | 7 | #[tonic::async_trait] 8 | impl WorldService for MissionRpc { 9 | async fn get_airbases( 10 | &self, 11 | request: Request, 12 | ) -> Result, Status> { 13 | let res = self.request("getAirbases", request).await?; 14 | Ok(Response::new(res)) 15 | } 16 | 17 | async fn get_mark_panels( 18 | &self, 19 | request: Request, 20 | ) -> Result, Status> { 21 | let res = self.request("getMarkPanels", request).await?; 22 | Ok(Response::new(res)) 23 | } 24 | 25 | async fn get_theatre( 26 | &self, 27 | request: Request, 28 | ) -> Result, Status> { 29 | let res = self.request("getTheatre", request).await?; 30 | Ok(Response::new(res)) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/shutdown.rs: -------------------------------------------------------------------------------- 1 | use std::future::{Future, ready}; 2 | use std::pin::Pin; 3 | use std::task::{Context, Poll}; 4 | 5 | use futures_util::future::{Either, Shared, WeakShared}; 6 | use futures_util::{FutureExt, Stream}; 7 | use tokio::sync::{mpsc, oneshot}; 8 | 9 | pub struct Shutdown { 10 | tx: oneshot::Sender<()>, 11 | alive: mpsc::Receiver<()>, 12 | signal: Shared, 13 | } 14 | 15 | #[derive(Clone)] 16 | pub struct ShutdownHandle { 17 | signal: Option>, 18 | } 19 | 20 | #[pin_project::pin_project] 21 | struct ShutdownSignal { 22 | alive: mpsc::Sender<()>, 23 | #[pin] 24 | rx: oneshot::Receiver<()>, 25 | } 26 | 27 | impl Shutdown { 28 | pub fn new() -> Self { 29 | let (tx, rx) = oneshot::channel(); 30 | let (alive_tx, alive_rx) = mpsc::channel(1); 31 | 32 | Shutdown { 33 | tx, 34 | alive: alive_rx, 35 | signal: ShutdownSignal { 36 | alive: alive_tx, 37 | rx, 38 | } 39 | .shared(), 40 | } 41 | } 42 | 43 | pub fn handle(&self) -> ShutdownHandle { 44 | ShutdownHandle { 45 | signal: self.signal.downgrade(), 46 | } 47 | } 48 | 49 | pub async fn shutdown(mut self) { 50 | // send out shutdown signal 51 | let _ = self.tx.send(()); 52 | drop(self.signal); 53 | 54 | // once every shutdown and thus every channel sender got dropped, the recv call will 55 | // return an error which is our signal that everything shutdown and that we are done 56 | let _ = self.alive.recv().await; 57 | } 58 | } 59 | 60 | impl ShutdownHandle { 61 | pub fn signal(&self) -> impl Future + use<> { 62 | match self.signal.as_ref() { 63 | Some(signal) => signal 64 | .upgrade() 65 | .map(Either::Left) 66 | .unwrap_or_else(|| Either::Right(ready(()))), 67 | None => Either::Right(ready(())), 68 | } 69 | } 70 | } 71 | 72 | impl Future for ShutdownSignal { 73 | type Output = (); 74 | 75 | fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { 76 | let this = self.as_mut().project(); 77 | match this.rx.poll(cx) { 78 | Poll::Ready(_) => Poll::Ready(()), 79 | Poll::Pending => Poll::Pending, 80 | } 81 | } 82 | } 83 | 84 | /// A stream that can be aborted via a shutdown signal. Once aborted, the stream will yield a final 85 | /// `None` to gracefully shutdown all stream receivers. 86 | #[pin_project::pin_project] 87 | pub struct AbortableStream { 88 | #[pin] 89 | state: State, 90 | } 91 | 92 | #[pin_project::pin_project(project = StateProj)] 93 | enum State { 94 | Stream { 95 | #[pin] 96 | shutdown_signal: F, 97 | #[pin] 98 | stream: S, 99 | }, 100 | Done, 101 | } 102 | 103 | impl AbortableStream { 104 | pub fn new(shutdown_signal: F, stream: S) -> Self { 105 | AbortableStream { 106 | state: State::Stream { 107 | shutdown_signal, 108 | stream, 109 | }, 110 | } 111 | } 112 | } 113 | 114 | impl Stream for AbortableStream 115 | where 116 | F: Future, 117 | S: Stream, 118 | { 119 | type Item = S::Item; 120 | 121 | fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { 122 | let mut this = self.as_mut().project(); 123 | 124 | match this.state.as_mut().project() { 125 | StateProj::Stream { 126 | shutdown_signal, 127 | stream, 128 | } => { 129 | // check the stream only if the shutdown signal is still pending (aka. no shut- 130 | // down signal received yet) 131 | if shutdown_signal.poll(cx).is_pending() { 132 | // if the stream yields a new item, return it without changing the state of 133 | // the abortable stream 134 | let item = match stream.poll_next(cx) { 135 | Poll::Ready(item) => item, 136 | Poll::Pending => return Poll::Pending, 137 | }; 138 | if item.is_some() { 139 | return Poll::Ready(item); 140 | } 141 | } 142 | 143 | // if the stream does not yield anymore items, or if a shutdown signal was 144 | // received, close the abortable stream 145 | this.state.set(State::Done); 146 | } 147 | StateProj::Done => {} 148 | } 149 | 150 | Poll::Ready(None) 151 | } 152 | 153 | fn size_hint(&self) -> (usize, Option) { 154 | match self.state { 155 | State::Stream { ref stream, .. } => stream.size_hint(), 156 | State::Done => (0, Some(0)), 157 | } 158 | } 159 | } 160 | -------------------------------------------------------------------------------- /src/stats.rs: -------------------------------------------------------------------------------- 1 | use std::convert::TryFrom; 2 | use std::sync::Arc; 3 | use std::sync::atomic::{AtomicU32, AtomicUsize, Ordering}; 4 | use std::time::{Duration, Instant}; 5 | 6 | use tokio::sync::Mutex; 7 | use tokio::time::MissedTickBehavior; 8 | 9 | use crate::shutdown::ShutdownHandle; 10 | 11 | #[derive(Clone)] 12 | pub struct Stats(Arc); 13 | 14 | struct Inner { 15 | shutdown_signal: ShutdownHandle, 16 | /// Total numer of calls into the MSE. 17 | calls_count: AtomicU32, 18 | /// Total numer of events received from the MSE. 19 | events_count: AtomicU32, 20 | /// Total numer of calls in the queue. 21 | queue_size: AtomicU32, 22 | /// Time spent waiting for MSE calls to complete (since last report). 23 | nanoseconds_waited: AtomicUsize, 24 | /// Stats collected during an interval necessary to create a report at the end of the interval. 25 | interval_stats: Arc>, 26 | } 27 | 28 | #[derive(Default)] 29 | struct IntervalStats { 30 | /// Highest TPS count of calls into the MSE. 31 | tps_highest: f64, 32 | /// Highest events per second. 33 | eps_highest: f64, 34 | /// Sum of the queue sizes at each tick (neccessary to calculate the average). 35 | queue_size_total: u32, 36 | /// Highest queue size at a tick of the interval. 37 | queue_size_highest: u32, 38 | } 39 | 40 | /// This guard is used to keep track of the time the gRPC server blocked DCS. 41 | pub struct TrackBlockTimeGuard { 42 | start: Instant, 43 | stats: Arc, 44 | } 45 | 46 | /// This guard is used to keep track of calls in the queue. 47 | pub struct TrackQueueSizeGuard { 48 | stats: Arc, 49 | } 50 | 51 | impl Stats { 52 | pub fn new(shutdown_signal: ShutdownHandle) -> Self { 53 | Stats(Arc::new(Inner { 54 | shutdown_signal, 55 | calls_count: AtomicU32::new(0), 56 | events_count: AtomicU32::new(0), 57 | queue_size: AtomicU32::new(0), 58 | nanoseconds_waited: AtomicUsize::new(0), 59 | interval_stats: Arc::new(Mutex::new(IntervalStats::default())), 60 | })) 61 | } 62 | 63 | pub fn track_call(&self) { 64 | self.0.calls_count.fetch_add(1, Ordering::Relaxed); 65 | } 66 | 67 | pub fn track_event(&self) { 68 | self.0.events_count.fetch_add(1, Ordering::Relaxed); 69 | } 70 | 71 | pub fn track_block_time(&self, start: Instant) -> TrackBlockTimeGuard { 72 | self.0.calls_count.fetch_add(1, Ordering::Relaxed); 73 | TrackBlockTimeGuard { 74 | start, 75 | stats: self.0.clone(), 76 | } 77 | } 78 | 79 | pub fn track_queue_size(&self) -> TrackQueueSizeGuard { 80 | self.0.queue_size.fetch_add(1, Ordering::Relaxed); 81 | TrackQueueSizeGuard { 82 | stats: self.0.clone(), 83 | } 84 | } 85 | 86 | pub async fn run_in_background(self) { 87 | let mut interval = tokio::time::interval(Duration::from_secs(1)); 88 | interval.set_missed_tick_behavior(MissedTickBehavior::Skip); 89 | 90 | let mut last_logged = Instant::now(); 91 | let log_interval = Duration::from_secs(60); 92 | let mut shutdown_signal = self.0.shutdown_signal.signal(); 93 | 94 | loop { 95 | let calls_count_before = self.0.calls_count.load(Ordering::Relaxed); 96 | let events_count_before = self.0.events_count.load(Ordering::Relaxed); 97 | let start = Instant::now(); 98 | 99 | // wait for either the shutdown signal or the next interval tick, whatever happens first 100 | tokio::select! { 101 | _ = &mut shutdown_signal => { 102 | break 103 | } 104 | _ = interval.tick() => {} 105 | }; 106 | 107 | let mut interval_stats = self.0.interval_stats.lock().await; 108 | let calls_count = self.0.calls_count.load(Ordering::Relaxed); 109 | let events_count = self.0.events_count.load(Ordering::Relaxed); 110 | 111 | // update report for elapsed second 112 | let elapsed = start.elapsed().as_secs_f64(); 113 | if elapsed > 0.0 { 114 | // update highest TPS 115 | let tps = f64::from(calls_count - calls_count_before) / elapsed; 116 | if tps > interval_stats.tps_highest { 117 | interval_stats.tps_highest = tps; 118 | } 119 | 120 | // update highest events per second 121 | let eps = f64::from(events_count - events_count_before) / elapsed; 122 | if eps > interval_stats.eps_highest { 123 | interval_stats.eps_highest = eps; 124 | } 125 | 126 | // update queue size 127 | let queue_size = self.0.queue_size.load(Ordering::Relaxed); 128 | interval_stats.queue_size_total += queue_size; 129 | if queue_size > interval_stats.queue_size_highest { 130 | interval_stats.queue_size_highest = queue_size; 131 | } 132 | } 133 | 134 | // log summary every minute 135 | let elapsed = last_logged.elapsed(); 136 | if elapsed > log_interval { 137 | // average TPS 138 | let tps_average = f64::from(calls_count) / elapsed.as_secs_f64(); 139 | 140 | // average events per second 141 | let eps_average = f64::from(events_count) / elapsed.as_secs_f64(); 142 | 143 | // total block time 144 | let block_time_total = Duration::from_nanos( 145 | u64::try_from(self.0.nanoseconds_waited.swap(0, Ordering::Relaxed)) 146 | .unwrap_or(u64::MAX), 147 | ); 148 | let block_time_total_percentage = 149 | (block_time_total.as_secs_f64() / elapsed.as_secs_f64()) * 100.0; 150 | 151 | // average queue size 152 | let queue_size_average = 153 | f64::from(interval_stats.queue_size_total) / elapsed.as_secs_f64(); 154 | 155 | // format and log stats 156 | log::info!( 157 | "Calls per second: average={:.2}, highest={:.2}", 158 | tps_average, 159 | interval_stats.tps_highest 160 | ); 161 | log::info!( 162 | "Events per second: average={:.2}, highest={:.2}", 163 | eps_average, 164 | interval_stats.eps_highest 165 | ); 166 | log::info!( 167 | "Blocking time: total={:?} (≙ {:.2}%)", 168 | block_time_total, 169 | block_time_total_percentage 170 | ); 171 | log::info!( 172 | "Queue size: average={:.2}, biggest={:.2}", 173 | queue_size_average, 174 | interval_stats.queue_size_highest 175 | ); 176 | 177 | // reset data for next interval 178 | last_logged = Instant::now(); 179 | *interval_stats = IntervalStats::default(); 180 | self.0.calls_count.store(0, Ordering::Relaxed); 181 | self.0.nanoseconds_waited.store(0, Ordering::Relaxed); 182 | } 183 | } 184 | } 185 | } 186 | 187 | impl Drop for TrackBlockTimeGuard { 188 | fn drop(&mut self) { 189 | self.stats.nanoseconds_waited.fetch_add( 190 | usize::try_from(self.start.elapsed().as_nanos()).unwrap_or(usize::MAX), 191 | Ordering::Relaxed, 192 | ); 193 | } 194 | } 195 | 196 | impl Drop for TrackQueueSizeGuard { 197 | fn drop(&mut self) { 198 | self.stats.queue_size.fetch_sub(1, Ordering::Relaxed); 199 | } 200 | } 201 | -------------------------------------------------------------------------------- /srs/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "dcs-grpc-srs" 3 | version.workspace = true 4 | rust-version.workspace = true 5 | authors.workspace = true 6 | license.workspace = true 7 | edition.workspace = true 8 | 9 | [dependencies] 10 | base64.workspace = true 11 | byteorder = "1.3" 12 | bytes.workspace = true 13 | futures-util.workspace = true 14 | log.workspace = true 15 | serde.workspace = true 16 | serde_json.workspace = true 17 | serde_repr = "0.1" 18 | thiserror.workspace = true 19 | tokio-stream.workspace = true 20 | tokio-util = { version = "0.7", features = ["codec", "net"] } 21 | tokio.workspace = true 22 | uuid = { version = "1.1", features = ["v4"] } 23 | 24 | [dev-dependencies] 25 | pretty_assertions = "1.3" 26 | -------------------------------------------------------------------------------- /srs/src/client.rs: -------------------------------------------------------------------------------- 1 | use std::future::Future; 2 | use std::net::SocketAddr; 3 | use std::sync::Arc; 4 | 5 | use tokio::sync::RwLock; 6 | 7 | use crate::StreamError; 8 | use crate::message::{Coalition, Position, create_sguid}; 9 | use crate::stream::{Receiver, Sender}; 10 | 11 | #[derive(Debug, Clone)] 12 | pub struct UnitInfo { 13 | pub id: u32, 14 | pub name: String, 15 | } 16 | 17 | #[derive(Debug, Clone)] 18 | pub struct Client { 19 | sguid: String, 20 | name: String, 21 | freq: u64, 22 | pos: Arc>, 23 | unit: Option, 24 | pub coalition: Coalition, 25 | } 26 | 27 | impl Client { 28 | pub fn new(name: &str, freq: u64, coalition: Coalition) -> Self { 29 | Client { 30 | sguid: create_sguid(), 31 | name: name.to_string(), 32 | freq, 33 | pos: Arc::new(RwLock::new(Position::default())), 34 | unit: None, 35 | coalition, 36 | } 37 | } 38 | 39 | pub fn sguid(&self) -> &str { 40 | &self.sguid 41 | } 42 | 43 | pub fn name(&self) -> &str { 44 | &self.name 45 | } 46 | 47 | pub fn freq(&self) -> u64 { 48 | self.freq 49 | } 50 | 51 | pub async fn position(&self) -> Position { 52 | let p = self.pos.read().await; 53 | p.clone() 54 | } 55 | 56 | pub fn position_handle(&self) -> Arc> { 57 | self.pos.clone() 58 | } 59 | 60 | pub fn unit(&self) -> Option<&UnitInfo> { 61 | self.unit.as_ref() 62 | } 63 | 64 | pub async fn set_position(&mut self, pos: Position) { 65 | let mut p = self.pos.write().await; 66 | *p = pos; 67 | } 68 | 69 | pub fn set_unit(&mut self, id: u32, name: &str) { 70 | self.unit = Some(UnitInfo { 71 | id, 72 | name: name.to_string(), 73 | }); 74 | } 75 | 76 | pub async fn start( 77 | self, 78 | addr: SocketAddr, 79 | shutdown_signal: impl Future + Unpin + Send + 'static, 80 | ) -> Result<(Sender, Receiver), StreamError> { 81 | crate::stream::stream(self, addr, shutdown_signal).await 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /srs/src/lib.rs: -------------------------------------------------------------------------------- 1 | mod client; 2 | mod message; 3 | mod messages_codec; 4 | mod stream; 5 | mod voice_codec; 6 | 7 | pub use client::Client; 8 | pub use message::*; 9 | pub use stream::{Packet, Receiver, Sender, StreamError}; 10 | -------------------------------------------------------------------------------- /srs/src/messages_codec.rs: -------------------------------------------------------------------------------- 1 | use std::{error, fmt, io}; 2 | 3 | use bytes::BytesMut; 4 | use tokio_util::codec::{Decoder, Encoder, LinesCodec, LinesCodecError}; 5 | 6 | use crate::message::{Message, MessageRequest}; 7 | 8 | pub struct MessagesCodec { 9 | lines_codec: LinesCodec, 10 | } 11 | 12 | impl MessagesCodec { 13 | pub fn new() -> Self { 14 | MessagesCodec { 15 | lines_codec: LinesCodec::new(), 16 | } 17 | } 18 | } 19 | 20 | impl Decoder for MessagesCodec { 21 | type Item = Message; 22 | type Error = MessagesCodecError; 23 | 24 | fn decode(&mut self, buf: &mut BytesMut) -> Result, Self::Error> { 25 | if let Some(line) = self.lines_codec.decode(buf)? { 26 | match serde_json::from_str(&line) { 27 | Ok(msg) => Ok(Some(msg)), 28 | Err(err) => Err(MessagesCodecError::JsonDecode(err, line)), 29 | } 30 | } else { 31 | Ok(None) 32 | } 33 | } 34 | 35 | fn decode_eof(&mut self, buf: &mut BytesMut) -> Result, Self::Error> { 36 | if let Some(line) = self.lines_codec.decode_eof(buf)? { 37 | match serde_json::from_str(&line) { 38 | Ok(msg) => Ok(Some(msg)), 39 | Err(err) => Err(MessagesCodecError::JsonDecode(err, line)), 40 | } 41 | } else { 42 | Ok(None) 43 | } 44 | } 45 | } 46 | 47 | impl Encoder for MessagesCodec { 48 | type Error = MessagesCodecError; 49 | 50 | fn encode(&mut self, msg: MessageRequest, buf: &mut BytesMut) -> Result<(), Self::Error> { 51 | let json = serde_json::to_string(&msg).map_err(MessagesCodecError::JsonEncode)?; 52 | self.lines_codec.encode(json, buf)?; 53 | Ok(()) 54 | } 55 | } 56 | 57 | #[derive(Debug)] 58 | pub enum MessagesCodecError { 59 | JsonDecode(serde_json::Error, String), 60 | JsonEncode(serde_json::Error), 61 | LinesCodec(LinesCodecError), 62 | Io(io::Error), 63 | } 64 | 65 | impl fmt::Display for MessagesCodecError { 66 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 67 | match self { 68 | MessagesCodecError::JsonDecode(_, json) => write!(f, "failed to decode JSON: {json}"), 69 | MessagesCodecError::JsonEncode(_) => write!(f, "failed to encode JSON"), 70 | MessagesCodecError::LinesCodec(err) => err.fmt(f), 71 | MessagesCodecError::Io(err) => err.fmt(f), 72 | } 73 | } 74 | } 75 | 76 | impl error::Error for MessagesCodecError { 77 | fn source(&self) -> Option<&(dyn error::Error + 'static)> { 78 | match self { 79 | MessagesCodecError::JsonDecode(err, _) => Some(err), 80 | MessagesCodecError::JsonEncode(err) => Some(err), 81 | MessagesCodecError::LinesCodec(err) => Some(err), 82 | MessagesCodecError::Io(err) => Some(err), 83 | } 84 | } 85 | } 86 | 87 | impl From for MessagesCodecError { 88 | fn from(err: io::Error) -> Self { 89 | MessagesCodecError::Io(err) 90 | } 91 | } 92 | 93 | impl From for MessagesCodecError { 94 | fn from(err: LinesCodecError) -> Self { 95 | MessagesCodecError::LinesCodec(err) 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /stubs/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "dcs-grpc-stubs" 3 | version.workspace = true 4 | rust-version.workspace = true 5 | authors.workspace = true 6 | license.workspace = true 7 | edition.workspace = true 8 | 9 | [dependencies] 10 | prost = "0.13" 11 | prost-types = "0.13" 12 | serde.workspace = true 13 | tonic.workspace = true 14 | 15 | [build-dependencies] 16 | tonic-build = "0.13" 17 | protoc-bundled = { git = "https://github.com/rkusa/protoc-bundled.git", rev = "27.0.0" } 18 | 19 | [dev-dependencies] 20 | serde_json.workspace = true 21 | 22 | [features] 23 | default = [] 24 | server = [] 25 | client = [] 26 | -------------------------------------------------------------------------------- /stubs/build.rs: -------------------------------------------------------------------------------- 1 | fn main() -> Result<(), Box> { 2 | unsafe { 3 | std::env::set_var("PROTOC", protoc_bundled::PROTOC); 4 | std::env::set_var("PROTOC_INCLUDE", protoc_bundled::PROTOC_INCLUDE); 5 | } 6 | 7 | println!("cargo:rerun-if-changed=../protos/dcs"); 8 | 9 | tonic_build::configure() 10 | .type_attribute(".", "#[derive(::serde::Serialize, ::serde::Deserialize)]") 11 | .type_attribute(".", "#[serde(rename_all = \"camelCase\")]") 12 | .type_attribute( 13 | "dcs.mission.v0.StreamEventsResponse.event", 14 | "#[serde(tag = \"type\")]", 15 | ) 16 | .type_attribute( 17 | "dcs.common.v0.Unit", 18 | "#[serde(from = \"UnitIntermediate\")]", 19 | ) 20 | .type_attribute( 21 | "dcs.common.v0.Weapon", 22 | "#[serde(from = \"WeaponIntermediate\")]", 23 | ) 24 | .type_attribute( 25 | "dcs.unit.v0.GetTransformResponse", 26 | "#[serde(from = \"GetTransformResponseIntermediate\")]", 27 | ) 28 | .type_attribute( 29 | "dcs.mission.v0.StreamUnitsResponse.update", 30 | "#[allow(clippy::large_enum_variant)]", 31 | ) 32 | .field_attribute( 33 | "dcs.mission.v0.StreamEventsResponse.MarkAddEvent.visibility", 34 | "#[serde(flatten)]", 35 | ) 36 | .field_attribute( 37 | "dcs.mission.v0.StreamEventsResponse.MarkChangeEvent.visibility", 38 | "#[serde(flatten)]", 39 | ) 40 | .field_attribute( 41 | "dcs.mission.v0.StreamEventsResponse.MarkRemoveEvent.visibility", 42 | "#[serde(flatten)]", 43 | ) 44 | .field_attribute( 45 | "dcs.mission.v0.AddMissionCommandRequest.details", 46 | r#"#[serde(with = "crate::utils::proto_struct")]"#, 47 | ) 48 | .field_attribute( 49 | "dcs.mission.v0.StreamEventsResponse.MissionCommandEvent.details", 50 | r#"#[serde(with = "crate::utils::proto_struct")]"#, 51 | ) 52 | .field_attribute( 53 | "dcs.mission.v0.AddCoalitionCommandRequest.details", 54 | r#"#[serde(with = "crate::utils::proto_struct")]"#, 55 | ) 56 | .field_attribute( 57 | "dcs.mission.v0.StreamEventsResponse.CoalitionCommandEvent.details", 58 | r#"#[serde(with = "crate::utils::proto_struct")]"#, 59 | ) 60 | .field_attribute( 61 | "dcs.mission.v0.AddGroupCommandRequest.details", 62 | r#"#[serde(with = "crate::utils::proto_struct")]"#, 63 | ) 64 | .field_attribute( 65 | "dcs.mission.v0.StreamEventsResponse.GroupCommandEvent.details", 66 | r#"#[serde(with = "crate::utils::proto_struct")]"#, 67 | ) 68 | .build_server(cfg!(feature = "server")) 69 | .build_client(cfg!(feature = "client")) 70 | .compile_protos(&["../protos/dcs/dcs.proto"], &["../protos"])?; 71 | Ok(()) 72 | } 73 | -------------------------------------------------------------------------------- /stubs/src/atmosphere.rs: -------------------------------------------------------------------------------- 1 | pub mod v0 { 2 | tonic::include_proto!("dcs.atmosphere.v0"); 3 | } 4 | -------------------------------------------------------------------------------- /stubs/src/coalition.rs: -------------------------------------------------------------------------------- 1 | pub mod v0 { 2 | tonic::include_proto!("dcs.coalition.v0"); 3 | } 4 | -------------------------------------------------------------------------------- /stubs/src/common.rs: -------------------------------------------------------------------------------- 1 | pub mod v0 { 2 | use std::ops::Neg; 3 | 4 | tonic::include_proto!("dcs.common.v0"); 5 | 6 | #[derive(Default, serde::Deserialize)] 7 | #[serde(rename_all = "camelCase")] 8 | pub(crate) struct RawTransform { 9 | pub position: Option, 10 | pub position_north: Option, 11 | pub forward: Option, 12 | pub right: Option, 13 | pub up: Option, 14 | pub velocity: Option, 15 | } 16 | 17 | pub(crate) struct Transform { 18 | pub position: Position, 19 | pub orientation: Orientation, 20 | pub velocity: Velocity, 21 | } 22 | 23 | impl From for Transform { 24 | fn from(raw: RawTransform) -> Self { 25 | let RawTransform { 26 | position, 27 | position_north, 28 | forward, 29 | right, 30 | up, 31 | velocity, 32 | } = raw; 33 | let position = position.unwrap_or_default(); 34 | let position_north = position_north.unwrap_or_default(); 35 | let forward = forward.unwrap_or_default(); 36 | let right = right.unwrap_or_default(); 37 | let up = up.unwrap_or_default(); 38 | let velocity = velocity.unwrap_or_default(); 39 | 40 | let projection_error = 41 | (position_north.z - position.u).atan2(position_north.x - position.v); 42 | let heading = forward.z.atan2(forward.x); 43 | 44 | let orientation = Orientation { 45 | heading: { 46 | let heading = heading.to_degrees(); 47 | if heading < 0.0 { 48 | heading + 360.0 49 | } else { 50 | heading 51 | } 52 | }, 53 | yaw: (heading - projection_error).to_degrees(), 54 | roll: right.y.asin().neg().to_degrees(), 55 | pitch: forward.y.asin().to_degrees(), 56 | forward: Some(forward), 57 | right: Some(right), 58 | up: Some(up), 59 | }; 60 | 61 | let velocity = Velocity { 62 | heading: { 63 | let heading = velocity.z.atan2(velocity.x).to_degrees(); 64 | if heading < 0.0 { 65 | heading + 360.0 66 | } else { 67 | heading 68 | } 69 | }, 70 | speed: (velocity.x.powi(2) + velocity.z.powi(2)).sqrt(), 71 | velocity: Some(velocity), 72 | }; 73 | 74 | Transform { 75 | position, 76 | orientation, 77 | velocity, 78 | } 79 | } 80 | } 81 | 82 | #[derive(serde::Deserialize)] 83 | #[serde(rename_all = "camelCase")] 84 | struct UnitIntermediate { 85 | id: u32, 86 | name: String, 87 | callsign: String, 88 | coalition: i32, 89 | r#type: String, 90 | player_name: Option, 91 | group: Option, 92 | number_in_group: u32, 93 | raw_transform: Option, 94 | } 95 | 96 | impl From for Unit { 97 | fn from(i: UnitIntermediate) -> Self { 98 | let UnitIntermediate { 99 | id, 100 | name, 101 | callsign, 102 | coalition, 103 | r#type, 104 | player_name, 105 | group, 106 | number_in_group, 107 | raw_transform, 108 | } = i; 109 | let transform = Transform::from(raw_transform.unwrap_or_default()); 110 | Unit { 111 | id, 112 | name, 113 | callsign, 114 | coalition, 115 | r#type, 116 | position: Some(transform.position), 117 | orientation: Some(transform.orientation), 118 | velocity: Some(transform.velocity), 119 | player_name, 120 | group, 121 | number_in_group, 122 | } 123 | } 124 | } 125 | 126 | #[derive(serde::Deserialize)] 127 | #[serde(rename_all = "camelCase")] 128 | struct WeaponIntermediate { 129 | id: u32, 130 | r#type: String, 131 | raw_transform: Option, 132 | } 133 | 134 | impl From for Weapon { 135 | fn from(i: WeaponIntermediate) -> Self { 136 | let WeaponIntermediate { 137 | id, 138 | r#type, 139 | raw_transform, 140 | } = i; 141 | let transform = Transform::from(raw_transform.unwrap_or_default()); 142 | Weapon { 143 | id, 144 | r#type, 145 | position: Some(transform.position), 146 | orientation: Some(transform.orientation), 147 | velocity: Some(transform.velocity), 148 | } 149 | } 150 | } 151 | } 152 | -------------------------------------------------------------------------------- /stubs/src/controller.rs: -------------------------------------------------------------------------------- 1 | pub mod v0 { 2 | tonic::include_proto!("dcs.controller.v0"); 3 | } 4 | -------------------------------------------------------------------------------- /stubs/src/custom.rs: -------------------------------------------------------------------------------- 1 | pub mod v0 { 2 | tonic::include_proto!("dcs.custom.v0"); 3 | } 4 | -------------------------------------------------------------------------------- /stubs/src/group.rs: -------------------------------------------------------------------------------- 1 | pub mod v0 { 2 | tonic::include_proto!("dcs.group.v0"); 3 | } 4 | -------------------------------------------------------------------------------- /stubs/src/hook.rs: -------------------------------------------------------------------------------- 1 | pub mod v0 { 2 | tonic::include_proto!("dcs.hook.v0"); 3 | } 4 | -------------------------------------------------------------------------------- /stubs/src/lib.rs: -------------------------------------------------------------------------------- 1 | // Current recommendation as of 2 | // https://github.com/tokio-rs/prost/issues/661#issuecomment-1156606409 3 | #![allow(clippy::derive_partial_eq_without_eq)] 4 | #![allow(clippy::large_enum_variant)] 5 | 6 | pub mod atmosphere; 7 | pub mod coalition; 8 | pub mod common; 9 | pub mod controller; 10 | pub mod custom; 11 | pub mod group; 12 | pub mod hook; 13 | pub mod metadata; 14 | pub mod mission; 15 | pub mod net; 16 | pub mod srs; 17 | pub mod timer; 18 | pub mod trigger; 19 | pub mod unit; 20 | mod utils; 21 | pub mod world; 22 | 23 | #[cfg(test)] 24 | mod tests { 25 | use super::common::v0::{ 26 | Airbase, AirbaseCategory, Coalition, Initiator, Position, Unit, initiator, 27 | }; 28 | use super::mission::v0::{StreamEventsResponse, stream_events_response as event}; 29 | use super::world::v0::GetAirbasesResponse; 30 | use crate::common::v0::{Orientation, Velocity}; 31 | 32 | #[test] 33 | fn test_event_deserialization() { 34 | let event: StreamEventsResponse = 35 | serde_json::from_str(r#"{"time":4.2,"event":{"type":"missionStart"}}"#).unwrap(); 36 | assert_eq!( 37 | event, 38 | StreamEventsResponse { 39 | time: 4.2, 40 | event: Some(event::Event::MissionStart(event::MissionStartEvent {})), 41 | } 42 | ); 43 | } 44 | 45 | // Note that this string simulates the response from Lua. This is important as it is 46 | // _after_ increment changes to enums to cater to gRPC enum indexing where 0 is not allowed 47 | // for responses. 48 | #[test] 49 | fn test_enum_deserialization() { 50 | let event: StreamEventsResponse = serde_json::from_str( 51 | r#" 52 | { 53 | "time": 4.2, 54 | "event": { 55 | "type": "markAdd", 56 | "initiator": { 57 | "initiator": { 58 | "unit": { 59 | "id": 1, 60 | "name": "Aerial-1-1", 61 | "callsign": "Enfield11", 62 | "coalition": 3, 63 | "type": "FA-18C_hornet", 64 | "playerName": "New callsign", 65 | "numberInGroup": 1 66 | } 67 | } 68 | }, 69 | "coalition": 3, 70 | "id": 42, 71 | "position": { 72 | "lat": 1, 73 | "lon": 2, 74 | "alt": 3, 75 | "u": 4, 76 | "v": 5 77 | }, 78 | "text": "Test" 79 | } 80 | } 81 | "#, 82 | ) 83 | .unwrap(); 84 | assert_eq!( 85 | event, 86 | StreamEventsResponse { 87 | time: 4.2, 88 | event: Some(event::Event::MarkAdd(event::MarkAddEvent { 89 | initiator: Some(Initiator { 90 | initiator: Some(initiator::Initiator::Unit(Unit { 91 | id: 1, 92 | name: "Aerial-1-1".to_string(), 93 | callsign: "Enfield11".to_string(), 94 | r#type: "FA-18C_hornet".to_string(), 95 | coalition: Coalition::Blue.into(), 96 | player_name: Some("New callsign".to_string()), 97 | group: None, 98 | number_in_group: 1, 99 | position: Some(Default::default()), 100 | orientation: Some(Orientation { 101 | heading: Default::default(), 102 | yaw: Default::default(), 103 | pitch: Default::default(), 104 | roll: Default::default(), 105 | forward: Some(Default::default()), 106 | right: Some(Default::default()), 107 | up: Some(Default::default()), 108 | }), 109 | velocity: Some(Velocity { 110 | heading: Default::default(), 111 | speed: Default::default(), 112 | velocity: Some(Default::default()) 113 | }), 114 | })) 115 | }), 116 | visibility: Some(event::mark_add_event::Visibility::Coalition( 117 | Coalition::Blue.into() 118 | )), 119 | id: 42, 120 | position: Some(Position { 121 | lat: 1.0, 122 | lon: 2.0, 123 | alt: 3.0, 124 | u: 4.0, 125 | v: 5.0, 126 | }), 127 | text: "Test".to_string(), 128 | })), 129 | } 130 | ); 131 | } 132 | 133 | // Note that this string sumulates the response from Lua. This is important as it is 134 | // _after_ increment changes to enums to cater to gRPC enum indexing where 0 is not allowed 135 | // for responses. 136 | #[test] 137 | fn test_optional_field_deserialization() { 138 | let resp: GetAirbasesResponse = serde_json::from_str( 139 | r#" 140 | 141 | { 142 | "airbases": [ 143 | { 144 | "coalition": 1, 145 | "name": "Anapa-Vityazevo", 146 | "callsign": "Anapa-Vityazevo", 147 | "position": { 148 | "lon": 37.35978347755592, 149 | "lat": 45.01317473377168, 150 | "alt": 43.00004196166992, 151 | "u": 0, 152 | "v": 0 153 | }, 154 | "category": 1, 155 | "displayName": "Anapa-Vityazevo" 156 | } 157 | ] 158 | } 159 | "#, 160 | ) 161 | .unwrap(); 162 | assert_eq!( 163 | resp, 164 | GetAirbasesResponse { 165 | airbases: vec![Airbase { 166 | unit: None, 167 | name: "Anapa-Vityazevo".to_string(), 168 | callsign: "Anapa-Vityazevo".to_string(), 169 | coalition: Coalition::Neutral.into(), 170 | position: Some(Position { 171 | lon: 37.35978347755592, 172 | lat: 45.01317473377168, 173 | alt: 43.00004196166992, 174 | u: 0.0, 175 | v: 0.0, 176 | }), 177 | category: AirbaseCategory::Airdrome.into(), 178 | display_name: "Anapa-Vityazevo".to_string(), 179 | }] 180 | } 181 | ); 182 | } 183 | } 184 | -------------------------------------------------------------------------------- /stubs/src/metadata.rs: -------------------------------------------------------------------------------- 1 | pub mod v0 { 2 | tonic::include_proto!("dcs.metadata.v0"); 3 | } 4 | -------------------------------------------------------------------------------- /stubs/src/mission.rs: -------------------------------------------------------------------------------- 1 | pub mod v0 { 2 | tonic::include_proto!("dcs.mission.v0"); 3 | } 4 | -------------------------------------------------------------------------------- /stubs/src/net.rs: -------------------------------------------------------------------------------- 1 | pub mod v0 { 2 | tonic::include_proto!("dcs.net.v0"); 3 | } 4 | -------------------------------------------------------------------------------- /stubs/src/srs.rs: -------------------------------------------------------------------------------- 1 | pub mod v0 { 2 | tonic::include_proto!("dcs.srs.v0"); 3 | } 4 | -------------------------------------------------------------------------------- /stubs/src/timer.rs: -------------------------------------------------------------------------------- 1 | pub mod v0 { 2 | tonic::include_proto!("dcs.timer.v0"); 3 | } 4 | -------------------------------------------------------------------------------- /stubs/src/trigger.rs: -------------------------------------------------------------------------------- 1 | pub mod v0 { 2 | tonic::include_proto!("dcs.trigger.v0"); 3 | } 4 | -------------------------------------------------------------------------------- /stubs/src/unit.rs: -------------------------------------------------------------------------------- 1 | pub mod v0 { 2 | use crate::common::v0::{RawTransform, Transform}; 3 | 4 | tonic::include_proto!("dcs.unit.v0"); 5 | 6 | #[derive(serde::Deserialize)] 7 | #[serde(rename_all = "camelCase")] 8 | struct GetTransformResponseIntermediate { 9 | time: f64, 10 | raw_transform: Option, 11 | } 12 | 13 | impl From for GetTransformResponse { 14 | fn from(i: GetTransformResponseIntermediate) -> Self { 15 | let GetTransformResponseIntermediate { 16 | time, 17 | raw_transform, 18 | } = i; 19 | let transform = Transform::from(raw_transform.unwrap_or_default()); 20 | GetTransformResponse { 21 | time, 22 | position: Some(transform.position), 23 | orientation: Some(transform.orientation), 24 | velocity: Some(transform.velocity), 25 | } 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /stubs/src/world.rs: -------------------------------------------------------------------------------- 1 | pub mod v0 { 2 | tonic::include_proto!("dcs.world.v0"); 3 | } 4 | -------------------------------------------------------------------------------- /tts/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "dcs-grpc-tts" 3 | version.workspace = true 4 | rust-version.workspace = true 5 | authors.workspace = true 6 | license.workspace = true 7 | edition.workspace = true 8 | 9 | [dependencies] 10 | audiopus = "0.2" 11 | base64.workspace = true 12 | bytes.workspace = true 13 | log.workspace = true 14 | ogg = "0.9" 15 | reqwest = { version = "0.12", default-features = false, features = [ 16 | "rustls-tls", 17 | "json", 18 | ] } 19 | rusoto_core = { version = "0.48", default-features = false, features = [ 20 | "rustls", 21 | ] } 22 | rusoto_credential = "0.48" 23 | rusoto_polly = { version = "0.48", default-features = false, features = [ 24 | "rustls", 25 | ] } 26 | serde.workspace = true 27 | serde_json.workspace = true 28 | thiserror.workspace = true 29 | tokio.workspace = true 30 | 31 | [target.'cfg(target_os = "windows")'.dependencies.windows] 32 | version = "0.61" 33 | features = [ 34 | "Foundation", 35 | "Foundation_Collections", 36 | "Storage_Streams", 37 | "Media_Core", 38 | "Media_SpeechSynthesis", 39 | ] 40 | 41 | [target.'cfg(target_os = "windows")'.dependencies.windows-future] 42 | version = "0.2" 43 | -------------------------------------------------------------------------------- /tts/src/aws.rs: -------------------------------------------------------------------------------- 1 | pub use rusoto_core::Region; 2 | use rusoto_core::request::HttpClient; 3 | use rusoto_credential::StaticProvider; 4 | use rusoto_polly::{Polly, PollyClient, SynthesizeSpeechInput}; 5 | 6 | #[derive(Debug)] 7 | pub struct AwsConfig { 8 | pub voice: Option, 9 | pub key: String, 10 | pub secret: String, 11 | pub region: Region, 12 | } 13 | 14 | /// Synthesize the `text` using AWS Polly. Returns a vec of opus frames. 15 | pub async fn synthesize(text: &str, config: &AwsConfig) -> Result>, AwsError> { 16 | let dispatcher = HttpClient::new()?; 17 | let creds = StaticProvider::new(config.key.clone(), config.secret.clone(), None, None); 18 | 19 | let req = SynthesizeSpeechInput { 20 | // TODO: allow usage of neural engine (only available for certain voices and regions!) 21 | engine: None, 22 | language_code: None, 23 | lexicon_names: None, 24 | output_format: "pcm".to_string(), 25 | sample_rate: None, // defaults to 16,000 26 | speech_mark_types: None, 27 | text: format!(r#"{text}"#), 28 | text_type: Some("ssml".to_string()), 29 | voice_id: config.voice.as_deref().unwrap_or("Brian").to_string(), 30 | }; 31 | 32 | let client = PollyClient::new_with(dispatcher, creds, config.region.clone()); 33 | let response = client.synthesize_speech(req).await?; 34 | 35 | let wav = response.audio_stream.ok_or(AwsError::MissingAudioStream)?; 36 | Ok(crate::wav_to_opus(wav).await?) 37 | } 38 | 39 | #[derive(Debug, thiserror::Error)] 40 | pub enum AwsError { 41 | #[error(transparent)] 42 | Tls(#[from] rusoto_core::request::TlsError), 43 | #[error("AWS Polly response did not contain an audio stream")] 44 | MissingAudioStream, 45 | #[error("failed to encode audio data as opus")] 46 | Opus(#[from] audiopus::Error), 47 | #[error("failed to synthesize text to speech")] 48 | Synthesize(#[from] rusoto_core::RusotoError), 49 | } 50 | -------------------------------------------------------------------------------- /tts/src/azure.rs: -------------------------------------------------------------------------------- 1 | use std::io::Cursor; 2 | 3 | use ogg::reading::PacketReader; 4 | use reqwest::StatusCode; 5 | 6 | #[derive(Debug)] 7 | pub struct AzureConfig { 8 | pub voice: Option, 9 | pub key: String, 10 | pub region: String, 11 | } 12 | 13 | /// Synthesize the `text` using AWS Polly. Returns a vec of opus frames. 14 | pub async fn synthesize(text: &str, config: &AzureConfig) -> Result>, AzureError> { 15 | let client = reqwest::Client::new(); 16 | 17 | // Acquire token 18 | let token_url = format!( 19 | "https://{}.api.cognitive.microsoft.com/sts/v1.0/issueToken", 20 | config.region 21 | ); 22 | let ocp_apim_key = &config.key; 23 | let res = client 24 | .post(&token_url) 25 | .header("Ocp-Apim-Subscription-Key", ocp_apim_key) 26 | .header("Content-Length", "0") 27 | .send() 28 | .await?; 29 | 30 | if res.status() != StatusCode::OK { 31 | let err = res.text().await?; 32 | return Err(AzureError::Azure(format!("Azure error: {err}"))); 33 | } 34 | 35 | let token = res.text().await?; 36 | 37 | // Prepare SSML 38 | let voice = config 39 | .voice 40 | .as_deref() 41 | .unwrap_or("en-US-AriaNeural") 42 | .to_string(); 43 | let (lang, _) = voice.split_at(5); 44 | 45 | let tts = format!( 46 | r#"{text}"# 47 | ); 48 | 49 | // Make actual synthesize request 50 | let api_url = format!( 51 | "https://{}.tts.speech.microsoft.com/cognitiveservices/v1", 52 | config.region 53 | ); 54 | let res = client 55 | .post(&api_url) 56 | .bearer_auth(token) 57 | .header("X-Microsoft-OutputFormat", "ogg-24khz-16bit-mono-opus") 58 | .header("Content-Type", "application/ssml+xml") 59 | .header("User-Agent", "DCS-gRPC") 60 | .body(tts) 61 | .send() 62 | .await?; 63 | 64 | if res.status() != StatusCode::OK { 65 | let err = res.text().await?; 66 | return Err(AzureError::Azure(format!("Azure error: {err}"))); 67 | } 68 | 69 | // Convert ogg audio data to opus frames 70 | let bytes = res.bytes().await?; 71 | let data = Cursor::new(bytes); 72 | let mut frames = Vec::new(); 73 | let mut audio = PacketReader::new(data); 74 | while let Some(pck) = audio.read_packet()? { 75 | frames.push(pck.data.to_vec()) 76 | } 77 | 78 | Ok(frames) 79 | } 80 | 81 | #[derive(Debug, thiserror::Error)] 82 | pub enum AzureError { 83 | #[error(transparent)] 84 | Request(#[from] reqwest::Error), 85 | #[error("received error from Azure API")] 86 | Azure(String), 87 | #[error("error reading ogg packet")] 88 | Ogg(#[from] ogg::OggReadError), 89 | } 90 | -------------------------------------------------------------------------------- /tts/src/gcloud.rs: -------------------------------------------------------------------------------- 1 | use std::io::Cursor; 2 | 3 | use base64::Engine; 4 | use base64::prelude::BASE64_STANDARD; 5 | use ogg::reading::PacketReader; 6 | use reqwest::StatusCode; 7 | use serde::{Deserialize, Serialize}; 8 | use serde_json::Value; 9 | 10 | #[derive(Debug)] 11 | pub struct GCloudConfig { 12 | pub voice: Option, 13 | pub key: String, 14 | } 15 | 16 | /// Synthesize the `text` using AWS Polly. Returns a vec of opus frames. 17 | pub async fn synthesize(text: &str, config: &GCloudConfig) -> Result>, GcloudError> { 18 | let voice = config.voice.as_deref().unwrap_or("en-GB-Neural2-A"); 19 | let (language_code, _) = voice.split_at(5); 20 | 21 | let payload = TextToSpeechRequest { 22 | audio_config: AudioConfig { 23 | audio_encoding: "OGG_OPUS", 24 | sample_rate_hertz: 16_000, 25 | speaking_rate: 0.9, 26 | }, 27 | input: Input { 28 | ssml: &format!(r#"{text}"#), 29 | }, 30 | voice: Voice { 31 | language_code, 32 | name: voice, 33 | }, 34 | }; 35 | 36 | let url = format!( 37 | "https://texttospeech.googleapis.com/v1/text:synthesize?key={}", 38 | config.key 39 | ); 40 | let client = reqwest::Client::new(); 41 | let res = client.post(&url).json(&payload).send().await?; 42 | if res.status() != StatusCode::OK { 43 | let err: Value = res.json().await?; 44 | return Err(GcloudError::Gcloud(err.to_string())); 45 | } 46 | 47 | // Convert ogg audio data to opus frames 48 | let data: TextToSpeechResponse = res.json().await?; 49 | let data = BASE64_STANDARD.decode(data.audio_content)?; 50 | let data = Cursor::new(data); 51 | let mut frames = Vec::new(); 52 | let mut audio = PacketReader::new(data); 53 | while let Some(pck) = audio.read_packet()? { 54 | frames.push(pck.data.to_vec()) 55 | } 56 | 57 | Ok(frames) 58 | } 59 | 60 | #[derive(Serialize, Debug)] 61 | #[serde(rename_all = "camelCase")] 62 | struct AudioConfig<'a> { 63 | audio_encoding: &'a str, 64 | sample_rate_hertz: u32, 65 | speaking_rate: f32, 66 | } 67 | 68 | #[derive(Serialize, Debug)] 69 | #[serde(rename_all = "camelCase")] 70 | struct Input<'a> { 71 | ssml: &'a str, 72 | } 73 | 74 | #[derive(Serialize, Debug)] 75 | #[serde(rename_all = "camelCase")] 76 | struct Voice<'a> { 77 | language_code: &'a str, 78 | name: &'a str, 79 | } 80 | 81 | #[derive(Serialize, Debug)] 82 | #[serde(rename_all = "camelCase")] 83 | struct TextToSpeechRequest<'a> { 84 | audio_config: AudioConfig<'a>, 85 | input: Input<'a>, 86 | voice: Voice<'a>, 87 | } 88 | 89 | #[derive(Deserialize, Debug)] 90 | #[serde(rename_all = "camelCase")] 91 | struct TextToSpeechResponse { 92 | audio_content: String, 93 | } 94 | 95 | #[derive(Debug, thiserror::Error)] 96 | pub enum GcloudError { 97 | #[error(transparent)] 98 | Request(#[from] reqwest::Error), 99 | #[error("received error from GCloud TTS API")] 100 | Gcloud(String), 101 | #[error("error reading ogg packet")] 102 | Ogg(#[from] ogg::OggReadError), 103 | #[error("failed to base64 decode audio data")] 104 | Base64(#[from] base64::DecodeError), 105 | } 106 | -------------------------------------------------------------------------------- /tts/src/lib.rs: -------------------------------------------------------------------------------- 1 | use std::error; 2 | 3 | pub use aws::{AwsConfig, Region as AwsRegion}; 4 | pub use azure::AzureConfig; 5 | pub use gcloud::GCloudConfig; 6 | #[cfg(target_os = "windows")] 7 | pub use win::WinConfig; 8 | 9 | mod aws; 10 | mod azure; 11 | mod gcloud; 12 | #[cfg(target_os = "windows")] 13 | mod win; 14 | 15 | #[derive(Debug)] 16 | pub enum TtsConfig { 17 | Aws(aws::AwsConfig), 18 | Azure(azure::AzureConfig), 19 | GCloud(gcloud::GCloudConfig), 20 | #[cfg(target_os = "windows")] 21 | Win(win::WinConfig), 22 | } 23 | 24 | /// Synthesize the `text` to speech. Returns a vec of opus frames. 25 | pub async fn synthesize( 26 | text: &str, 27 | config: &TtsConfig, 28 | ) -> Result>, Box> { 29 | Ok(match config { 30 | TtsConfig::Aws(config) => aws::synthesize(text, config).await?, 31 | TtsConfig::Azure(config) => azure::synthesize(text, config).await?, 32 | TtsConfig::GCloud(config) => gcloud::synthesize(text, config).await?, 33 | #[cfg(target_os = "windows")] 34 | TtsConfig::Win(config) => win::synthesize(text, config).await?, 35 | }) 36 | } 37 | 38 | async fn wav_to_opus(wav: bytes::Bytes) -> Result>, audiopus::Error> { 39 | use audiopus::coder::Encoder; 40 | use audiopus::{Application, Channels, SampleRate}; 41 | 42 | tokio::task::spawn_blocking(move || { 43 | let audio_stream = wav 44 | .chunks(2) 45 | .map(|bytes| i16::from_le_bytes(bytes.try_into().unwrap())) 46 | .collect::>(); 47 | 48 | const MONO_20MS: usize = 16000 /* 1 channel */ * 20 / 1000; 49 | let enc = Encoder::new(SampleRate::Hz16000, Channels::Mono, Application::Voip)?; 50 | let mut pos = 0; 51 | let mut output = [0; 256]; 52 | let mut frames = Vec::new(); 53 | 54 | while pos + MONO_20MS < audio_stream.len() { 55 | let len = enc.encode(&audio_stream[pos..(pos + MONO_20MS)], &mut output)?; 56 | frames.push(output[..len].to_vec()); 57 | 58 | pos += MONO_20MS; 59 | } 60 | 61 | Ok::<_, audiopus::Error>(frames) 62 | }) 63 | .await 64 | .unwrap() 65 | } 66 | -------------------------------------------------------------------------------- /tts/src/win.rs: -------------------------------------------------------------------------------- 1 | use std::borrow::Cow; 2 | 3 | use tokio::sync::Mutex; 4 | use windows::Media::SpeechSynthesis::SpeechSynthesizer; 5 | use windows::Storage::Streams::DataReader; 6 | use windows::core::HSTRING; 7 | 8 | #[derive(Debug)] 9 | pub struct WinConfig { 10 | pub voice: Option, 11 | } 12 | 13 | static MUTEX: Mutex<()> = Mutex::const_new(()); 14 | 15 | pub async fn synthesize(text: &str, config: &WinConfig) -> Result>, WinError> { 16 | // Note, there does not seem to be a way to explicitly set 16000kHz, 16 audio bits per 17 | // sample and mono channel. 18 | 19 | // Prevent concurrent Windows TTS synthesis, as this might cause a crash. 20 | let lock = MUTEX.lock().await; 21 | 22 | let mut voice_info = None; 23 | if let Some(voice) = &config.voice { 24 | let all_voices = SpeechSynthesizer::AllVoices()?; 25 | let len = all_voices.Size()? as usize; 26 | for i in 0..len { 27 | let v = all_voices.GetAt(i as u32)?; 28 | let lang = v.Language()?.to_string(); 29 | if !lang.starts_with("en-") { 30 | continue; 31 | } 32 | 33 | let name = v.DisplayName()?.to_string(); 34 | if name.ends_with(voice) { 35 | voice_info = Some(v); 36 | break; 37 | } 38 | } 39 | } else { 40 | // default to the first english voice in the list 41 | let all_voices = SpeechSynthesizer::AllVoices()?; 42 | let len = all_voices.Size()? as usize; 43 | for i in 0..len { 44 | let v = all_voices.GetAt(i as u32)?; 45 | let lang = v.Language()?.to_string(); 46 | if lang.starts_with("en-") { 47 | let name = v.DisplayName()?.to_string(); 48 | log::debug!("Using WIN voice: {}", name); 49 | voice_info = Some(v); 50 | break; 51 | } 52 | } 53 | 54 | if voice_info.is_none() { 55 | log::warn!("Could not find any english Windows TTS voice"); 56 | } 57 | } 58 | 59 | if voice_info.is_none() { 60 | let all_voices = SpeechSynthesizer::AllVoices()?; 61 | let len = all_voices.Size()? as usize; 62 | log::info!( 63 | "Available WIN voices are (you don't have to include the `Microsoft` prefix in \ 64 | the name):" 65 | ); 66 | for i in 0..len { 67 | let v = all_voices.GetAt(i as u32)?; 68 | let lang = v.Language()?.to_string(); 69 | if !lang.starts_with("en-") { 70 | continue; 71 | } 72 | 73 | let name = v.DisplayName()?.to_string(); 74 | log::info!("- {} ({})", name, lang); 75 | } 76 | } 77 | 78 | let synth = SpeechSynthesizer::new()?; 79 | let lang = if let Some(info) = voice_info { 80 | synth.SetVoice(&info)?; 81 | info.Language()?.to_string().into() 82 | } else { 83 | Cow::Borrowed("en") 84 | }; 85 | 86 | // the DataReader is !Send, which is why we have to process it in a local set 87 | let stream = synth 88 | .SynthesizeSsmlToStreamAsync(&HSTRING::from(&format!( 89 | r#"{text}"# 90 | )))? 91 | .await?; 92 | let size = stream.Size()?; 93 | 94 | let rd = DataReader::CreateDataReader(&stream.GetInputStreamAt(0)?)?; 95 | rd.LoadAsync(size as u32)?.await?; 96 | 97 | let mut wav = vec![0u8; size as usize]; 98 | rd.ReadBytes(wav.as_mut_slice())?; 99 | 100 | drop(lock); 101 | 102 | Ok(crate::wav_to_opus(wav.into()).await?) 103 | } 104 | 105 | #[derive(Debug, thiserror::Error)] 106 | pub enum WinError { 107 | #[error("Calling WinRT API failed with error code {0}: {1}")] 108 | Win(i32, String), 109 | #[error("Runtime error")] 110 | Io(#[from] std::io::Error), 111 | #[error("failed to encode audio data as opus")] 112 | Opus(#[from] audiopus::Error), 113 | } 114 | 115 | impl From for WinError { 116 | fn from(err: windows::core::Error) -> Self { 117 | WinError::Win(err.code().0, err.message().to_string()) 118 | } 119 | } 120 | --------------------------------------------------------------------------------