diff --git a/.actrc b/.actrc
new file mode 100644
index 0000000..8af6fb7
--- /dev/null
+++ b/.actrc
@@ -0,0 +1,8 @@
+# Configuration file for act (run github actions locally using docker)
+# https://github.com/nektos/act
+
+# Swap docker image for the one containing the rust toolchain
+-P ubuntu-latest=ghcr.io/catthehacker/ubuntu:rust-latest
+
+# Load custom event
+-e .github/.act-event.json
diff --git a/.github/.act-event.json b/.github/.act-event.json
new file mode 100644
index 0000000..22e66fc
--- /dev/null
+++ b/.github/.act-event.json
@@ -0,0 +1,6 @@
+{
+ "act": true,
+ "repository": {
+ "default_branch": "main"
+ }
+}
diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md
new file mode 100644
index 0000000..fbe8429
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/bug_report.md
@@ -0,0 +1,23 @@
+---
+name: Bug report
+about: Create a report for a found bug
+title: ''
+labels: bug
+assignees: ''
+
+---
+
+**Describe the bug**
+A clear and concise description of what the bug is.
+
+**Steps To Reproduce**
+Please provide the steps to reproduce the behavior (if not possible, describe as many details as possible).
+
+**Expected behavior**
+A clear and concise description of what you expected to happen.
+
+**Screenshots or Data**
+If applicable, add screenshots/data to help explain your problem.
+
+**Additional context**
+Add any other context about the problem here.
diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md
new file mode 100644
index 0000000..a9ae259
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/feature_request.md
@@ -0,0 +1,14 @@
+---
+name: Feature request
+about: Suggest a feature
+title: ''
+labels: enhancement
+assignees: ''
+
+---
+
+**What is this feature about?**
+Shortly explain what your requested feature is about.
+
+**Additional context/references**
+Add any other context or references about the feature request here.
diff --git a/.github/ISSUE_TEMPLATE/help-regarding-code-protocol-errors.md b/.github/ISSUE_TEMPLATE/help-regarding-code-protocol-errors.md
new file mode 100644
index 0000000..88c286f
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/help-regarding-code-protocol-errors.md
@@ -0,0 +1,17 @@
+---
+name: Help regarding code/protocol errors
+about: Use this if you can't figure out how to use something.
+title: ''
+labels: ''
+assignees: ''
+
+---
+
+**This issue shall be made only if you have already gone through the docs, have you done it?**
+Please state if there is something confusing regarding the docs (eg. location or wording).
+
+**What's you problem?**
+State as concise as possible what you want to do and can't do.
+
+**Suggestions to make this clearer**
+Mention how could stuff be improved so that someone doesn't have the same problem as you (eg. Error should give more information).
diff --git a/.github/badges/.gitkeep b/.github/badges/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/.github/badges/node.svg b/.github/badges/node.svg
new file mode 100644
index 0000000..8b8ae49
--- /dev/null
+++ b/.github/badges/node.svg
@@ -0,0 +1,20 @@
+
\ No newline at end of file
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 0000000..bdb8e27
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,16 @@
+version: 2
+updates:
+ - package-ecosystem: "github-actions"
+ directory: "/.github/workflows"
+ schedule:
+ interval: "daily"
+
+ - package-ecosystem: "cargo"
+ directory: "/crates/cli"
+ schedule:
+ interval: "daily"
+
+ - package-ecosystem: "cargo"
+ directory: "/crates/lib"
+ schedule:
+ interval: "daily"
diff --git a/.github/labeler.yml b/.github/labeler.yml
new file mode 100644
index 0000000..76326b3
--- /dev/null
+++ b/.github/labeler.yml
@@ -0,0 +1,18 @@
+ci:
+ - .github/workflows/**
+ - .github/labeler.yml
+ - .actrc
+ - .pre-commit-config.yaml
+
+protocol:
+ - crates/lib/src/protocols/**
+
+game:
+ - crates/lib/src/games/**
+
+cli:
+ - crates/cli/**
+
+crate:
+ - Cargo.toml
+ - Cargo.lock
diff --git a/.github/workflows/audit.yml b/.github/workflows/audit.yml
new file mode 100644
index 0000000..5f71714
--- /dev/null
+++ b/.github/workflows/audit.yml
@@ -0,0 +1,20 @@
+# yaml-language-server: $schema=https://raw.githubusercontent.com/softprops/github-actions-schemas/master/workflow.json
+name: Security audit
+on:
+ push:
+ paths:
+ - "**/Cargo.toml"
+ - "**/Cargo.lock"
+jobs:
+ security_audit:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v5
+
+ - name: Generate Cargo.lock # https://github.com/rustsec/audit-check/issues/27
+ run: cargo generate-lockfile
+
+ - name: Audit Check
+ uses: rustsec/audit-check@v2.0.0
+ with:
+ token: ${{ secrets.GITHUB_TOKEN }}
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..89dbef3
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,199 @@
+# yaml-language-server: $schema=https://raw.githubusercontent.com/softprops/github-actions-schemas/master/workflow.json
+name: CI
+
+on:
+ push:
+ branches: ["main"]
+ paths:
+ - "**.rs" # Any rust file
+ - "**/Cargo.toml" # Any Cargo.toml
+ - ".rustfmt.toml"
+ - ".github/workflows/ci.yml" # This action
+ pull_request:
+ branches: ["main"]
+ paths:
+ - "**.rs" # Any rust file
+ - "**/Cargo.toml" # Any Cargo.toml
+ - ".rustfmt.toml"
+ - ".github/workflows/ci.yml" # This action
+
+env:
+ CARGO_TERM_COLOR: always
+
+jobs:
+ # First check that we can build EVERYTHING and that tests pass
+ build_first:
+ name: "Build, check, and test with all features"
+ runs-on: ubuntu-latest
+ outputs:
+ cli: ${{ steps.filter.outputs.cli }}
+ lib: ${{ steps.filter.outputs.lib }}
+ steps:
+ - uses: actions/checkout@v5
+ - uses: Swatinem/rust-cache@v2
+ with:
+ shared-key: "cargo-deps"
+ cache-targets: false
+ - name: Run Build
+ run: cargo check --verbose --workspace --bins --lib --examples --all-features
+ - name: Run Tests
+ run: cargo test --verbose --workspace --bins --lib --examples --tests --all-features
+ # Check what paths were modified so we only run the required tests
+ - uses: dorny/paths-filter@v3
+ id: filter
+ with:
+ filters: |
+ cli:
+ - 'crates/cli/**'
+ lib:
+ - 'crates/lib/**'
+
+ # If we were able to build then test different feature combinations compile with the library
+ build_lib:
+ runs-on: ubuntu-latest
+ needs: ["build_first"]
+ # Only run if library files were modified
+ if: ${{ needs.build_first.outputs.lib == 'true' }}
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - build_type: ""
+ build_name: "Default"
+ - build_type: "--no-default-features"
+ build_name: "No features"
+ - build_type: "--no-default-features --features games"
+ build_name: "Just games"
+ - build_type: "--no-default-features --features services"
+ build_name: "Just Services"
+ - build_type: "--no-default-features --features game_defs"
+ build_name: "Just Game definitions"
+ - build_type: "--no-default-features --features serde"
+ build_name: "Just serde"
+ name: "Build library ${{ matrix.build_name }}"
+ steps:
+ - uses: actions/checkout@v5
+ - uses: Swatinem/rust-cache@v2
+ with:
+ shared-key: "cargo-deps"
+ cache-targets: false
+ save-if: false
+ - name: Run Build
+ run: cargo check -p gamedig --verbose --lib --examples --tests ${{ matrix.build_type }}
+
+ # If we were able to build then test different feature combinations compile with the CLI
+ build_cli:
+ runs-on: ubuntu-latest
+ needs: ["build_first"]
+ # Only run if CLI files were modified
+ if: ${{ needs.build_first.outputs.cli == 'true' }}
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - build_type: ""
+ build_name: "Default"
+ - build_type: "--no-default-features"
+ build_name: "No features"
+ name: "Build CLI ${{ matrix.build_name }}"
+ steps:
+ - uses: actions/checkout@v5
+ - uses: Swatinem/rust-cache@v2
+ with:
+ shared-key: "cargo-deps"
+ cache-targets: false
+ save-if: false
+ - name: Run Build
+ run: cargo check -p gamedig_cli --verbose --bins --examples --tests ${{ matrix.build_type }}
+
+ # If we were able to build then test the MSRV compiles (for the libary as not enforced for CLI)
+ build_msrv:
+ name: "Build using MSRV (lib only)"
+ runs-on: ubuntu-latest
+ needs: ["build_first"]
+ # Only run if library files were modified
+ if: ${{ needs.build_first.outputs.lib == 'true' }}
+ # Unfortunate hard-coding of rustup directory so that rust-cache caches it
+ env:
+ RUSTUP_HOME: /home/runner/.rustup
+ steps:
+ # Act's rust runner has rustup in a different place
+ - if: ${{ env.ACT }}
+ run: mkdir -p /home/runner && ln -s /usr/share/rust/.rustup /home/runner/.rustup
+ - uses: actions/checkout@v5
+ - uses: Swatinem/rust-cache@v2
+ with:
+ cache-targets: false
+ cache-directories: ${{ env.RUSTUP_HOME }}/toolchains
+ - name: Install MSRV
+ uses: actions-rs/toolchain@v1
+ with:
+ toolchain: 1.85.1
+ override: true
+ - name: Run MSRV
+ run: cargo check -p gamedig
+ # Check the code is formatted properly
+ formatting:
+ name: "Check code formatting"
+ runs-on: ubuntu-latest
+ # Unfortunate hard-coding of rustup directory so that rust-cache caches it
+ env:
+ RUSTUP_HOME: /home/runner/.rustup
+ steps:
+ # Act's rust runner has rustup in a different place
+ - if: ${{ env.ACT }}
+ run: mkdir -p /home/runner && ln -s /usr/share/rust/.rustup /home/runner/.rustup
+ - uses: actions/checkout@v5
+ - uses: Swatinem/rust-cache@v2
+ with:
+ cache-targets: false
+ cache-directories: ${{ env.RUSTUP_HOME }}/toolchains
+ - name: Install Formatting nightly
+ uses: actions-rs/toolchain@v1
+ with:
+ toolchain: nightly-2025-04-19
+ components: rustfmt
+ override: true
+ - name: Run Formatting check
+ run: cargo fmt --check --verbose
+
+ # If we were able to build then lint the codebase with clippy
+ clippy:
+ name: "Run clippy tests"
+ runs-on: ubuntu-latest
+ needs: ["build_first"]
+ steps:
+ - uses: actions/checkout@v5
+ - uses: Swatinem/rust-cache@v2
+ with:
+ shared-key: "cargo-deps"
+ cache-targets: false
+ save-if: false
+ # Run github actions version of clippy that adds annotations
+ - name: Run Clippy
+ uses: actions-rs/clippy-check@v1
+ with:
+ token: ${{ secrets.GITHUB_TOKEN }}
+ args: --workspace --bins --lib --examples --tests --all-features
+ if: ${{ !env.ACT }} # skip during local actions testing
+ # Run clippy binary
+ - name: Run clippy (local)
+ run: cargo clippy --verbose --workspace --bins --lib --examples --tests --all-features
+ if: ${{ env.ACT }} # only run during local actions testing
+
+ # If we were able to build then test that rustdoc (and rustdoc examples) compile
+ doc:
+ name: "Check rustdoc"
+ runs-on: ubuntu-latest
+ needs: ["build_first"]
+ steps:
+ - uses: actions/checkout@v5
+ - uses: Swatinem/rust-cache@v2
+ with:
+ shared-key: "cargo-deps"
+ cache-targets: false
+ save-if: false
+ - name: "Run cargo doc"
+ run: cargo doc --workspace
+ env:
+ RUSTDOCFLAGS: "-D warnings"
diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml
new file mode 100644
index 0000000..c39c0db
--- /dev/null
+++ b/.github/workflows/codeql.yml
@@ -0,0 +1,44 @@
+name: "CodeQL"
+
+on:
+ push:
+ branches: [ "main" ]
+ pull_request:
+ branches: [ "main" ]
+ schedule:
+ - cron: '0 0 1 * *' # monthly on the 1st at 00:00 UTC
+
+jobs:
+ analyze:
+ name: Analyze (rust)
+ runs-on: ubuntu-latest
+ permissions:
+ security-events: write
+ packages: read
+ actions: read
+ contents: read
+
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - language: rust
+ build-mode: none
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v5
+
+ - name: Build
+ run: cargo build --release
+
+ - name: Initialize CodeQL
+ uses: github/codeql-action/init@v3
+ with:
+ languages: ${{ matrix.language }}
+ build-mode: ${{ matrix.build-mode }}
+
+ - name: Perform CodeQL Analysis
+ uses: github/codeql-action/analyze@v3
+ with:
+ category: "/language:${{ matrix.language }}"
diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml
new file mode 100644
index 0000000..6f3f4c2
--- /dev/null
+++ b/.github/workflows/labeler.yml
@@ -0,0 +1,16 @@
+name: "Pull Request Labeler"
+on:
+ - pull_request_target
+
+jobs:
+ triage:
+ permissions:
+ contents: read
+ pull-requests: write
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v5
+ - uses: actions/labeler@v4
+ with:
+ repo-token: "${{ secrets.GITHUB_TOKEN }}"
+ dot: true
diff --git a/.github/workflows/node-badge.yml b/.github/workflows/node-badge.yml
new file mode 100644
index 0000000..0fccbcc
--- /dev/null
+++ b/.github/workflows/node-badge.yml
@@ -0,0 +1,63 @@
+# Based on: https://github.com/emibcn/badge-action/blob/master/.github/workflows/test.yml
+name: "Generate node comparison badge"
+
+on:
+ push:
+ paths:
+ - "crates/lib/src/games/definitions.rs"
+ - ".github/workflows/node-badge.yml"
+ - ".github/workflows/scripts/node-badge.mjs"
+ branches:
+ - "main" # Limit badge commits to only happen on the main branch
+ schedule: # This runs on the default branch only, it could still trigger on PRs but only if they develop on default branch and enable actions.
+ - cron: "34 3 * * 2" # Update once a week in case node-gamedig has changed
+ workflow_dispatch:
+
+jobs:
+ badge:
+ runs-on: "ubuntu-latest"
+ name: Create node comparison badge
+ env:
+ BADGE_PATH: ".github/badges/node.svg"
+ steps:
+ - name: Extract branch name
+ shell: bash
+ run: echo "branch=${GITHUB_REF#refs/heads/}" >> "${GITHUB_OUTPUT}"
+ id: extract_branch
+
+ - uses: actions/checkout@v5
+ - uses: actions/checkout@v5
+ with:
+ repository: "gamedig/node-gamedig"
+ path: "node-gamedig"
+ sparse-checkout: |
+ lib/games.js
+ package.json
+
+ - name: Calculate comparison
+ id: comparison
+ run: node .github/workflows/scripts/node-badge.mjs
+
+ - name: Generate the badge SVG image
+ uses: emibcn/badge-action@v2.0.3
+ id: badge
+ with:
+ label: "Node game coverage"
+ status: "${{ steps.comparison.outputs.percent }}%"
+ color: "0f80c1"
+ path: ${{ env.BADGE_PATH }}
+
+ - name: "Commit badge"
+ continue-on-error: true
+ run: |
+ git config --local user.email "action@github.com"
+ git config --local user.name "GitHub Action"
+ git add "${BADGE_PATH}"
+ git commit -m "Add/Update badge"
+
+ - name: Push badge commit
+ uses: ad-m/github-push-action@master
+ if: ${{ success() }}
+ with:
+ github_token: ${{ secrets.GITHUB_TOKEN }}
+ branch: ${{ steps.extract_branch.outputs.branch }}
diff --git a/.github/workflows/scripts/node-badge.mjs b/.github/workflows/scripts/node-badge.mjs
new file mode 100644
index 0000000..0e6aa46
--- /dev/null
+++ b/.github/workflows/scripts/node-badge.mjs
@@ -0,0 +1,63 @@
+"use strict";
+
+//! Calculate the percentage of games from node that we support
+// Expects node-gamedig checkout out in git root /node-gamedig
+// Expects the generic example to output a list of game IDs when no arguments are provided
+
+import process from "node:process";
+import { closeSync, openSync, writeSync } from "node:fs";
+import { spawnSync } from "node:child_process";
+
+const setOutput = (key, value) => {
+ const file = openSync(process.env.GITHUB_OUTPUT, "a");
+ writeSync(file, `${key}=${value}\n`);
+ closeSync(file);
+};
+
+// Get node IDs
+// NOTE: Here we directly import from games to avoid loading
+// unecessary parts of the library that would require us
+// to install dependencies.
+import { games } from "../../../node-gamedig/lib/games.js";
+
+const node_ids = new Set(Object.keys(games));
+const node_total = node_ids.size;
+
+// Get rust IDs
+
+const command = spawnSync("cargo", [
+ "run",
+ "-p",
+ "gamedig",
+ "--example",
+ "generic",
+]);
+
+if (command.status !== 0) {
+ console.error(command.stderr.toString("utf8"));
+ process.exit(1);
+}
+
+const rust_ids_pretty = command.stdout.toString("utf8");
+const rust_ids = new Set(
+ rust_ids_pretty
+ .split("\n")
+ .map((line) => line.split("\t")[0])
+ .filter((id) => id.length > 0)
+);
+
+// Detect missing node IDs
+
+for (const id of rust_ids) {
+ if (node_ids.delete(id)) {
+ rust_ids.delete(id);
+ }
+}
+
+console.log("Node remains", node_ids);
+console.log("Rust remains", rust_ids);
+
+const percent = 1 - node_ids.size / node_total;
+
+// Output percent to 2 decimal places
+setOutput("percent", Math.round(percent * 10000) / 100);
diff --git a/.gitignore b/.gitignore
index 579ce9a..e285a7a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -11,3 +11,7 @@ Cargo.lock
# Others
.idea/
+.venv/
+.vscode/
+
+test_everything.py
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
new file mode 100644
index 0000000..ba5f8e4
--- /dev/null
+++ b/.pre-commit-config.yaml
@@ -0,0 +1,60 @@
+# See https://pre-commit.com for more information
+# See https://pre-commit.com/hooks.html for more hooks
+repos:
+ - repo: local
+ hooks:
+ - id: clippy
+ name: Check clippy
+ language: system
+ files: '([.]rs|Cargo\.toml)$'
+ pass_filenames: false
+ entry: rustup run --install nightly-2025-04-19 cargo-clippy -- --workspace --all-features -- -D warnings
+
+ - id: build-no-features
+ name: Check crate build with no features
+ language: system
+ files: '([.]rs|Cargo\.toml)$'
+ pass_filenames: false
+ entry: cargo check --workspace --no-default-features
+
+ - id: build-all-features
+ name: Check crate builds with all features
+ language: system
+ files: '([.]rs|Cargo\.toml)$'
+ pass_filenames: false
+ entry: cargo check --workspace --all-features --lib --bins --examples
+
+ - id: test
+ name: Check tests pass
+ language: system
+ files: '([.]rs|Cargo\.toml)$'
+ pass_filenames: false
+ entry: cargo test --workspace --bins --lib --examples --tests --all-features
+
+ - id: format
+ name: Check rustfmt
+ language: system
+ files: '([.]rs|Cargo\.toml)$'
+ pass_filenames: false
+ entry: rustup run --install nightly-2025-04-19 cargo-fmt --check
+
+ - id: msrv
+ name: Check MSRV compiles (lib only)
+ language: system
+ files: '([.]rs|Cargo\.toml)$'
+ pass_filenames: false
+ entry: rustup run --install 1.85.1 cargo check -p gamedig
+
+ - id: docs
+ name: Check rustdoc compiles
+ language: system
+ files: '([.]rs|Cargo\.toml)$'
+ pass_filenames: false
+ entry: env RUSTDOCFLAGS="-D warnings" cargo doc
+
+ - id: actions
+ name: Check actions work
+ language: system
+ files: '^[.]github/workflows/'
+ pass_filenames: false
+ entry: act --rm
diff --git a/.rustfmt.toml b/.rustfmt.toml
new file mode 100644
index 0000000..d410c70
--- /dev/null
+++ b/.rustfmt.toml
@@ -0,0 +1,72 @@
+attr_fn_like_width = 70
+array_width = 60
+binop_separator = "Front"
+blank_lines_lower_bound = 0
+blank_lines_upper_bound = 1
+brace_style = "PreferSameLine"
+chain_width = 60
+color = "Auto"
+combine_control_expr = false
+comment_width = 80
+condense_wildcard_suffixes = true
+control_brace_style = "AlwaysSameLine"
+disable_all_formatting = false
+doc_comment_code_block_width = 100
+edition = "2021"
+emit_mode = "Files"
+empty_item_single_line = true
+error_on_line_overflow = false
+error_on_unformatted = false
+fn_call_width = 60
+fn_params_layout = "Tall"
+fn_single_line = true
+force_explicit_abi = true
+force_multiline_blocks = true
+format_generated_files = true
+format_macro_bodies = true
+format_strings = true
+group_imports = "Preserve"
+hard_tabs = false
+show_parse_errors = true
+hex_literal_case = "Preserve"
+ignore = []
+indent_style = "Block"
+imports_granularity = "Preserve"
+imports_indent = "Block"
+imports_layout = "HorizontalVertical"
+inline_attribute_width = 0
+make_backup = false
+match_arm_blocks = true
+match_arm_leading_pipes = "Never"
+match_block_trailing_comma = false
+max_width = 120
+merge_derives = true
+newline_style = "Auto"
+normalize_comments = true
+normalize_doc_attributes = false
+overflow_delimited_expr = false
+reorder_impl_items = false
+reorder_imports = true
+reorder_modules = true
+required_version = "1.8.0"
+short_array_element_width_threshold = 10
+single_line_if_else_max_width = 50
+skip_children = false
+space_after_colon = true
+space_before_colon = false
+spaces_around_ranges = true
+struct_field_align_threshold = 0
+struct_lit_single_line = true
+struct_lit_width = 18
+struct_variant_width = 35
+tab_spaces = 4
+trailing_comma = "Vertical"
+trailing_semicolon = true
+type_punctuation_density = "Wide"
+unstable_features = false
+use_field_init_shorthand = false
+use_small_heuristics = "Default"
+use_try_shorthand = true
+style_edition = "2021"
+where_single_line = true
+wrap_comments = true
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..ab34b87
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,119 @@
+# Contributing to rust-GameDig
+
+This project is very open to new suggestions, additions and/or changes, these
+can come in the form of *discussions* about the project's state, *proposing a
+new feature*, *holding a few points on why we shall do X breaking change* or
+*submitting a fix*.
+
+## Communications
+
+GitHub is the place we use to track bugs and discuss new features/changes,
+although we have a [Discord](https://discord.gg/NVCMn3tnxH) server for the
+community, all bugs, suggestions and changes will be reported on GitHub
+alongside with their backing points to ensure the transparency of the project's
+development.
+
+## Issues
+
+Before opening an issue, check if there is an existing relevant issue first,
+someone might just have had your issue already, or you might find something
+related that could be of help.
+
+When opening a new issue, make sure to fill the issue template. They are made
+to make the subject to be as understandable as possible, not doing so may result
+in your issue not being managed right away, if you don't understand something
+(be it regarding your own problem/the issue template/the library), please state
+so.
+
+## Development
+
+Note before contributing that everything done here is under the [MIT](https://opensource.org/license/mit/) license.
+
+### Naming
+
+Naming is an important matter, and it shouldn't be changed unless necessary.
+
+Game **names** should be added as they appear on steam (or other storefront
+if not listed there) with the release year appended in brackets (except when the
+release year is already part of the name).
+If there is a mod that needs to be added (or it adds the support for server
+queries for the game), its name should be composed of the game name, a separating
+**bracket**, the mod name and the release year as specified previously
+(e.g. `Grand Theft Auto V - FiveM (2013)`).
+
+A game's **identification** is a lowercase alphanumeric string will and be forged
+following these rules:
+
+1. Names composed of a maximum of two words (unless #4 applies) will result in an
+ id where the words are concatenated (`Dead Cells` -> `deadcells`), acronyms in
+ the name count as a single word (`S.T.A.L.K.E.R.` -> `stalker`).
+2. Names of more than two words shall be made into an acronym made of the
+ initial
+ letters (`The Binding of Isaac` -> `tboi`), [hypenation composed words](https://prowritingaid.com/hyphenated-words)
+ don't count as a single word, but of how many parts they are made of
+ (`Dino D-Day`, 3 words, so `ddd`).
+3. If a game has the exact name as a previously existing id's game
+ (`Star Wars Battlefront 2`, the 2005 and 2017 one), append the release year to
+ the newer id (2005 would be `swb2` (suppose we already have this one supported)
+ and 2017 would be `swb22017`).
+4. If a new id (`Day of Dragons` -> `dod`) results in an id that already exists
+ (`Day of Defeat` -> `dod`), then the new name should ignore rule #2
+ (`Day of Dragons` -> `dayofdragons`).
+5. Roman numbering will be converted to arabic numbering (`XIV` -> `14`).
+6. Unless numbers (years included) are at the end of a name, they will be considered
+ words. If a number is not in the first position, its entire numeric digits will be
+ used instead of the acronym of that number's digits (`Left 4 Dead` -> `l4d`). If the
+ number is in the first position the longhand (words: 5 -> five) representation of the
+ number will be used to create an acronym (`7 Days to Die` -> `sdtd`). Other examples:
+ `Team Fortress 2` -> `teamfortress2`, `Unreal Tournament 2003` ->
+ `unrealtournament2003`.
+7. If a game supports multiple protocols, multiple entries will be done for said game
+ where the edition/protocol name (first disposable in this order) will be appended to
+ the base game id's: `` (where the protocol id will follow all
+ rules except #2) (Minecraft is mainly divided by 2 editions, Java and Bedrock
+ which will be `minecraftjava` and `minecraftbedrock` respectively, but it also has
+ legacy versions, which use another protocol, an example would be the one for `1.6`,
+ so the name would be `Legacy 1.6` which its id will be `legacy16`, resulting in the
+ entry of `minecraftlegacy16`). One more entry can be added by the base name of the
+ game, which queries in a group said supported protocols to make generic queries
+ easier and disposable.
+8. If its actually about a mod that adds the ability for queries to be performed,
+ process only the mod name.
+
+### Making commits
+
+Where possible please format commits as complete atomic changes that don't rely on
+any future commits. Also make sure that the commit message is as descriptive as
+possible.
+
+To avoid CI failing when you make a PR you can use our pre-commit hooks: tests that
+run before you are able to make a commit (you can skip this at any time by adding
+the `-n` flag to `git commit`).
+
+To set this up you need the following programs installed
+
+- [pre-commit](https://pre-commit.com/)
+- [rustup](https://rustup.rs/)
+- [act](https://github.com/nektos/act) (If you want to test changes to github actions workflows)
+
+Once these are installed you can enable the pre-commit hook by running the following in
+the root directory of the repository.
+
+```shell
+$ pre-commit install
+```
+
+### Priorities
+
+Game suggestions will be prioritized by maintainers based on whether the game
+uses a protocol already implemented in the library (games that use already
+implemented protocols will be added first), except in the case where a
+contribution is made with the protocol needed to implement the game.
+
+The same goes for protocols, if 2 were to be requested, the one implemented in
+the most games will be prioritized.
+
+### Releases
+
+Currently, there is no release schedule.
+Releases are made when the team decides one will be fitting to be done.
diff --git a/Cargo.toml b/Cargo.toml
index 4c4f940..0fe5ede 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,14 +1,22 @@
-[package]
-name = "gamedig"
-version = "0.0.0"
-edition = "2021"
-authors = ["CosminPerRam [cosmin.p@live.com]", "mmorrisontx [https://github.com/mmorrisontx]"]
-license-file = "LICENSE.md"
-description = "Check out servers with this."
-homepage = "https://github.com/CosminPerRam/rust-gamedig"
-documentation = "https://github.com/CosminPerRam/rust-gamedig"
-repository = "https://github.com/CosminPerRam/rust-gamedig"
-readme = "README.md"
-keywords = ["server", "valve", "games", "checker", "status"]
+[workspace]
+members = ["crates/cli", "crates/lib", "crates/id-tests"]
-[dependencies]
+# Edition 2021, uses resolver = 2
+resolver = "2"
+
+[profile.release]
+opt-level = 3
+debug = false
+rpath = true
+lto = 'fat'
+codegen-units = 1
+
+[profile.release.package."*"]
+opt-level = 3
+
+# When building locally, use the local version of the library
+# Comment this out when you want to resolve the library from crates.io
+# This is only for crates that use gamedig as a dependency
+# https://doc.rust-lang.org/cargo/reference/overriding-dependencies.html
+[patch.crates-io]
+gamedig = { path = "./crates/lib" }
\ No newline at end of file
diff --git a/GAMES.md b/GAMES.md
new file mode 100644
index 0000000..de88490
--- /dev/null
+++ b/GAMES.md
@@ -0,0 +1,104 @@
+A supported game is defined as a game that has been successfully tested, other games that are not present here but use
+one of the implemented protocols might work too, but that isn't guaranteed.
+Beware of the `Notes` column, as it contains information about query port offsets or other query
+requirements/information.
+
+# Supported games:
+
+| Game | Use name | Protocol | Notes |
+|------------------------------------|---------------------|----------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
+| Team Fortress 2 | TEAMFORTRESS2 | Valve | |
+| The Ship | THESHIP | Valve (*Altered) | |
+| Counter-Strike: Global Offensive | CSGO | Valve | The server must have the cvar `host_players_show` set to `2` to get the full player list. |
+| Counter-Strike: Source | CSS | Valve | |
+| Day of Defeat: Source | DODS | Valve | |
+| Left 4 Dead | LEFT4DEAD | Valve | |
+| Left 4 Dead 2 | LEFT4DEAD2 | Valve | |
+| Half-Life 2 Deathmatch | HL2D | Valve | |
+| Alien Swarm | ALIENSWARM | Valve | |
+| Alien Swarm: Reactive Drop | ASRD | Valve | |
+| Insurgency | INSURGENCY | Valve | |
+| Insurgency: Sandstorm | INSURGENCYSANDSTORM | Valve | Query port offset: 1. |
+| Insurgency: Modern Infantry Combat | IMIC | Valve | |
+| Counter-Strike: Condition Zero | CSCZ | Valve GoldSrc | |
+| Day of Defeat | DOD | Valve GoldSrc | |
+| Minecraft | MINECRAFT | Proprietary | Bedrock edition provides a different response compared to the Java edition, query specifically for bedrock to get them, otherwise, only matching fields will be provided. |
+| 7 Days To Die | SD2D | Valve | |
+| ARK: Survival Evolved | ASE | Valve | |
+| Unturned | UNTURNED | Valve | |
+| The Forest | THEFOREST | Valve GoldSrc | Query port offset: 1. |
+| Team Fortress Classic | TFC | Valve | |
+| Sven Co-op | SCO | Valve GoldSrc | |
+| Rust | RUST | Valve | |
+| Counter-Strike | COUNTERSTRIKE | Valve GoldSrc | |
+| Arma 2: Operation Arrowhead | A2OA | Valve | Query port offset: 1. |
+| Arma 3 | ARMA3 | Valve | |
+| Day of Infamy | DOI | Valve | |
+| Half-Life Deathmatch: Source | HLDS | Valve | |
+| Risk of Rain 2 | ROR2 | Valve | Query port offset: 1. |
+| Battalion 1944 | BATTALION1944 | Valve | Query port offset: 3. It is strongly recommended to also query the rules, as it sends basic server info in them. |
+| Black Mesa | BLACKMESA | Valve | |
+| Project Zomboid | PROJECTZOMBOID | Valve | |
+| Age of Chivalry | AOC | Valve | |
+| Don't Starve Together | DST | Valve | Query port is 27016. |
+| Colony Survival | COLONYSURVIVAL | Valve | |
+| Onset | ONSET | Valve | Query port is 7776. |
+| Codename CURE | CODENAMECURE | Valve | |
+| Ballistic Overkill | BALLISTICOVERKILL | Valve | Query port is 27016. |
+| BrainBread 2 | BRAINBREAD2 | Valve | |
+| Avorion | AVORION | Valve | Query port is 27020. |
+| Operation: Harsh Doorstop | OHD | Valve | Query port is 27005. |
+| V Rising | VRISING | Valve | Query port is 27016. |
+| Unreal Tournament | UNREALTOURNAMENT | GameSpy 1 | Query Port offset: 1. |
+| Battlefield 1942 | B1942 | GameSpy 1 | Query port is 23000. |
+| Serious Sam | SERIOUSSAM | GameSpy 1 | Query Port offset: 1. |
+| Frontlines: Fuel of War | FFOW | Valve (*Altered) | Query Port offset: 2. |
+| Crysis Wars | CRYSISWARS | GameSpy 3 | |
+| Quake 2 | QUAKE2 | Quake 2 | |
+| Quake 1 | QUAKE1 | Quake 1 | |
+| Quake 3: Arena | QUAKE3 | Quake 3 | |
+| Hell Let Loose | HLL | Valve Protocol | Query port is 26420. Note that on this port it might not send players data, as there might be another query port that does send players data. |
+| Soldier of Fortune 2 | SOF2 | Quake 3 | |
+| Halo: Combat Evolved | HCE | GameSpy 2 | |
+| Just Cause 2: Multiplayer | JC2M | GameSpy 3 (*Altered) | |
+| Warsow | WARSOW | Quake 3 | |
+| Creativerse | CREATIVERSE | Valve | Query Port offset: 1. |
+| Garry's Mod | GARRYSMOD | Valve | |
+| Barotrauma | BAROTRAUMA | Valve | Query Port offset: 1. |
+| Valheim | VALHEIM | Valve | Query Port offset: 1. Does not respond to the A2S rules. |
+| The Front | THEFRONT | Valve | Responds with wrong values on `name` (gives out a SteamID instead of the server name) and `players_maximum` (always 200). |
+| Conan Exiles | CONANEXILES | Valve | Does not respond to the players query. |
+| Darkest Hour: Europe '44-'45 | DARKESTHOUR | Unreal2 | Query port offset: 1 |
+| Devastation | DEVASTATION | Unreal2 | Query port offset: 1 |
+| Killing Floor | KILLINGFLOOR | Unreal2 | Query port offset: 1 |
+| Red Orchestra | REDORCHESTRA | Unreal2 | Query port offset: 1 |
+| Unreal Tournament 2003 | UT2003 | Unreal2 | Query port offset: 1 |
+| Unreal Tournament 2004 | UT2004 | Unreal2 | Query port offset: 1 |
+| Post Scriptum | POSTSCRIPTUM | Valve | |
+| Squad | SQUAD | Valve | |
+| Savage 2 | SAVAGE2 | Proprietary | |
+| Rising World | RISINGWORLD | Valve | Query port offset: -1 |
+| ATLAS | ATLAS | Valve | Query port offset: 51800 |
+| America's Army: Proving Grounds | AAPG | Valve | Query port: 27020. Does not respond to the rules query. |
+| Base Defense | BASEDEFENSE | Valve | Query port: 27015. Does not respond to the rules query. |
+| Zombie Panic: Source | ZPS | Valve | Query port: 27015. |
+| Call Of Duty: Black Ops 3 | CODBO3 | Valve | Query port: 27017. |
+| Counter-Strike 2 | COUNTERSTRIKE2 | Valve | |
+| Double Action: Boogaloo | DAB | Valve | |
+| Mordhau | MORDHAU | Valve | |
+| Enshrouded | ENSHROUDED | Valve | |
+| Myth of Empires | MOE | Valve | |
+| Pirates, Vikings, and Knights II | PVAK2 | Valve | |
+| PixARK | PIXARK | Valve | |
+| Ark: Survival Ascended | ASA | Epic | Available on the 'tls' feature |
+| Aliens vs. Predator 2010 | AVP | Valve | |
+| Arma Reforger | ARMAREFORGER | Valve | |
+| Nova-Life: Amboise | NLA | Valve | |
+| Abiotic Factor | ABIOTICFACTOR | Valve | |
+| Soulmask | SOULMASK | Valve | |
+| Starbound | STARBOUND | Valve | |
+| Minetest | MINETEST | Proprietary | Available on the 'tls', 'serde' and 'services' feature |
+
+## Planned to add support:
+
+_
diff --git a/LICENSE.md b/LICENSE.md
index 7fa2763..27fe7df 100644
--- a/LICENSE.md
+++ b/LICENSE.md
@@ -1,6 +1,6 @@
MIT License
-Copyright (c) 2022 CosminPerRam
+Copyright (c) 2022 - 2026 GameDig Organization & Contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/PROTOCOLS.md b/PROTOCOLS.md
new file mode 100644
index 0000000..abf10d1
--- /dev/null
+++ b/PROTOCOLS.md
@@ -0,0 +1,18 @@
+A protocol is defined as proprietary if it is being used only for a single scope (or series, like Minecraft).
+
+# Supported protocols:
+
+| Name | For | Proprietary? | Documentation reference | Notes |
+|---------------------------|-------|--------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
+| Valve Protocol | Games | No | [Server Queries](https://developer.valvesoftware.com/wiki/Server_queries) | In some cases, the players details query might contain some 0-length named players. Multi-packet decompression not tested. |
+| Minecraft | Games | Yes | Java: [List Server Protocol](https://wiki.vg/Server_List_Ping) Bedrock: [Node-GameDig Source](https://github.com/gamedig/node-gamedig/blob/master/protocols/minecraftbedrock.js) | |
+| GameSpy | Games | No | One: [Node-GameDig Source](https://github.com/gamedig/node-gamedig/blob/master/protocols/gamespy1.js) Two: [Node-GameDig Source](https://github.com/gamedig/node-gamedig/blob/master/protocols/gamespy2.js) Three: [Node-GameDig Source](https://github.com/gamedig/node-gamedig/blob/master/protocols/gamespy3.js) | These protocols are not really standardized, gamedig tries to get the most common fields amongst its supported games, if there are parsing problems, use the `query_vars` function. |
+| Quake | Games | No | One: [Node-GameDig Source](https://github.com/gamedig/node-gamedig/blob/master/protocols/quake1.js) Two: [Node-GameDig Source](https://github.com/gamedig/node-gamedig/blob/master/protocols/quake2.js) Three: [Node-GameDig Source](https://github.com/gamedig/node-gamedig/blob/master/protocols/quake3.js) | |
+| Just Cause 2: Multiplayer | Games | Yes | [Node-GameDig Source](https://github.com/gamedig/node-gamedig/blob/master/protocols/jc2mp.js) |
+| Unreal 2 | Games | No | [Node-GameDig Source](https://github.com/gamedig/node-gamedig/blob/master/protocols/unreal2.js) | Sometimes servers send strings that node-gamedig would treat as latin1 that are UTF-8 encoded, when this happens the remove color code breaks because latin1 decodes the colour sequences differently. Some games provide additional info at the end of the server info packet, this is not currently handled (see the node implementation). Some games use a bot player to denote the team names, this is not currently handled. |
+| Savage 2 | Games | Yes | [Node-GameDig Source](https://github.com/gamedig/node-gamedig/blob/master/protocols/savage2.js) | |
+| Epic | Games | No | [Node-GameDig Source](https://github.com/gamedig/node-gamedig/blob/master/protocols/epic.js) | Available only on the 'tls' feature. |
+
+## Planned to add support:
+
+_
diff --git a/README.md b/README.md
index c4c8038..32f0d7c 100644
--- a/README.md
+++ b/README.md
@@ -1 +1,118 @@
-# rust-gamedig
\ No newline at end of file
+
rust-GameDig
+
+
The fast library for querying game servers/services.
+ This library brings what
+
+ node-GameDig
+
+ does (and not only), to pure Rust!
+
+
+**Warning**: This project goes through frequent API breaking changes and hasn't been thoroughly tested.
+
+## Community
+
+Checkout the GameDig Community Discord Server [here](https://discord.gg/NVCMn3tnxH).
+Note that it isn't be a replacement for GitHub issues, if you have found a problem
+within the library or want to request a feature, it's better to do so here rather than
+on Discord.
+
+## Usage
+
+Minimum Supported Rust Version is `1.85.1` and the code is cross-platform.
+
+Pick a game/service/protocol (check the [GAMES](GAMES.md), [SERVICES](SERVICES.md) and [PROTOCOLS](PROTOCOLS.md) files
+to see the currently supported ones), provide the ip and the port (be aware that some game servers use a separate port
+for the info queries, the port can also be optional if the server is running the default ports) then query on it.
+
+[Team Fortress 2](https://store.steampowered.com/app/440/Team_Fortress_2/) query example:
+
+```rust
+use gamedig::games::teamfortress2;
+
+fn main() {
+ let response = teamfortress2::query(&"127.0.0.1".parse().unwrap(), None);
+ // None is the default port (which is 27015), could also be Some(27015)
+
+ match response { // Result type, must check what it is...
+ Err(error) => println!("Couldn't query, error: {}", error),
+ Ok(r) => println!("{:#?}", r)
+ }
+}
+```
+
+Response (note that some games have a different structure):
+
+```json5
+{
+ protocol: 17,
+ name: "Team Fortress 2 Dedicated Server.",
+ map: "ctf_turbine",
+ game: "tf2",
+ appid: 440,
+ players_online: 0,
+ players_details: [],
+ players_maximum: 69,
+ players_bots: 0,
+ server_type: Dedicated,
+ has_password: false,
+ vac_secured: true,
+ version: "7638371",
+ port: Some(27015),
+ steam_id: Some(69753253289735296),
+ tv_port: None,
+ tv_name: None,
+ keywords: Some(
+ "alltalk,nocrits"
+ ),
+ rules: [
+ "mp_autoteambalance"
+ :
+ "1",
+ "mp_maxrounds"
+ :
+ "5",
+ //....
+ ]
+}
+```
+
+Want to see more examples? Checkout the [examples](crates/lib/examples) folder.
+
+## Command Line Interface
+
+The library also has an [official CLI](https://crates.io/crates/gamedig_cli) that you can use, it has
+MSRV of `1.85.1`.
+
+## Documentation
+
+The documentation is available at [docs.rs](https://docs.rs/gamedig/latest/gamedig/).
+Curious about the history and what changed between versions?
+Everything is in the changelogs file: [lib](crates/lib/CHANGELOG.md) and [cli](crates/lib/CHANGELOG.md).
+
+## Contributing
+
+If you want to see your favorite game/service being supported here, open an issue, and I'll prioritize it (or do a pull
+request if you want to implement it yourself)!
+
+Before contributing please read [CONTRIBUTING](CONTRIBUTING.md).
diff --git a/RESPONSES.md b/RESPONSES.md
new file mode 100644
index 0000000..14a7948
--- /dev/null
+++ b/RESPONSES.md
@@ -0,0 +1,78 @@
+Every protocol has its own response type(s), below is a listing of the overlapping fields on these responses.
+
+If a cell is blank it doesn't exist, otherwise it contains the type of that data in the current column's response type.
+In the case that a field that performs the same function exists in the current column's response type that name is
+annotated in brackets.
+
+# Response table
+
+| Field | Generic | GameSpy(1) | GameSpy(2) | GameSpy(3) | Minecraft(Java) | Minecraft(Bedrock) | Valve | Quake | Unreal2 | Epic | Proprietary: FFOW | Proprietary: TheShip | Proprietary: JC2MP | Proprietary: Savage 2 | Proprietary: Minetest |
+|----------------------|----------|------------|------------|------------|-----------------|--------------------|---------------|-----------|------------|----------|-------------------|----------------------|--------------------|-----------------------|-----------------------|
+| name | `Option` | `String` | `String` | `String` | | `String` | `String` | `String` | `String` | `String` | `String` | `String` | `String` | `String` | `String` |
+| description | `Option` | | | | `String` | | | | | | `String` | | `String` | | `String` |
+| game_mode | `Option` | `String` | | `String` | | `Option` | `String` | | `String` | | `String` | `String` | | `String` | |
+| game_version | `Option` | `String` | | `String` | `String` | | `String` | `String` | | `String` | `String` | `String` | `String` | | `String` |
+| map | `Option` | `String` | `String` | `String` | | `Option` | `String` | `String` | `String` | `String` | `String` | `String` | | `String` | |
+| players_maxmimum | `u32` | `u32` | `u32` | `u32` | `u32` | `u32` | `u8` | `u8` | `u32` | `u32` | `u8` | `u8` | `u32` | `u8` | `u32` |
+| players_online | `u32` | `u32` | `u32` | `u32` | `u32` | `u32` | `u8` | `u8` | `u32` | `u32` | `u8` | `u8` | `u32` | `u8` | `u32` |
+| players_bots | `Option` | | | | | | `u8` | | | | | `u8` | | | |
+| has_password | `Option` | `bool` | `bool` | `bool` | | | `bool` | | | `bool` | `bool` | `bool` | `bool` | | `Option` |
+| players_minimum | | `Option` | `Option` | `Option` | | | | | | | | | | `u8` | |
+| players | | `Vec` | `Vec` | `Vec` | `Option>` | | `Option>` | `Vec ` | `Vec` | `Vec` | | `Vec` | `Vec` | | `Vec` |
+| tournament | | `bool` | | `bool` | | | | | | | | | | | |
+| unused_entries | | `Hashmap` | | `HashMap` | | | | `HashMap` | | | | | | | |
+| teams | | | `Vec` | `Vec` | | | | | | | | | | | |
+| protocol_version | | | | | `i32` | `String` | `u8` | | | | `u8` | `u8` | | `String` | |
+| server_type | | | | | `Server` | `Server` | `Server` | | | | | `Server` | | | |
+| rules | | | | | | | `Option>` | | `HashMap>` | | | `HashMap` | | | |
+| environment_type | | | | | | | `Environment` | | | | `Environment` | | | | |
+| vac_secured | | | | | | | `bool` | | | | `bool` | `bool` | | | |
+| map_title | | `Option` | | | | | | | | | | | | | |
+| admin_contact | | `Option` | | | | | | | | | | | | | |
+| admin_name | | `Option` | | | | | | | | | | | | | |
+| favicon | | | | | `Option` | | | | | | | | | | |
+| previews_chat | | | | | `Option` | | | | | | | | | | |
+| enforces_secure_chat | | | | | `Option` | | | | | | | | | | |
+| edition | | | | | | `String` | | | | | | | | | |
+| id | | | | | | `String` | | | `String` | | | | | | `String` |
+| the_ship | | | | | | | `Option` | | | | | | | | |
+| is_mod | | | | | | | `bool` | | | | | | | | |
+| extra_data | | | | | | | `Option` | | | | | | | | |
+| mod_data | | | | | | | `Option` | | | | | | | | |
+| folder | | | | | | | `String` | | | | | | | | |
+| appid | | | | | | | `u32` | | | | | | | | |
+| active_mod | | | | | | | | | | | `String` | | | | |
+| round | | | | | | | | | | | `u8` | | | | |
+| rounds_maximum | | | | | | | | | | | `u8` | | | | |
+| time_left | | | | | | | | | | | `u16` | | | | |
+| port | | | | | | | | | `u32` | | | `Option` | | | `u32` |
+| steam_id | | | | | | | | | | | | `Option` | | | |
+| tv_port | | | | | | | | | | | | `Option` | | | |
+| tv_name | | | | | | | | | | | | `Option` | | | |
+| keywords | | | | | | | | | | | | `Option` | | | |
+| mode | | | | | | | | | | | | `u8` | | | |
+| witnesses | | | | | | | | | | | | `u8` | | | |
+| duration | | | | | | | | | | | | `u8` | | | |
+| query_port | | | | | | | | | `u32` | | | | | | |
+| ip | | | | | | | | | `String` | | | | | | `String` |
+| mutators | | | | | | | | | `HashSet` | | | | | | |
+| next_map | | | | | | | | | | | | | | `String` | |
+| location | | | | | | | | | | | | | | `String` | |
+| level_minimum | | | | | | | | | | | | | | `String` | |
+| time | | | | | | | | | | | | | | `String` | |
+| creative | | | | | | | | | | | | | | | `Option` |
+| damage | | | | | | | | | | | | | | | `bool` |
+| game_time | | | | | | | | | | | | | | | `u32` |
+| lag | | | | | | | | | | | | | | | `Option` |
+| proto_max | | | | | | | | | | | | | | | `u16` |
+| proto_min | | | | | | | | | | | | | | | `u16` |
+| pvp | | | | | | | | | | | | | | | `bool` |
+| uptime | | | | | | | | | | | | | | | `u32` |
+| url | | | | | | | | | | | | | | | `Option` |
+| update_time | | | | | | | | | | | | | | | `u32` |
+| start | | | | | | | | | | | | | | | `u32` |
+| clients_top | | | | | | | | | | | | | | | `u32` |
+| updates | | | | | | | | | | | | | | | `u32` |
+| pop_v | | | | | | | | | | | | | | | `f32` |
+| geo_continent | | | | | | | | | | | | | | | `Option` |
+| ping | | | | | | | | | | | | | | | `f32` |
diff --git a/SERVICES.md b/SERVICES.md
new file mode 100644
index 0000000..5c00adb
--- /dev/null
+++ b/SERVICES.md
@@ -0,0 +1,10 @@
+# Supported services:
+
+| Name | Documentation reference |
+|------------------------|-------------------------------------------------------------------------------------------------------|
+| Valve Master Server | [Master Server Query Protocol](https://developer.valvesoftware.com/wiki/Master_Server_Query_Protocol) |
+| MineTest Master Server | [Node-GameDig](https://github.com/gamedig/node-gamedig/blob/master/protocols/minetest.js) |
+
+## Planned to add support:
+
+TeamSpeak
diff --git a/VERSIONS.md b/VERSIONS.md
new file mode 100644
index 0000000..7db63b2
--- /dev/null
+++ b/VERSIONS.md
@@ -0,0 +1,31 @@
+# MSRV (Minimum Supported Rust Version)
+
+Current: `1.85.1`
+
+Places to update:
+
+- `Cargo.toml`
+- `README.md`
+- `crates/lib/README.md`
+- `.github/workflows/ci.yml`
+- `.pre-commit-config.yaml`
+
+# rustfmt version
+
+Current: `1.8.0`
+
+Places to update:
+
+- `.rustfmt.toml`
+- The nightly rust version
+
+# The nightly rust version
+
+The toolchain version used to run rustfmt in CI
+
+Current: `nightly-2025-04-19`
+
+Places to update:
+
+- `.github/workflows/ci.yml`
+- `.pre-commit-config.yaml`
diff --git a/crates/cli/.cargo/config.toml b/crates/cli/.cargo/config.toml
new file mode 100644
index 0000000..b53c97e
--- /dev/null
+++ b/crates/cli/.cargo/config.toml
@@ -0,0 +1,10 @@
+[profile.release]
+opt-level = 'z'
+debug = false
+rpath = true
+lto = 'fat'
+codegen-units = 1
+strip = 'debuginfo'
+
+[profile.release.package."*"]
+opt-level = 'z'
diff --git a/crates/cli/CHANGELOG.md b/crates/cli/CHANGELOG.md
new file mode 100644
index 0000000..2598475
--- /dev/null
+++ b/crates/cli/CHANGELOG.md
@@ -0,0 +1,76 @@
+Who knows what the future holds...
+
+# X.Y.Z - DD/MM/YYYY
+
+Nothing... yet.
+
+# 0.5.0 - 22/02/2026
+
+### Breaking Changes:
+
+- MSRV has been updated to `1.85.1` to match the latest `gamedig` version.
+
+### Changes:
+
+- Updated dependencies
+
+# 0.4.0 - 24/08/2025
+
+### Breaking Changes:
+
+- MSRV has been updated to `1.82.0` to match the latest `gamedig` version.
+
+### Changes:
+
+- Some minor clippy fixes
+- Updated dependencies
+
+# 0.3.0 - 23/04/2025
+
+### Changes:
+
+- CLI now uses `gamedig` v0.7.0 (To update, run `cargo install gamedig_cli`).
+
+### Breaking Changes:
+
+- MSRV has been updated to `1.81.0` to match the latest `gamedig` version.
+
+# 0.2.1 - 05/12/2024
+
+Dependencies:
+- `gamedig`: `v0.6.0 -> v0.6.1`
+
+# 0.2.0 - 26/11/2024
+
+### Breaking Changes:
+
+- Restructured the release flow to be more consistent (GitHub releases will no longer be available, use cargo instead).
+- Changed crate name from `gamedig-cli` to `gamedig_cli` to align with recommended naming conventions.
+- The CLI now requires a minimum Rust version of `1.74.1`.
+
+# 0.1.1 - 15/07/2024
+
+### Changes:
+
+- Dependency updates (by @cainthebest)
+ - `gamedig`: `v0.5.0 -> v0.5.1`
+ - `clap`: `v4.1.11 -> v4.5.4`
+ - `quick-xml`: `v0.31.0 -> v0.36.0`
+ - `webbrowser`: `v0.8.12 -> v1.0.0`
+
+# 0.1.0 - 15/03/2024
+
+### Changes:
+
+- Added the CLI (by @cainthebest).
+- Added DNS lookup support (by @Douile).
+- Added JSON output option (by @Douile).
+- Added BSON output in hex or base64 (by @cainthebest).
+- Added XML output option (by @cainthebest).
+- Added ExtraRequestSettings as CLI arguments (by @Douile).
+- Added TimeoutSettings as CLI argument (by @Douile).
+- Added Comprehensive end-user documentation for the CLI interface (by @Douile & @cainthebest).
+- Tweaked compile-time flags to allow for a more preformant binary (by @cainthebest).
+- Added client for socket capture, dev tools are not included by default (by @Douile).
+- Added license information to the CLI (by @cainthebest).
+- Added source code information to the CLI (by @cainthebest).
diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml
new file mode 100644
index 0000000..474450a
--- /dev/null
+++ b/crates/cli/Cargo.toml
@@ -0,0 +1,52 @@
+[package]
+name = "gamedig_cli"
+authors = ["rust-GameDig contributors [https://github.com/gamedig/rust-gamedig/contributors]"]
+description = "A command line interface for gamedig"
+license = "MIT"
+version = "0.5.0"
+edition = "2021"
+default-run = "gamedig_cli"
+homepage = "https://gamedig.github.io/"
+repository = "https://github.com/gamedig/rust-gamedig"
+readme = "README.md"
+keywords = ["server", "query", "game", "check", "status"]
+rust-version = "1.85.1"
+categories = ["command-line-interface"]
+
+[features]
+default = ["json", "bson", "xml", "browser"]
+
+# Tools
+packet_capture = ["gamedig/packet_capture"]
+
+# Output formats
+bson = ["dep:serde", "dep:bson", "dep:hex", "dep:base64", "gamedig/serde"]
+json = ["dep:serde", "dep:serde_json", "gamedig/serde"]
+xml = ["dep:serde", "dep:serde_json", "dep:quick-xml", "gamedig/serde"]
+
+# Misc
+browser = ["dep:webbrowser"]
+
+[dependencies]
+# Core Dependencies
+thiserror = "2.0.18"
+clap = { version = "4.5.60", default-features = false, features = ["derive"] }
+gamedig = { version = "0.9.0", default-features = false, features = ["clap", "games", "game_defs"] }
+
+# Feature Dependencies
+# Serialization / Deserialization
+serde = { version = "1", optional = true, default-features = false }
+
+# BSON
+bson = { version = "2.15", optional = true, default-features = false }
+base64 = { version = "0.22", optional = true, default-features = false, features = ["std"] }
+hex = { version = "0.4.3", optional = true, default-features = false }
+
+# JSON
+serde_json = { version = "1", optional = true, default-features = false }
+
+# XML
+quick-xml = { version = "0.39.2", optional = true, default-features = false }
+
+# Browser
+webbrowser = { version = "1.1.0", optional = true, default-features = false }
diff --git a/crates/cli/LICENSE.md b/crates/cli/LICENSE.md
new file mode 100644
index 0000000..27fe7df
--- /dev/null
+++ b/crates/cli/LICENSE.md
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2022 - 2026 GameDig Organization & Contributors
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/crates/cli/README.md b/crates/cli/README.md
new file mode 100644
index 0000000..8c87577
--- /dev/null
+++ b/crates/cli/README.md
@@ -0,0 +1,67 @@
+# Rust GameDig CLI
+
+The official [rust-GameDig](https://crates.io/crates/gamedig) Command Line Interface.
+
+[](https://github.com/gamedig/rust-gamedig/actions) [](https://github.com/gamedig/rust-gamedig/blob/main/LICENSE.md) [](https://github.com/gamedig/node-gamedig)
+
+## Installation
+
+You can install the CLI via `cargo`:
+
+```sh
+cargo install gamedig_cli
+```
+
+or
+
+```sh
+cargo install gamedig_cli --git https://github.com/gamedig/rust-gamedig.git
+```
+
+## Usage
+
+Running `gamedig_cli` without any arguments will display the usage information. You can also use the `--help` (or `-h`) flag to see detailed usage instructions.
+
+Here's also a quick rundown of a simple query with the `json-pretty` format:
+
+Pick a game/service/protocol (check
+the [GAMES](https://github.com/gamedig/rust-gamedig/blob/main/GAMES.md), [SERVICES](https://github.com/gamedig/rust-gamedig/blob/main/SERVICES.md)
+and [PROTOCOLS](https://github.com/gamedig/rust-gamedig/blob/main/PROTOCOLS.md) files to see the currently supported
+ones), provide the ip and the port (be aware that some game servers use a separate port for the info queries, the port
+can also be optional if the server is running the default ports) then query on it.
+
+[Team Fortress 2](https://store.steampowered.com/app/440/Team_Fortress_2/) query example:
+
+```sh
+gamedig_cli query -g teamfortress2 -i 127.0.0.1 -f json-pretty
+```
+
+What we are doing here:
+
+- `-g` (or `--game`) specifies the game.
+- `-i` (or `--ip`) target ip.
+- `-f` (or `--format`) our preferred format.
+
+Note: We haven't specified a port (via `-p` or `--port`), so the default one for the game will be used (`27015` in this
+case).
+
+Response (note that some games have a different structure):
+
+```json
+{
+ "name": "A cool server.",
+ "description": null,
+ "game_mode": "Team Fortress",
+ "game_version": "8690085",
+ "map": "cp_foundry",
+ "players_maximum": 24,
+ "players_online": 0,
+ "players_bots": 0,
+ "has_password": false,
+ "players": []
+}
+```
+
+## Contributing
+
+Please read [CONTRIBUTING](https://github.com/gamedig/rust-gamedig/blob/main/CONTRIBUTING.md).
diff --git a/crates/cli/src/error.rs b/crates/cli/src/error.rs
new file mode 100644
index 0000000..1700827
--- /dev/null
+++ b/crates/cli/src/error.rs
@@ -0,0 +1,31 @@
+pub type Result = std::result::Result;
+
+#[derive(thiserror::Error, Debug)]
+pub enum Error {
+ #[error("IO Error: {0}")]
+ Io(#[from] std::io::Error),
+
+ #[error("Clap Error: {0}")]
+ Clap(#[from] clap::Error),
+
+ #[error("Gamedig Error: {0}")]
+ Gamedig(#[from] gamedig::errors::GDError),
+
+ #[cfg(any(feature = "json", feature = "xml"))]
+ #[error("Serde Error: {0}")]
+ Serde(#[from] serde_json::Error),
+
+ #[cfg(feature = "bson")]
+ #[error("Bson Error: {0}")]
+ Bson(#[from] bson::ser::Error),
+
+ #[cfg(feature = "xml")]
+ #[error("Xml Error: {0}")]
+ Xml(#[from] quick_xml::Error),
+
+ #[error("Unknown Game: {0}")]
+ UnknownGame(String),
+
+ #[error("Invalid hostname: {0}")]
+ InvalidHostname(String),
+}
diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs
new file mode 100644
index 0000000..50bd5a8
--- /dev/null
+++ b/crates/cli/src/main.rs
@@ -0,0 +1,487 @@
+use std::net::{IpAddr, ToSocketAddrs};
+
+use clap::{Parser, Subcommand, ValueEnum};
+use gamedig::{
+ games::*,
+ protocols::types::{CommonResponse, ExtraRequestSettings, TimeoutSettings},
+};
+
+mod error;
+
+use self::error::{Error, Result};
+
+const GAMEDIG_HEADER: &str = r"
+
+ _____ _____ _ _____ _ _____
+ / ____| | __ \(_) / ____| | |_ _|
+| | __ __ _ _ __ ___ ___| | | |_ __ _ | | | | | |
+| | |_ |/ _` | '_ ` _ \ / _ \ | | | |/ _` | | | | | | |
+| |__| | (_| | | | | | | __/ |__| | | (_| | | |____| |____ _| |_
+ \_____|\__,_|_| |_| |_|\___|_____/|_|\__, | \_____|______|_____|
+ __/ |
+ |___/
+
+ A command line interface for querying game servers.
+ Copyright (C) 2022 - 2024 GameDig Organization & Contributors
+ Licensed under the MIT license
+";
+
+// NOTE: For some reason without setting long_about here the doc comment for
+// ExtraRequestSettings gets set as the about for the CLI.
+#[derive(Debug, Parser)]
+#[command(author, version, about = GAMEDIG_HEADER, long_about = None)]
+struct Cli {
+ #[command(subcommand)]
+ action: Action,
+}
+
+#[derive(Subcommand, Debug)]
+enum Action {
+ /// Query game server information
+ Query {
+ /// Unique identifier of the game for which server information is being
+ /// queried.
+ #[arg(short, long)]
+ game: String,
+
+ /// Hostname or IP address of the server.
+ #[arg(short, long)]
+ ip: String,
+
+ /// Optional query port number for the server. If not provided the
+ /// default port for the game is used.
+ #[arg(short, long)]
+ port: Option,
+
+ /// Specifies the output format
+ #[arg(short, long, default_value = "debug", value_enum)]
+ format: OutputFormat,
+
+ /// Which response variant to use when outputting
+ #[arg(short, long, default_value = "generic")]
+ output_mode: OutputMode,
+
+ /// Optional file path for packet capture file writer
+ ///
+ /// When set a PCAP file will be written to the location. This file can
+ /// be read with a tool like wireshark. The PCAP contains a log of the
+ /// TCP and UDP data sent/recieved by the gamedig library, it does not
+ /// contain an accurate representation of the real packets sent on the
+ /// wire as some information has to be hallucinated in order for it to
+ /// display nicely.
+ #[cfg(feature = "packet_capture")]
+ #[arg(short, long)]
+ capture: Option,
+
+ /// Optional timeout settings for the server query
+ #[command(flatten, next_help_heading = "Timeouts")]
+ timeout_settings: Option,
+
+ /// Optional extra settings for the server query
+ #[command(flatten, next_help_heading = "Query options")]
+ extra_options: Option,
+ },
+
+ /// Check out the source code
+ Source,
+ /// Display the MIT License information
+ License,
+}
+
+#[derive(Clone, Debug, PartialEq, Eq, ValueEnum)]
+enum OutputMode {
+ /// A generalised response that maps common fields from all game types to
+ /// the same name.
+ Generic,
+ /// The raw result returned from the protocol query, formatted similarly to
+ /// how the server returned it.
+ ProtocolSpecific,
+}
+
+#[derive(Clone, Debug, PartialEq, Eq, ValueEnum)]
+enum OutputFormat {
+ /// Human readable structured output
+ Debug,
+ /// RFC 8259
+ #[cfg(feature = "json")]
+ JsonPretty,
+ /// RFC 8259
+ #[cfg(feature = "json")]
+ Json,
+ /// Parser tries to be mostly XML 1.1 (RFC 7303) compliant
+ #[cfg(feature = "xml")]
+ Xml,
+ /// RFC 4648 section 8
+ #[cfg(feature = "bson")]
+ BsonHex,
+ /// RFC 4648 section 4
+ #[cfg(feature = "bson")]
+ BsonBase64,
+}
+
+/// Attempt to find a game from the [library game definitions](GAMES) based on
+/// its unique identifier.
+///
+/// # Arguments
+/// * `game_id` - A string slice containing the unique game identifier.
+///
+/// # Returns
+/// * Result<&'static [Game]> - On sucess returns a reference to the game
+/// definition; on failure returns a [Error::UnknownGame] error.
+fn find_game(game_id: &str) -> Result<&'static Game> {
+ // Attempt to retrieve the game from the predefined game list
+ GAMES
+ .get(game_id)
+ .ok_or_else(|| Error::UnknownGame(game_id.to_string()))
+}
+
+/// Resolve an IP address by either parsing an IP address or doing a DNS lookup.
+/// In the case of DNS lookup update extra request options with the hostname.
+///
+/// # Arguments
+/// * `host` - A string slice containing the IP address or hostname of a server
+/// to resolve.
+/// * `extra_options` - Mutable reference to extra options for the game query.
+///
+/// # Returns
+/// * `Result` - On sucess returns a resolved IP address; on failure
+/// returns an [Error::InvalidHostname] error.
+fn resolve_ip_or_domain>(host: T, extra_options: &mut Option) -> Result {
+ let host_str = host.as_ref();
+ if let Ok(parsed_ip) = host_str.parse() {
+ Ok(parsed_ip)
+ } else {
+ set_hostname_if_missing(host_str, extra_options);
+ resolve_domain(host_str)
+ }
+}
+
+/// Resolve a domain name to one of its IP addresses (the first one returned).
+///
+/// # Arguments
+/// * `domain` - A string slice containing the domain name to lookup.
+///
+/// # Returns
+/// * `Result` - On success, returns one of the resolved IP addresses;
+/// on failure returns an [Error::InvalidHostname] error.
+fn resolve_domain(domain: &str) -> Result {
+ // Append a dummy port to perform socket address resolution and then extract the
+ // IP
+ Ok(format!("{domain}:0")
+ .to_socket_addrs()
+ .map_err(|_| Error::InvalidHostname(domain.to_string()))?
+ .next()
+ .ok_or_else(|| Error::InvalidHostname(domain.to_string()))?
+ .ip())
+}
+
+/// Sets the hostname on extra request settings if it is not already set.
+///
+/// # Arguments
+/// * `host` - A string slice containing the hostname.
+/// * `extra_options` - A mutable reference to optional [ExtraRequestSettings].
+fn set_hostname_if_missing(host: &str, extra_options: &mut Option) {
+ if let Some(extra_options) = extra_options {
+ if extra_options.hostname.is_none() {
+ // If extra_options exists but hostname is None overwrite hostname in place
+ extra_options.hostname = Some(host.to_string())
+ }
+ } else {
+ // If extra_options is None create default settings with hostname
+ *extra_options = Some(ExtraRequestSettings::default().set_hostname(host.to_string()));
+ }
+}
+
+/// Output the result of a query to stdout.
+///
+/// # Arguments
+/// * `args` - A reference to the command line options.
+/// * `result` - A reference to the result of the query.
+fn output_result(output_mode: OutputMode, format: OutputFormat, result: &T) {
+ match format {
+ OutputFormat::Debug => {
+ match output_mode {
+ OutputMode::Generic => output_result_debug(result.as_json()),
+ OutputMode::ProtocolSpecific => output_result_debug(result.as_original()),
+ };
+ }
+ #[cfg(feature = "json")]
+ OutputFormat::JsonPretty => {
+ let _ = match output_mode {
+ OutputMode::Generic => output_result_json_pretty(result.as_json()),
+ OutputMode::ProtocolSpecific => output_result_json_pretty(result.as_original()),
+ };
+ }
+ #[cfg(feature = "json")]
+ OutputFormat::Json => {
+ let _ = match output_mode {
+ OutputMode::Generic => output_result_json(result.as_json()),
+ OutputMode::ProtocolSpecific => output_result_json(result.as_original()),
+ };
+ }
+ #[cfg(feature = "xml")]
+ OutputFormat::Xml => {
+ let _ = match output_mode {
+ OutputMode::Generic => output_result_xml(result.as_json()),
+ OutputMode::ProtocolSpecific => output_result_xml(result.as_original()),
+ };
+ }
+ #[cfg(feature = "bson")]
+ OutputFormat::BsonHex => {
+ let _ = match output_mode {
+ OutputMode::Generic => output_result_bson_hex(result.as_json()),
+ OutputMode::ProtocolSpecific => output_result_bson_hex(result.as_original()),
+ };
+ }
+ #[cfg(feature = "bson")]
+ OutputFormat::BsonBase64 => {
+ let _ = match output_mode {
+ OutputMode::Generic => output_result_bson_base64(result.as_json()),
+ OutputMode::ProtocolSpecific => output_result_bson_base64(result.as_original()),
+ };
+ }
+ }
+}
+
+/// Output the result using debug formatting.
+///
+/// # Arguments
+/// * `result` - A result that can be output using the debug formatter.
+fn output_result_debug(result: R) {
+ println!("{result:#?}");
+}
+
+/// Output the result as a JSON object.
+///
+/// # Arguments
+/// * `result` - A serde serializable result.
+#[cfg(feature = "json")]
+fn output_result_json(result: T) -> Result<()> {
+ println!("{}", serde_json::to_string(&result)?);
+
+ Ok(())
+}
+
+/// Output the result as a pretty printed JSON object.
+///
+/// # Arguments
+/// * `result` - A serde serializable result.
+#[cfg(feature = "json")]
+fn output_result_json_pretty(result: T) -> Result<()> {
+ println!("{}", serde_json::to_string_pretty(&result)?);
+
+ Ok(())
+}
+
+/// Output the result as an XML object.
+/// # Arguments
+/// * `result` - A serde serializable result.
+#[cfg(feature = "xml")]
+fn output_result_xml(result: T) -> Result<()> {
+ use quick_xml::{
+ events::{BytesDecl, BytesEnd, BytesStart, BytesText, Event},
+ Writer,
+ };
+ use serde_json::Value;
+
+ // Serialize the input `result` of generic type `T` into a JSON value.
+ // This step converts the Rust data structure into a JSON format,
+ // which will then be used to generate the corresponding XML.
+ let json = serde_json::to_value(result)?;
+
+ // Initialize the XML writer with a new, empty vector to store the XML data.
+ let mut writer = Writer::new(Vec::new());
+
+ // Write the XML 1.1 declaration
+ writer.write_event(Event::Decl(BytesDecl::new("1.1", Some("utf-8"), None)))?;
+
+ // Define a recursive function `json_to_xml` to convert the JSON value into XML
+ // format. The function takes a mutable reference to the XML writer, an
+ // optional key as a string slice, and a reference to the JSON value to be
+ // converted.
+ fn json_to_xml(writer: &mut Writer, key: Option<&str>, value: &Value) -> Result<()> {
+ match value {
+ // If the JSON value is an object, iterate through its properties,
+ // creating XML elements with corresponding keys and values.
+ Value::Object(obj) => {
+ if let Some(key) = key {
+ // Start an XML element for the object.
+ writer.write_event(Event::Start(BytesStart::new(key)))?;
+ }
+
+ for (k, v) in obj {
+ // Recursively process each property of the object.
+ json_to_xml(writer, Some(k), v)?;
+ }
+
+ if let Some(key) = key {
+ // Close the XML element for the object.
+ writer.write_event(Event::End(BytesEnd::new(key)))?;
+ }
+ }
+
+ // If the JSON value is an array, iterate through its elements,
+ // creating XML elements for each item.
+ Value::Array(arr) => {
+ for v in arr {
+ // Use "item" as the default key for array elements without keys.
+ json_to_xml(writer, key.or(Some("item")), v)?;
+ }
+ }
+
+ // If the JSON value is null, create an empty XML element.
+ Value::Null => {
+ if let Some(key) = key {
+ writer.write_event(Event::Empty(BytesStart::new(key)))?;
+ }
+ }
+
+ // For all other JSON value types (String, Number, Bool),
+ // convert the value to a string and create an XML element with the text content.
+ // Note: We handle null strings here as well, as they are treated as a string type.
+ _ => {
+ if let Some(key) = key {
+ // Start the XML element with the given key.
+ writer.write_event(Event::Start(BytesStart::new(key)))?;
+ }
+
+ // Convert the JSON value to a string, trimming quotes for non-string values.
+ let text_string = match value {
+ Value::String(s) => s.to_string(),
+ _ => value.to_string().trim_matches('"').to_string(),
+ };
+
+ // Create a text node with the converted string value.
+ writer.write_event(Event::Text(BytesText::new(&text_string)))?;
+
+ if let Some(key) = key {
+ // Close the XML element.
+ writer.write_event(Event::End(BytesEnd::new(key)))?;
+ }
+ }
+ }
+ Ok(())
+ }
+
+ // Start the root XML element named "data".
+ writer.write_event(Event::Start(BytesStart::new("data")))?;
+ // Convert the top-level JSON value to XML.
+ json_to_xml(&mut writer, None, &json)?;
+ // Close the root XML element.
+ writer.write_event(Event::End(BytesEnd::new("data")))?;
+
+ // Convert the XML data stored in the writer to a UTF-8 string.
+ let xml_bytes = writer.into_inner();
+ let xml_string = String::from_utf8(xml_bytes).expect("Failed to convert XML bytes to UTF-8 string");
+
+ println!("{xml_string}");
+
+ Ok(())
+}
+
+/// Output the result as a BSON object encoded as a hex string.
+///
+/// # Arguments
+/// * `result` - A serde serializable result.
+#[cfg(feature = "bson")]
+fn output_result_bson_hex(result: T) -> Result<()> {
+ let bson = bson::to_bson(&result)?;
+
+ if let bson::Bson::Document(document) = bson {
+ let bytes = bson::to_vec(&document)?;
+
+ println!("{}", hex::encode(bytes));
+
+ Ok(())
+ } else {
+ panic!("Failed to convert result to BSON Hex (BSON_DOCUMENT_UNAVAILABLE)");
+ }
+}
+
+/// Output the result as a BSON object encoded as a base64 string.
+///
+/// # Arguments
+/// * `result` - A serde serializable result.
+#[cfg(feature = "bson")]
+fn output_result_bson_base64(result: T) -> Result<()> {
+ use base64::Engine;
+
+ let bson = bson::to_bson(&result)?;
+
+ if let bson::Bson::Document(document) = bson {
+ let bytes = bson::to_vec(&document)?;
+
+ println!("{}", base64::prelude::BASE64_STANDARD.encode(bytes));
+
+ Ok(())
+ } else {
+ panic!("Failed to convert result to BSON Base64 (BSON_DOCUMENT_UNAVAILABLE)");
+ }
+}
+
+fn main() -> Result<()> {
+ let args = Cli::parse();
+
+ match args.action {
+ Action::Query {
+ game,
+ ip,
+ port,
+ format,
+ output_mode,
+ #[cfg(feature = "packet_capture")]
+ capture,
+ timeout_settings,
+ extra_options,
+ } => {
+ // Process the query command
+ let game = find_game(&game)?;
+ let mut extra_options = extra_options;
+ let ip = resolve_ip_or_domain(&ip, &mut extra_options)?;
+
+ #[cfg(feature = "packet_capture")]
+ gamedig::capture::setup_capture(capture);
+
+ let result = query_with_timeout_and_extra_settings(game, &ip, port, timeout_settings, extra_options)?;
+ output_result(output_mode, format, result.as_ref());
+ }
+ Action::Source => {
+ println!("{GAMEDIG_HEADER}");
+
+ #[cfg(feature = "browser")]
+ {
+ // Directly offering to open the URL
+ println!("\nWould you like to open the GitHub repository in your default browser? [Y/n]");
+
+ let mut choice = String::new();
+ std::io::stdin().read_line(&mut choice).unwrap();
+ if choice.trim().eq_ignore_ascii_case("Y") {
+ if webbrowser::open("https://github.com/gamedig/rust-gamedig").is_ok() {
+ println!("Opening GitHub repository in default browser...");
+ } else {
+ println!("Failed to open GitHub repository in default browser.");
+ println!("Please use the following URL: https://github.com/gamedig/rust-gamedig");
+ }
+ } else {
+ println!("Not to worry, you can always open the repository manually");
+ println!("by visiting the following URL: https://github.com/gamedig/rust-gamedig");
+ }
+ }
+
+ #[cfg(not(feature = "browser"))]
+ {
+ println!("\nYou can find the source code for this project at the following URL:");
+ println!("https://github.com/gamedig/rust-gamedig");
+ }
+
+ println!("\nBe sure to leave a star if you like the project :)");
+ }
+ Action::License => {
+ // Bake the license into the binary
+ // so we don't have to ship it separately
+ println!("{}", include_str!("../LICENSE.md"));
+ }
+ }
+
+ Ok(())
+}
diff --git a/crates/id-tests/Cargo.toml b/crates/id-tests/Cargo.toml
new file mode 100644
index 0000000..22b9a90
--- /dev/null
+++ b/crates/id-tests/Cargo.toml
@@ -0,0 +1,29 @@
+[package]
+name = "gamedig-id-tests"
+version = "0.0.1"
+edition = "2021"
+authors = [
+ "rust-GameDig contributors [https://github.com/gamedig/rust-gamedig/contributors]",
+ "node-GameDig contributors [https://github.com/gamedig/node-gamedig/contributors]",
+]
+license = "MIT"
+description = "Test if IDs match the gamedig rules"
+homepage = "https://github.com/gamedig/rust-gamedig/CONTRIBUTING.md#naming"
+repository = "https://github.com/gamedig/rust-gamedig"
+readme = "README.md"
+rust-version = "1.65.0"
+
+[features]
+cli = ["dep:serde_json", "dep:serde"]
+default = ["cli"]
+
+[[bin]]
+name = "gamedig-id-tests"
+required-features = ["cli"]
+
+[dependencies]
+number_to_words = "0.1"
+roman_numeral = "0.1"
+
+serde_json = { version = "1", optional = true }
+serde = { version = "1", optional = true, features = ["derive"] }
\ No newline at end of file
diff --git a/crates/id-tests/src/lib.rs b/crates/id-tests/src/lib.rs
new file mode 100644
index 0000000..f0fe155
--- /dev/null
+++ b/crates/id-tests/src/lib.rs
@@ -0,0 +1,451 @@
+use std::collections::HashMap;
+
+mod utils;
+use utils::{extract_bracketed_suffix, split_on_switch_between_alpha_numeric};
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum IDRule {
+ IDsMustBeLowerCase,
+ NumbersAreTheirOwnWord,
+ IfFirstWordNumberNoDigits,
+ IfLastWordNumberMustBeAppended,
+ ConvertRomanNumeralsToArabic,
+ TwoWordsOrLessUseFullWords,
+ MoreThanTwoWordsMakeAcronym,
+ IfIDDuplicateSameGameAppendYearToNewer,
+ IfIDDuplicateSameGameAppendProtocol,
+ IfIDDuplicateNoAcronym,
+ IfModForQueriesProcessOnlyModName,
+ NoDuplicates,
+}
+
+#[derive(Clone, Debug)]
+pub struct IDFail {
+ pub game_id: String,
+ pub game_name: String,
+ pub expected_id: String,
+ pub rule_stack: Vec,
+}
+
+impl IDFail {
+ fn new(game_id: String, game_name: String, expected_id: String, rule_stack: Vec) -> Self {
+ Self {
+ game_id,
+ game_name,
+ expected_id,
+ rule_stack,
+ }
+ }
+}
+
+/// Test a single game against the rules
+pub fn test_game_name_rule(
+ seen_ids: &mut HashMap>,
+ id: &str,
+ mut game: GameNameParsed,
+ is_mod_name: bool,
+) -> Vec {
+ let mut wrong_ids = Vec::new();
+
+ let mut rule_stack = Vec::new();
+ if is_mod_name {
+ rule_stack.push(IDRule::IfModForQueriesProcessOnlyModName);
+ }
+
+ let mut suffix = String::new();
+
+ // A game's identification is a lowercase alphanumeric string will and be forged
+ // following these rules:
+ if id.to_lowercase().ne(id) {
+ wrong_ids.push(IDFail::new(
+ id.to_owned(),
+ game.name.to_owned(),
+ id.to_lowercase(),
+ vec![IDRule::IDsMustBeLowerCase],
+ ));
+ }
+
+ // 5. Roman numbering will be converted to arabic numbering (XIV -> 14).
+ game.words = {
+ let mut is_first = true;
+ game.words
+ .into_iter()
+ .map(|w| {
+ // First word will never be a numeral
+ if is_first {
+ is_first = false;
+ w
+ } else if let Ok(number) = roman_numeral::RomanNumeral::from_string(&w) {
+ rule_stack.push(IDRule::ConvertRomanNumeralsToArabic);
+ number.get_u32().to_string()
+ } else {
+ w
+ }
+ })
+ .collect()
+ };
+
+ // 6. Unless numbers are at the end of a name, they will be considered words,
+ // but digits will always be used instead of the acronym (counter to #2)
+ // (Left 4 Dead -> l4d) unless they at the start position (7 Days to Die ->
+ // sdtd), if they are at the end (such as sequel number or the year), always
+ // append them (Team Fortress 2 -> teamfortress2, Unreal Tournament 2003 ->
+ // unrealtournament2003).
+ game.words = game
+ .words
+ .into_iter()
+ .flat_map(|w| {
+ let n = split_on_switch_between_alpha_numeric(&w);
+ if n.len() > 1 {
+ rule_stack.push(IDRule::NumbersAreTheirOwnWord);
+ }
+ n
+ })
+ .collect();
+
+ // If first word is number make text
+ if !game.words.is_empty() && game.words[0].chars().next().unwrap().is_ascii_digit() {
+ game.words[0] = number_to_words::number_to_words(game.words[0].parse::().unwrap(), false);
+ rule_stack.push(IDRule::IfFirstWordNumberNoDigits);
+ }
+
+ // If last word is number append full number
+ if let Some(last_word) = game.words.last() {
+ if last_word.chars().all(|c| c.is_ascii_digit()) {
+ suffix += &game.words.pop().unwrap();
+ rule_stack.push(IDRule::IfLastWordNumberMustBeAppended);
+ }
+ }
+
+ let main = if game.words.len() <= 2 {
+ // 1. Names composed of a maximum of two words (unless #4 applies) will result
+ // in an id where the words are concatenated (Dead Cells -> deadcells),
+ // acronyms in the name count as a single word (S.T.A.L.K.E.R. -> stalker).
+
+ rule_stack.push(IDRule::TwoWordsOrLessUseFullWords);
+
+ game.words
+ .iter()
+ .map(|w| w.trim_matches('-').to_owned())
+ .collect::>()
+ .join("")
+ } else {
+ // 2. Names of more than two words shall be made into an acronym made of the
+ // initial letters (The Binding of Isaac -> tboi), hypenation composed words
+ // don't count as a single word, but of how many parts they are made of (Dino
+ // D-Day, 3 words, so ddd).
+
+ rule_stack.push(IDRule::MoreThanTwoWordsMakeAcronym);
+
+ game.words
+ .iter()
+ .map(|w| w.chars().next().unwrap())
+ .filter(|c| c.is_alphanumeric())
+ .collect()
+ };
+
+ let mut expected_id = format!("{main}{suffix}").to_lowercase();
+
+ if let Some(other_game_name_words) = seen_ids.get(&expected_id) {
+ let mut game_names_same = other_game_name_words.len() == game.words.len();
+ // Check all words in game name are the same
+ if game_names_same {
+ for (our_word, their_word) in game.words.iter().zip(other_game_name_words.iter()) {
+ if our_word.to_lowercase() != their_word.to_lowercase() {
+ game_names_same = false;
+ break;
+ }
+ }
+ }
+
+ if game_names_same {
+ if let Some(year) = game.year {
+ // 3. If a game has the exact name as a previously existing id's game (Star Wars
+ // Battlefront 2, the 2005 and 2017 one), append the release year to the
+ // newer id (2005 would be swbf2 (suppose we already have this one supported)
+ // and 2017 would be swbf22017).
+
+ rule_stack.push(IDRule::IfIDDuplicateSameGameAppendYearToNewer);
+ expected_id = format!("{expected_id}{year}").to_lowercase();
+ } else if let Some(protocol) = game.optional_parts.first() {
+ // 7. If a game supports multiple protocols, multiple entries will be done for
+ // said game where the edition/protocol name (first disposable in this order)
+ // will be appended to the game name (Minecraft is divided by 2 editions,
+ // Java and Bedrock which will be minecraftjava and minecraftbedrock
+ // respectively) and one more entry can be added by the base name of the game
+ // which queries in a group said supported protocols to make generic queries
+ // easier and disposable.
+
+ rule_stack.push(IDRule::IfIDDuplicateSameGameAppendProtocol);
+
+ // Parse the protocol as a game name so we can remove all non-valid characters
+ let protocol_parsed = extract_game_parts_from_name(protocol);
+
+ expected_id = format!("{}{}", expected_id, protocol_parsed.words.concat(),);
+ }
+ }
+ }
+
+ // 4. If a new id (Day of Dragons -> dod) results in an id that already exists
+ // (Day of Defeat -> dod), then the new name should ignore rule #2 (Day of
+ // Dragons -> dayofdragons).
+ if seen_ids.contains_key(&expected_id) {
+ rule_stack.push(IDRule::IfIDDuplicateNoAcronym);
+
+ let main = game
+ .words
+ .iter()
+ .map(|w| w.trim_matches('-').to_owned())
+ .collect::>()
+ .join("");
+
+ expected_id = format!("{main}{suffix}").to_lowercase();
+ }
+
+ // 8. If its actually about a mod that adds the ability for queries to be
+ // performed, process only the mod name.
+ if !is_mod_name && id != expected_id {
+ if let Some((_, mod_game)) = game.name.split_once('-') {
+ let mut result = test_game_name_rule(seen_ids, id, extract_game_parts_from_name(mod_game), true);
+
+ if result.is_empty() {
+ return result;
+ } else {
+ wrong_ids.append(&mut result);
+ }
+ }
+ }
+
+ let duplicate = if seen_ids.insert(expected_id.clone(), game.words).is_some() {
+ rule_stack.push(IDRule::NoDuplicates);
+ true
+ } else {
+ false
+ };
+
+ // Check ID matches
+ if id != expected_id || duplicate {
+ wrong_ids.push(IDFail::new(
+ id.to_owned(),
+ game.name.to_owned(),
+ expected_id,
+ rule_stack,
+ ));
+ }
+
+ wrong_ids
+}
+
+#[derive(Clone, Debug)]
+pub struct GameNameParsed<'a> {
+ name: &'a str,
+ words: Vec,
+ optional_parts: Vec<&'a str>,
+ year: Option,
+}
+
+pub fn extract_game_parts_from_name(game: &str) -> GameNameParsed<'_> {
+ // Separate game name into words
+ // NOTE: we have to leave "-" in to prevent hyphenated prefixes being parsed as
+ // numerals
+ let mut optional_game_name_parts = Vec::new();
+
+ let (game, paren) = extract_bracketed_suffix(game);
+
+ if let Some(paren) = paren {
+ optional_game_name_parts.push(paren);
+ }
+
+ let mut number_accumulator: Option = None;
+
+ // Filter map necessary to move out words
+ #[allow(clippy::unnecessary_filter_map)]
+ let game_name_words: Vec<_> = game
+ // First split all text on space or dash
+ .split_inclusive(&[' ', '-'])
+ // Remove whitespace surrounding words (leave in dash because it is important information)
+ .map(str::trim)
+ // If a word is entirely surrounded in brackets move it to optional parts
+ .filter_map(|w| {
+ if w.starts_with('(') && w.ends_with(')') {
+ optional_game_name_parts.push(w);
+ None
+ } else {
+ Some(w)
+ }
+ })
+ // Remove all characters that aren't alphanumeric or dashses
+ .map(|w| {
+ w.replace(
+ |c: char| !c.is_ascii_digit() && !c.is_alphabetic() && c != '-',
+ "",
+ )
+ })
+ // Remove words that are empty (discounting strings that are just dashes)
+ .filter(|w| !w.trim_matches('-').is_empty())
+ // Combine numbers that are seperated by dashes
+ // e.g. 44-45 = 4445
+ // Panics if there is text after number with trailing dash (44-text)
+ .filter_map(|w| {
+ if number_accumulator.is_some() {
+ if let Some(maybe_number) = w.strip_suffix('-') {
+ if maybe_number.chars().all(|c| c.is_ascii_digit()) {
+ number_accumulator.as_mut().unwrap().push_str(maybe_number);
+ return None;
+ } else {
+ panic!("Text after number-");
+ }
+ } else if w.chars().all(|c| c.is_ascii_digit()) {
+ let mut accumulator = number_accumulator.as_ref().unwrap().clone();
+ number_accumulator = None;
+ accumulator.push_str(&w);
+ return Some(accumulator);
+ } else {
+ panic!("Text after number-");
+ }
+ } else if let Some(maybe_number) = w.strip_suffix('-') {
+ if maybe_number.chars().all(|c| c.is_ascii_digit()) {
+ number_accumulator = Some(maybe_number.to_string());
+ return None;
+ }
+ }
+
+ Some(w)
+ })
+ .collect();
+
+ let mut game_year: Option = None;
+ for optional_part in &optional_game_name_parts {
+ if let Some(game_year_text) = optional_part
+ .strip_prefix('(')
+ .and_then(|s| s.strip_suffix(')'))
+ {
+ if let Ok(year) = game_year_text.parse() {
+ game_year = Some(year);
+ break;
+ }
+ } else if let Ok(year) = optional_part.parse() {
+ game_year = Some(year);
+ break;
+ }
+ }
+
+ GameNameParsed {
+ name: game,
+ words: game_name_words,
+ optional_parts: optional_game_name_parts,
+ year: game_year,
+ }
+}
+
+/// Iterate game entries and validate the id matches current rules
+pub fn test_game_name_rules<'a, I: Iterator>(games: I) -> Vec {
+ let mut wrong_ids = Vec::with_capacity(games.size_hint().0);
+
+ let mut seen_ids: HashMap> = HashMap::new();
+
+ // We must sort games by year so that rule 3 is applied correctly
+ let mut sorted_games: Vec<_> = games
+ .map(|(id, game)| {
+ let game = extract_game_parts_from_name(game);
+
+ (id, game)
+ })
+ .collect();
+
+ sorted_games.sort_by(|(_, a_game), (_, b_game)| {
+ a_game
+ .year
+ .cmp(&b_game.year)
+ .then(a_game.name.len().cmp(&b_game.name.len()))
+ });
+
+ let game_count = sorted_games.len();
+
+ for (id, game) in sorted_games {
+ wrong_ids.append(&mut test_game_name_rule(&mut seen_ids, id, game, false))
+ }
+
+ if !wrong_ids.is_empty() {
+ for fail in &wrong_ids {
+ println!("{fail:#?}");
+ }
+ let percentage = (wrong_ids.len() * 100) / game_count;
+ println!(
+ "{} ({}%) IDs didn't match naming rules",
+ wrong_ids.len(),
+ percentage
+ );
+ }
+
+ wrong_ids
+}
+
+pub fn test_single_game_rule(id: &str, name: &str) -> Vec { test_game_name_rules(std::iter::once((id, name))) }
+
+#[cfg(test)]
+mod id_tests {
+ use super::{test_game_name_rules, test_single_game_rule};
+ #[test]
+ fn id_rule_one() {
+ assert!(test_single_game_rule("testgame", "Test Game").is_empty());
+ assert!(test_single_game_rule("testgame", "TestGame").is_empty());
+
+ assert!(test_single_game_rule("deadcells", "Dead Cells").is_empty());
+ assert!(test_single_game_rule("stalker", "S.T.A.L.K.E.R").is_empty());
+ }
+
+ #[test]
+ fn id_rule_two() {
+ assert!(test_single_game_rule("tgt", "Test Game Three").is_empty());
+ assert!(test_single_game_rule("tgt", "Test Game-Three").is_empty());
+
+ assert!(test_single_game_rule("tboi", "The Binding of Isaac").is_empty());
+ assert!(test_single_game_rule("ddd", "Dino D-Day").is_empty());
+ }
+
+ #[test]
+ fn id_rule_three() {
+ let games = vec![
+ ("swb22017", "Star Wars Battlefront 2 (2017)"),
+ ("swb2", "Star Wars Battlefront 2 (2015)"),
+ ];
+ assert!(test_game_name_rules(games.into_iter()).is_empty());
+ }
+
+ #[test]
+ fn id_rule_four() {
+ let games = vec![("dod", "Day of Defeat"), ("dayofdragons", "Day of Dragons")];
+ assert!(test_game_name_rules(games.into_iter()).is_empty());
+ }
+
+ #[test]
+ fn id_rule_five() {
+ assert!(test_single_game_rule("gta14", "Grand Theft Auto XIV").is_empty());
+ }
+
+ #[test]
+ fn id_rule_six() {
+ assert!(test_single_game_rule("l4d", "Left 4 Dead").is_empty());
+ assert!(test_single_game_rule("sdtd", "7 Days to Die").is_empty());
+ assert!(test_single_game_rule("teamfortress2", "Team Fortress 2").is_empty());
+ assert!(test_single_game_rule("unrealtournament2003", "Unreal Tournament 2003").is_empty());
+ assert!(test_single_game_rule("dhe4445", "Darkest Hour: Europe '44-'45").is_empty());
+ }
+
+ #[test]
+ fn id_rule_seven() {
+ let games = vec![
+ ("minecraft", "Minecraft"),
+ ("minecraftjava", "Minecraft (java)"),
+ ("minecraftbedrock", "Minecraft (bedrock)"),
+ ];
+ assert!(test_game_name_rules(games.into_iter()).is_empty());
+ }
+
+ #[test]
+ fn id_rule_eight() {
+ assert!(test_single_game_rule("fivem", "Grand Theft Auto V - FiveM (2013)").is_empty());
+ assert!(test_single_game_rule("jc3m", "Just Cause 3 - Multiplayer").is_empty());
+ }
+}
diff --git a/crates/id-tests/src/main.rs b/crates/id-tests/src/main.rs
new file mode 100644
index 0000000..e01b3bf
--- /dev/null
+++ b/crates/id-tests/src/main.rs
@@ -0,0 +1,32 @@
+#![cfg(feature = "cli")]
+
+use std::collections::HashMap;
+
+/// Format for input games (the same as used in node-gamedig/lib/games.js).
+type GamesInput = HashMap;
+
+#[derive(Debug, Clone, PartialEq, serde::Deserialize)]
+struct Game {
+ name: String,
+}
+
+use gamedig_id_tests::test_game_name_rules;
+
+fn main() {
+ let games: GamesInput = std::env::args_os().nth(1).map_or_else(
+ || serde_json::from_reader(std::io::stdin().lock()).unwrap(),
+ |file| {
+ let file = std::fs::OpenOptions::new().read(true).open(file).unwrap();
+
+ serde_json::from_reader(file).unwrap()
+ },
+ );
+
+ let failed = test_game_name_rules(
+ games
+ .iter()
+ .map(|(key, game)| (key.as_str(), game.name.as_str())),
+ );
+
+ assert!(failed.is_empty());
+}
diff --git a/crates/id-tests/src/utils.rs b/crates/id-tests/src/utils.rs
new file mode 100644
index 0000000..71dfe71
--- /dev/null
+++ b/crates/id-tests/src/utils.rs
@@ -0,0 +1,66 @@
+/// Split a str when characters swap between being digits and not digits.
+pub fn split_on_switch_between_alpha_numeric(text: &str) -> Vec {
+ if text.is_empty() {
+ return vec![];
+ }
+
+ let mut parts = Vec::with_capacity(text.len());
+ let mut current = Vec::with_capacity(text.len());
+
+ let mut iter = text.chars();
+ let c = iter.next().unwrap();
+ let mut last_was_numeric = c.is_ascii_digit();
+ current.push(c);
+
+ for c in iter {
+ if c.is_ascii_digit() == last_was_numeric {
+ current.push(c);
+ } else {
+ parts.push(current.iter().collect());
+ current.clear();
+ current.push(c);
+ last_was_numeric = !last_was_numeric;
+ }
+ }
+
+ parts.push(current.into_iter().collect());
+
+ parts
+}
+
+#[test]
+fn split_correctly() {
+ assert_eq!(
+ split_on_switch_between_alpha_numeric("2D45A"),
+ &["2", "D", "45", "A"]
+ );
+}
+
+#[test]
+fn split_symbol_broken_numbers() {
+ let game_name = super::extract_game_parts_from_name("Darkest Hour: Europe '44-'45");
+ assert_eq!(game_name.words, &["Darkest", "Hour", "Europe", "4445"]);
+}
+
+/// Extract parts at end of string enclosed in brackets.
+pub fn extract_bracketed_suffix(text: &str) -> (&str, Option<&str>) {
+ if let Some(text) = text.strip_suffix(')') {
+ if let Some((text, extra)) = text.rsplit_once('(') {
+ return (text, Some(extra));
+ }
+ }
+
+ (text, None)
+}
+
+#[test]
+fn extract_brackets_correctly() {
+ assert_eq!(
+ extract_bracketed_suffix("no brackets here"),
+ ("no brackets here", None)
+ );
+ assert_eq!(
+ extract_bracketed_suffix("Game name (with protocol here)"),
+ ("Game name ", Some("with protocol here"))
+ );
+}
diff --git a/crates/lib/CHANGELOG.md b/crates/lib/CHANGELOG.md
new file mode 100644
index 0000000..e63f600
--- /dev/null
+++ b/crates/lib/CHANGELOG.md
@@ -0,0 +1,674 @@
+Who knows what the future holds...
+
+# X.Y.Z - DD/MM/YYYY
+
+# 0.9.0 22/02/2026
+
+Breaking:
+
+- MSRV is now `1.85.1` (was `1.82.0`), this is due to deps we rely on requiring a higher version
+
+Games:
+
+- Fixed minecraft java server side EncoderException error on query (by @paul-hansen).
+
+Crate:
+
+- Some minor clippy fixes
+- Updated some dependencies
+
+# 0.8.0 24/08/2025
+
+Breaking:
+
+- MSRV is now `1.82.0` (was `1.81.0`), this is due to deps we rely on requiring a higher version
+
+Crate:
+
+- Some minor clippy fixes
+- Updated dependencies
+
+# 0.7.0 - 23/04/2025
+
+Breaking:
+
+- MSRV is now `1.81.0` (was `1.71.1`), this is due to deps we rely on requiring a higher version
+
+Games:
+
+- Added `Arma 3` support (by @Perondas).
+
+Crate:
+
+- Some minor clippy fixes
+
+# 0.6.1 - 05/12/2024
+
+Games:
+
+- Added `Starbound` support (by @Novaenia).
+
+Protocols:
+
+- Fixed enum cast error on valve when parsing uppercase envrionment and server type fields (by @Novaenia).
+
+# 0.6.0 - 26/11/2024
+
+Breaking:
+
+- MSRV is now `1.71.1` (was `1.65.0`), this is due to deps we rely on requiring a higher version on linux builds (`1.65.0` is 2+ years old).
+
+Games:
+
+- [Minetest](https://www.minetest.net/) support (available on the `tls`, `serde` and `services` features) (#218 by
+ @CosminPerRam).
+- Fixed the forest game failing when host has the client steam id (#232 by @paul-hansen).
+
+# 0.5.2 - 20/10/2024
+
+Games:
+
+- [Soulmask](https://store.steampowered.com/app/2646460/Soulmask/) support (by @CosminPerRam).
+
+Protocols:
+
+- Fixed Epic (EOS) protocol to match ports on query (by @cainthebest).
+
+Services:
+
+- MineTest Master Server support (available only on the `tls` and `serde` feature) (by @CosminPerRam).
+
+Crate:
+
+- Performance improvements from clippy suggestions (by @CosminPerRam).
+- Feature gate some variables so that they are not unused (by @cainthebest).
+- Fixed a OOB panic that could occur when reading strings from the buffer (by @cainthebest).
+- Updated `pnet_packet` from `0.34.0` to `0.35.0`.
+
+# 0.5.1 - 12/05/2024
+
+Games:
+
+- [Mordhau](https://store.steampowered.com/app/629760/MORDHAU/) support.
+- [Enshrouded](https://store.steampowered.com/app/1203620/Enshrouded/) support.
+- [Myth of Empires](https://store.steampowered.com/app/1371580/Myth_of_Empires/) support.
+- [Pirates, Vikings, and Knights II](https://store.steampowered.com/app/17570/Pirates_Vikings_and_Knights_II/) support.
+- [PixARK](https://store.steampowered.com/app/593600/PixARK/) support.
+- [Ark: Survival Ascended](https://store.steampowered.com/app/2399830/ARK_Survival_Ascended/) support, note: not yet in
+ the games definitions.
+- [Aliens vs. Predator 2010](https://store.steampowered.com/app/10680/Aliens_vs_Predator/) support.
+- [Arma Reforger](https://store.steampowered.com/app/1874880/Arma_Reforger/) support.
+- [Nova-Life: Amboise](https://store.steampowered.com/app/885570/NovaLife_Amboise/) support.
+- [Abiotic Factor](https://store.steampowered.com/app/427410/Abiotic_Factor/) support.
+
+Protocols:
+
+- Epic (EOS) support, available only on the `tls` feature.
+
+Crate:
+
+- Updated some dependencies: `crc32fast` to `1.4.0`, `clap` to `4.5.4` and `ureq` to `ureq`.
+
+# 0.5.0 - 15/03/2024
+
+### Changes:
+
+Games:
+
+- [Valheim](https://store.steampowered.com/app/892970/Valheim/) support.
+- [The Front](https://store.steampowered.com/app/2285150/The_Front/) support.
+- [Conan Exiles](https://store.steampowered.com/app/440900/Conan_Exiles/) support.
+- [Post Scriptum](https://store.steampowered.com/app/736220/Post_Scriptum/) support.
+- [Squad](https://store.steampowered.com/app/393380/Squad/) support.
+- [Savage 2](https://savage2.net/) support.
+- [Rising World](https://store.steampowered.com/app/324080/Rising_World/) support.
+- [ATLAS](https://store.steampowered.com/app/834910/ATLAS/) support.
+- [America's Army: Proving Grounds](https://store.steampowered.com/app/203290/Americas_Army_Proving_Grounds/) support.
+- [Base Defense](https://store.steampowered.com/app/632730/Base_Defense/) support.
+- [Zombie Panic: Source](https://store.steampowered.com/app/17500/Zombie_Panic_Source/) support.
+- Added a valve protocol query example.
+- Made all of Just Cause 2: Multiplayer Response and Player fields public.
+- [Mindustry](https://mindustrygame.github.io/) support.
+- [Eco](https://store.steampowered.com/app/382310/Eco/) support.
+- [Call Of Duty: Black Ops 3](https://store.steampowered.com/agecheck/app/311210/) support.
+- [Counter-Strike 2](https://store.steampowered.com/app/730/CounterStrike_2/) support.
+- [Double Action: Boogaloo](https://store.steampowered.com/app/317360/Double_Action_Boogaloo/) support.
+
+Crate:
+
+- Changed the serde feature to only enable serde derive for some types: serde and serde_json is now a dependecy by
+ default.
+
+Protocols:
+
+- Added the unreal2 protocol and its associated games: Darkest Hour, Devastation, Killing Floor, Red Orchestra, Unreal
+ Tournament 2003, Unreal Tournament 2004 (by @Douile).
+- Added HTTPClient to allow use of HTTP(S) (and JSON) APIs (by @CosminPerRam & @Douile).
+
+Crate:
+
+- Added a `packet_capture` feature to capture the raw packets sent and received by the socket (by @Douile).
+- Added packet emulation and socket retrevial using the `packet_capture` feature (by @Douile).
+- Added PCAP writing support to the `packet_capture` feature (by @Douile & @cainthebest).
+- Refactored socket to use a custom implementation of socket for packet capture when using the `packet_capture`
+ feature (by @Douile).
+
+CLI:
+
+- Added a CLI (by @cainthebest).
+- Added DNS lookup support (by @Douile).
+- Added JSON output option (by @Douile).
+- Added BSON output in hex or base64 (by @cainthebest).
+- Added XML output option (by @cainthebest).
+- Added ExtraRequestSettings as CLI arguments (by @Douile).
+- Added TimeoutSettings as CLI argument (by @Douile).
+- Added Comprehensive end-user documentation for the CLI interface (by @Douile & @cainthebest).
+- Tweaked compile-time flags to allow for a more preformant binary (by @cainthebest).
+- Added client for socket capture, dev tools are not included by default (by @Douile).
+- Added license information to the CLI (by @cainthebest).
+- Added source code information to the CLI (by @cainthebest).
+
+### Breaking:
+
+Game:
+
+- Changed identifications of the following games as they weren't properly expecting the naming rules:
+-
+ - Left 4 Dead: `left4dead` -> `l4d`.
+-
+ - 7 Days to Die: `7d2d` in definitions and `sd2d` in game declaration -> `sdtd`.
+-
+ - Quake 3 Arena: `quake3arena` -> `q3a`.
+-
+ - Unreal tournament 2003: `ut2003` -> `unrealtournament2003`
+-
+ - Unreal tournament 2004: `ut2004` -> `unrealtournament2004`
+-
+ - Darkest Hour: Europe '44-'45: `darkesthour` -> `dhe4445`
+- Minecraft:
+-
+ - Legacy 1.5 and 1.3 were renamed to 1.4 and beta 1.8 respectively to show the lowest version they support, this
+ change includes Structs, Enum and game id renames, also removed the "v" from the game definition name.
+-
+ - Moved the Minecraft protocol implementation in the games folder as its proprietary.
+
+Protocols:
+
+- Valve: Removed `SteamApp` due to it not being really useful at all, replaced all instances with `Engine`.
+
+Query:
+
+- Added a connection timeout to TimeoutSettings (at the moment this only applies to TCP)
+ - Sockets are now expected to apply timeout settings in new()
+
+# 0.4.1 - 13/10/2023
+
+### Changes:
+
+Game:
+
+- Added [Barotrauma](https://store.steampowered.com/app/602960/Barotrauma/) support.
+
+Crate:
+
+- Added `Send` and `Sync` on `Error::source` to fix some async issues.
+
+Protocols:
+
+- Minecraft Java: Add derives to `RequestSettings` and add `new_just_hostname` that creates new settings just by
+ specifying
+ the hostname, `protocol_version` defaults to -1.
+
+Games:
+
+- Organised game modules into protocols (when protocol used by other games),
+ you can now access a game by its name or by its protocol name:
+ - `use gamedig::games::teamfortress2;`
+ - `use gamedig::games::valve::teamfortress2;`
+
+Generics:
+
+- Added standard derives to `ProprietaryProtocol`, `CommonResponseJson`, `CommonPlayerJson`, `TimeoutSettings` and
+ `ExtraRequestSettings`.
+
+### Breaking...
+
+None, yaay!
+
+# 0.4.0 - 07/10/2023
+
+### Changes:
+
+Games:
+
+- [Creativerse](https://store.steampowered.com/app/280790/Creativerse/) support.
+
+Protocols:
+
+- Quake 2: Fixed a bug where the version tag wouldn't always be present.
+- The Ship: Removed instances of using `unwrap` without handling the panics.
+
+Crate:
+
+- Updated [byteorder](https://crates.io/crates/byteorder) dependency from 1.4 to 1.5.
+- Rich errors, capturing backtrace is done on `RUST_BACKTRACE=1`. (by @Douile)
+- Applied some nursery Clippy lints.
+- The `retries` field was added to `TimeoutSettings` that specifies the number of times to retry a failed request (
+ request being individual send, receive sequence, some protocols can include multiple requests in a single query). (by
+ @Douile)
+ - By default `retries` is set to `0`, meaning no retries will be attempted
+
+Generics:
+
+- Added `ExtraRequestSettings` containing all possible extra request settings. (by @Douile)
+- Added `query_with_timeout_and_extra_settings()` to allow generic queries with extra settings. (by @Douile)
+
+### Breaking...
+
+Crate:
+
+- The enum used for errors, `GDError` has been renamed to `GDErrorKind`.
+- `GDError` is now a struct that holds its kind, the source and a backtrace.
+- The `Socket::apply_timeout` method now borrows `TimeoutSettings` (`&Option`)
+ - To make this easier to work with a new method was added
+ to `TimeoutSettings`: `TimeoutSettings::get_read_and_write_or_defaults` this takes a borrowed
+ optional `TimeoutSettings` and returns the contained read and write durations or the default read and write
+ durations.
+
+Generics:
+
+- Renamed `CommonResponseJson`'s `game` field (and the function) to `game_mode`.
+- Changed `players_maximum` and `players_online` (and their functions) types from `u64` to `u32`.
+- Changed `score` type (and the function) of player from `u32` to `i32`.
+
+Games:
+
+- Rename some game definitions and implementations to follow a stable ID naming system.
+
+Protocols:
+
+- Valve:
+
+1. Renamed `protocol` to `protocol_version`.
+2. Renamed `version` to `game_version`.
+3. Renamed `game` to `game_mode`.
+4. Fixed `player`'s `score` field being `u32` when it needed to be `i32`, as specified in the protocol.
+5. Added the field `check_app_id` to `GatherSettings` which controls if the app id specified to the request and
+ reported by the server are the same, errors if not, enabled by default. (by @Douile)
+6. Valve: Renamed SteamApp enum variants to match new definition names
+
+- GameSpy (1, 2, 3):
+
+1. Renamed `version` to `game_version`.
+2. Changed `players_maximum` and `players_online` (and their functions) types from `usize` to `u32`.
+
+- GameSpy 1:
+
+1. Renamed the player's `frags` to `score` and type from `u32` to `i32`.
+2. Made `Option` the following response fields `team`, `face`, `skin`, `mesh` and `secret` to fix missing fields
+ issues. (by @Douile)
+
+- Quake (1, 2):
+
+1. Renamed `game_type` to `game_mode`.
+2. Changed `version` type from `String`to `Option`.
+
+- Minecraft Java
+
+1. Renamed `version_protocol` to `protocol_version`.
+2. Renamed `version_name` to `game_version`.
+3. Renamed `players_sample` to `players`.
+4. Added an optional parameter, `RequestSettings`, which contains fields that are used when creating the handshake
+ packet (this solves some servers not responding to the query). (by @Douile)
+5. Legacy versions naming has been changed to represent up to what version they can query, `LegacyBV1_8` (Beta 1.8 to
+ 1.3) -> `LegacyV1_3` and `LegacyV1_4` (1.4 to 1.5) -> `LegacyV1_5` (and their enums accordingly).
+
+- Minecraft Bedrock
+
+1. Renamed `version_protocol` to `protocol_version`.
+
+- Minecraft:
+
+1. Added an optional parameter, `request_settings` parameter to `query`.
+
+- The Ship:
+
+1. Renamed `protocol` to `protocol_version`.
+2. Renamed `max_players` to `players_maximum` and changed its type from `u64` to `u32`.
+3. Renamed `bots` to `players_bots`. and changed its type from `u64` to `u32`.
+4. Renamed `players` to `players_online`.
+5. Renamed `players_details` to `players`.
+6. Renamed `game` to `game_mode`.
+7. Added field `game_version`.
+8. Changed `players_bots` type from `Option` to `Option`.
+9. Changed `score` type of player from `u32` to `i32`.
+
+- Frontlines: Fuel of War:
+
+1. Renamed `game_mode` to `game`.
+2. Renamed `version` to `game_version`.
+3. Renamed `protocol` to `protocol_version`.
+4. Renamed `game` to `game_mode`.
+5. Changed `players_maximum` and `players_minimum` types from `usize` to `u32`.
+
+- Just Cause 2: Multiplayer:
+
+1. Renamed `version` to `game_version`.
+2. Changed `players_maximum` and `players_minimum` types from `usize` to `u32`.
+
+# 0.3.0 - 18/07/2023
+
+### Changes:
+
+Protocols:
+
+- GameSpy 2 support.
+- Quake 2: Added Optional address field to Player.
+
+Generic query:
+
+- Added generic queries (by [@Douile](https://github.com/Douile)) which come with a common struct for the response
+ fields.
+- The supported games list is available programmatically.
+
+Games:
+
+- [Halo: Combat Evolved](https://en.wikipedia.org/wiki/Halo:_Combat_Evolved) support.
+- [Just Cause 2: Multiplayer](https://store.steampowered.com/app/259080/Just_Cause_2_Multiplayer_Mod/) support.
+- [Warsow](https://warsow.net/) support.
+
+Internal:
+
+- Buffer reader rewrite, resulting in more data checks and better code quality (
+ thanks [@cainthebest](https://github.com/cainthebest)).
+- Better CI to never break accidentally MSRV again (thanks [@Douile](https://github.com/Douile)).
+
+### Breaking...
+
+Protocols:
+
+- Quake 2: Renamed the players `frags` field to `score` to be more inline with the other protocols.
+
+Crate:
+
+- `no_games` and `no_services` have been changed to `games` and `services`, this better represents that they are present
+ by default (by [@Douile](https://github.com/Douile)).
+- Fixed crate's `rust-version`, it is now `1.60.0` (was `1.56.1`)
+
+# 0.2.3 - 02/06/2023
+
+### Changes:
+
+Protocols:
+
+- Valve:
+
+1. Added standard and serde derives to `GatheringSettings`.
+
+- Quake 1, 2 and 3 support.
+
+Games:
+
+- [Quake 2](https://store.steampowered.com/app/2320/Quake_II/) support.
+- [Quake 1](https://store.steampowered.com/app/2310/Quake/) support.
+- [Quake 3: Arena](https://store.steampowered.com/app/2200/Quake_III_Arena/) support.
+- [Hell Let Loose](https://store.steampowered.com/app/686810/Hell_Let_Loose/) support.
+- [Soldier of Fortune 2](https://www.gog.com/en/game/soldier_of_fortune_ii_double_helix_gold_edition) support.
+
+### Breaking:
+
+- Every function that used `&str` for the address has been changed to `&IpAddr` (
+ thanks [@Douile](https://github.com/Douile) for the re-re-write).
+- Protocols now use `&SocketAddr` instead of `address: &str, port: u16`.
+
+Services:
+
+- Valve Master Query:
+
+1. Removed Filter and SearchFilters lifetimes and changed `&'a str` to `String` and `&'a [&'a str]` to `Vec`
+
+# 0.2.2 - 01/05/2023
+
+### Changes:
+
+Crate:
+
+- General optimizations thanks to [cargo clippy](https://github.com/rust-lang/rust-clippy)
+ and [@cainthebest](https://github.com/cainthebest).
+- Added feature `serde` which enables json serialization/deserialization for all types (
+ by [@cainthebest](https://github.com/cainthebest)).
+- Documentation improvements.
+
+Protocols:
+
+- GameSpy 1: Add key `admin` as a possible variable for `admin_name`.
+- GameSpy 3 support.
+
+Games:
+
+- [Serious Sam](https://www.gog.com/game/serious_sam_the_first_encounter) support.
+- [Frontlines: Fuel of War](https://store.steampowered.com/app/9460/Frontlines_Fuel_of_War/) support.
+- [Crysis Wars](https://steamcommunity.com/app/17340) support.
+
+Services:
+
+- [Valve Master Server Query](https://developer.valvesoftware.com/wiki/Master_Server_Query_Protocol) support.
+- Added feature `no_services` which disables the supported services.
+
+### Breaking:
+
+Protocols:
+
+- Valve: Request type enums have been renamed from all caps to starting-only uppercase, ex: `INFO` to `Info`
+- GameSpy 1: `players_minimum` is now an `Option` instead of an `u8`
+- GameSpy 1: Is now under `protocols::gamespy::one` instead of `protocols::gamespy`
+
+# 0.2.1 - 03/03/2023
+
+### Changes:
+
+Crate:
+
+- Added feature `no_games` which disables the supported games (useful when only the
+ protocols/services are needed, also saves storage space).
+
+Games:
+
+- [V Rising](https://store.steampowered.com/app/1604030/V_Rising/) support.
+- [Unreal Tournament](https://en.wikipedia.org/wiki/Unreal_Tournament) support.
+- [Battlefield 1942](https://www.ea.com/games/battlefield/battlefield-1942) support.
+
+Protocols:
+
+- Valve:
+
+1. Reversed (from `0.1.0`) "Players with no name are no more added to the `players_details` field.", also added a note
+ in the [protocols](PROTOCOLS.md) file regarding this.
+2. Fixed querying while multiple challenge responses might happen.
+
+- GameSpy 1 support.
+
+### Breaking:
+
+None.
+
+# 0.2.0 - 18/02/2023
+
+### Changes:
+
+Games:
+
+- [Don't Starve Together](https://store.steampowered.com/app/322330/Dont_Starve_Together/) support.
+- [Colony Survival](https://store.steampowered.com/app/366090/Colony_Survival/) support.
+- [Onset](https://store.steampowered.com/app/1105810/Onset/) support.
+- [Codename CURE](https://store.steampowered.com/app/355180/Codename_CURE/) support.
+- [Ballistic Overkill](https://store.steampowered.com/app/296300/Ballistic_Overkill/) support.
+- [BrainBread 2](https://store.steampowered.com/app/346330/BrainBread_2/) support.
+- [Avorion](https://store.steampowered.com/app/445220/Avorion/) support.
+- [Operation: Harsh Doorstop](https://store.steampowered.com/app/736590/Operation_Harsh_Doorstop/) support.
+
+Protocols:
+
+- Valve:
+
+1. `appid` is now a field in the `Response` struct.
+
+### Breaking:
+
+Protocols:
+
+- Valve:
+ due to some games being able to host a server from within the game AND from a dedicated server,
+ if you were to query one of them, the query would fail for the other one, as the `SteamID` enum
+ for that game could specify only one id.
+
+1. `SteamID` is now `SteamApp`, was an u32 enum, and now it's a simple enum.
+2. `App` is now `Engine`, the `Source` enum's structure has been changed from `Option` to
+ `Option>`, where the first parameter is the game app id and the second is
+ the dedicated server app id (if there is one).
+
+# 0.1.0 - 17/01/2023
+
+### Changes:
+
+Games:
+
+- [Risk of Rain 2](https://store.steampowered.com/app/632360/Risk_of_Rain_2/) support.
+- [Battalion 1944](https://store.steampowered.com/app/489940/BATTALION_Legacy/) support.
+- [Black Mesa](https://store.steampowered.com/app/362890/Black_Mesa/) support.
+- [Project Zomboid](https://store.steampowered.com/app/108600/Project_Zomboid/) support.
+- [Age of Chivalry](https://store.steampowered.com/app/17510/Age_of_Chivalry/) support.
+
+Protocols:
+
+- Valve: Players with no name are no more added to the `players_details` field.
+- Valve: Split packets are now appending in the correct order.
+
+Crate:
+
+- `MSRV` is now `1.56.1` (was `1.58.1`)
+
+### Breaking:
+
+Protocols:
+
+- Valve: The rules field is now a `HashMap` instead of a `Vec` (where the `ServerRule`
+ structure had a name and a value fields).
+- Valve: Structs that contained the `players`, `max_players` and `bots` fields have been renamed
+ to `players_online`, `players_maximum` and `players_bots` respectively.
+- Minecraft: Structs that contained the `online_players`, `max_players` and `sample_players` fields have been renamed
+ to `players_online`, `players_maximum` and `players_sample` respectively.
+- Minecraft: The Java query response struct named `Response` has been renamed to `JavaResponse`.
+
+Errors:
+
+- Besides the `BadGame` error, now no other errors returns details about what happened (as it was quite pointless).
+
+Crate:
+
+- `package.metadata.msrv` has been replaced with `package.rust-version`
+
+# 0.0.7 - 03/01/2023
+
+### Changes:
+
+[Minecraft](https://www.minecraft.com) bedrock edition support.
+Fix Minecraft legacy v1.6 max/online players count being reversed.
+Added `query_legacy_specific` method to the Minecraft protocol.
+
+### Breaking:
+
+Removed `query_specific` from the mc protocol in favor of `query_java`, `query_legacy` and `query_legacy_specific`.
+Some public functions that are meant to be used only internally were made private.
+
+# 0.0.6 - 28/11/2022
+
+[Minecraft](https://www.minecraft.com) support (bedrock not supported yet).
+[7 Days To Die](https://store.steampowered.com/app/251570/7_Days_to_Die/) support.
+[ARK: Survival Evolved](https://store.steampowered.com/app/346110/ARK_Survival_Evolved/) support.
+[Unturned](https://store.steampowered.com/app/304930/Unturned/) support.
+[The Forest](https://store.steampowered.com/app/242760/The_Forest/) support.
+[Team Fortress Classic](https://store.steampowered.com/app/20/Team_Fortress_Classic/) support.
+[Sven Co-op](https://store.steampowered.com/app/225840/Sven_Coop/) support.
+[Rust](https://store.steampowered.com/app/252490/Rust/) support.
+[Counter-Strike](https://store.steampowered.com/app/10/CounterStrike/) support.
+[Arma 2: Operation Arrowhead](https://store.steampowered.com/app/33930/Arma_2_Operation_Arrowhead/) support.
+[Day of Infamy](https://store.steampowered.com/app/447820/Day_of_Infamy/) support.
+[Half-Life Deathmatch: Source](https://store.steampowered.com/app/360/HalfLife_Deathmatch_Source/) support.
+Successfully tested `Alien Swarm` and `Insurgency: Modern Infantry Combat`.
+Restored rules response for `Counter-Strike: Global Offensive` (note: for a full player list response, the
+cvar `host_players_show` must be set to `2`).
+Increased Valve Protocol `PACKET_SIZE` from 1400 to 6144 (because some games send larger packets than the specified
+protocol size).
+Removed DNS resolving as it was not needed.
+Valve Protocol minor optimizations.
+
+# 0.0.5 - 15/11/2022
+
+Added `SocketBind` error, regarding failing to bind a socket.
+Socket custom timeout capability (with an error if provided durations are zero).
+Because of this, a parameter similar to GatherSettings has been added on the Valve Protocol Query.
+Support for GoldSrc split packets and obsolete A2S_INFO response.
+Changed the Valve Protocol app parameter to represent the engine responses.
+It is now an enum of:
+
+- `Source(Option)` - A Source response with optionally, the id (if the id is present and the response id is not the
+ same, the query fails), if it isn't provided, find it.
+- `GoldSrc(bool)` - A GoldSrc response with the option to enforce the obsolete A2S_INFO response.
+
+Fixed Source multi-packet response crash due to when a certain app with a certain protocol doesn't have the Size
+field.
+Reduced Valve Protocol `PACKET_SIZE` to be as specified from 2048 to 1400.
+[Counter-Strike: Condition Zero](https://store.steampowered.com/app/80/CounterStrike_Condition_Zero/) implementation.
+[Day of Defeat](https://store.steampowered.com/app/30/Day_of_Defeat/) implementation.
+Games besides CSGO and TS now have the same response structure.
+
+# 0.0.4 - 23/10/2022
+
+Queries now support DNS resolve.
+Changed uses a bit, example: from `use gamedig::valve::ValveProtocol::query`
+to `use gamedig::protocols::valve::query`.
+Changed Valve Protocol Query parameters to (ip, port, app, gather_settings), changes include:
+
+- the app is now optional, being None means to anonymously query the server.
+- gather_settings is now also an optional, being None means all query settings.
+
+Valve Protocol now supports querying anonymous apps (see previous lines).
+Better bad game error.
+[Alien Swarm](https://store.steampowered.com/app/630/Alien_Swarm/) implementation (not tested).
+[Alien Swarm: Reactive Drop](https://store.steampowered.com/app/563560/Alien_Swarm_Reactive_Drop/) implementation.
+[Insurgency](https://store.steampowered.com/app/222880/Insurgency/) implementation.
+[Insurgency: Sandstorm](https://store.steampowered.com/app/581320/Insurgency_Sandstorm/) implementation.
+[Insurgency: Modern Infantry Combat](https://store.steampowered.com/app/17700/INSURGENCY_Modern_Infantry_Combat/)
+implementation (not tested).
+
+# 0.0.3 - 22/10/2022
+
+Valve protocol now properly supports multi-packet responses (compressed ones not tested).
+CSGO, TF2 and TS now have independent Responses, if you want a generic one, query the protocol.
+[Counter Strike: Source](https://store.steampowered.com/app/240/CounterStrike_Source/) implementation (if protocol is 7,
+queries with multi-packet responses will crash).
+[Day of Defeat: Source](https://store.steampowered.com/app/300/Day_of_Defeat_Source/) implementation.
+[Garry's Mod](https://store.steampowered.com/app/4000/Garrys_Mod/) implementation.
+[Half-Life 2 Deathmatch](https://store.steampowered.com/app/320/HalfLife_2_Deathmatch/) implementation.
+[Left 4 Dead](https://store.steampowered.com/app/500/Left_4_Dead/) implementation.
+[Left 4 Dead 2](https://store.steampowered.com/app/550/Left_4_Dead_2/) implementation.
+
+# 0.0.2 - 20/10/2022
+
+Further implementation of the Valve protocol (PLAYERS and RULES queries).
+[Counter Strike: Global Offensive](https://store.steampowered.com/app/730/CounterStrike_Global_Offensive/)
+implementation.
+[The Ship](https://developer.valvesoftware.com/wiki/The_Ship) implementation.
+The library now has error handling.
+
+# 0.0.1 - 16/10/2022
+
+The first usable version of the crate, yay!
+It brings:
+Initial implementation of the [Valve server query protocol](https://developer.valvesoftware.com/wiki/Server_queries).
+Initial [Team Fortress 2](https://en.wikipedia.org/wiki/Team_Fortress_2) support.
+
+# 0.0.0 - 15/10/2022
+
+The first *markdown*, the crate is unusable as it doesn't contain anything helpful.
diff --git a/crates/lib/Cargo.toml b/crates/lib/Cargo.toml
new file mode 100644
index 0000000..cb4aaee
--- /dev/null
+++ b/crates/lib/Cargo.toml
@@ -0,0 +1,78 @@
+[package]
+name = "gamedig"
+version = "0.9.0"
+edition = "2021"
+authors = [
+ "rust-GameDig contributors [https://github.com/gamedig/rust-gamedig/contributors]",
+ "node-GameDig contributors [https://github.com/gamedig/node-gamedig/contributors]",
+]
+license = "MIT"
+description = "Query game servers and not only."
+homepage = "https://gamedig.github.io/"
+documentation = "https://docs.rs/gamedig/latest/gamedig/"
+repository = "https://github.com/gamedig/rust-gamedig"
+readme = "README.md"
+keywords = ["server", "query", "game", "check", "status"]
+rust-version = "1.85.1"
+categories = ["parser-implementations", "parsing", "network-programming", "encoding"]
+
+[features]
+default = ["games", "services", "game_defs"]
+
+# Enable query functions for specific games
+games = []
+# Enable game definitions for use with the generic query functions
+game_defs = ["dep:phf", "games"]
+
+# Enable service querying
+services = []
+
+# Enable serde derivations for our types
+serde = []
+
+# Enable clap derivations for our types
+clap = ["dep:clap"]
+packet_capture = ["dep:pcap-file", "dep:pnet_packet", "dep:lazy_static"]
+
+# Enable TLS for HTTP Client
+tls = ["ureq/tls"]
+
+[dependencies]
+url = "2.5.8"
+byteorder = "1.5.0"
+bzip2-rs = "0.1.2"
+crc32fast = "1.5.0"
+base64 = "0.22.1"
+encoding_rs = "0.8.35"
+serde_json = { version = "1.0.149" }
+serde = { version = "1.0.228", features = ["derive"] }
+ureq = { version = "2.12.1", default-features = false, features = ["gzip", "json"] }
+phf = { version = "0.13.1", optional = true, features = ["macros"] }
+clap = { version = "4.5.60", optional = true, features = ["derive"] }
+pcap-file = { version = "2.0.0", optional = true }
+pnet_packet = { version = "0.35.0", optional = true }
+lazy_static = { version = "1.5.0", optional = true }
+
+[dev-dependencies]
+gamedig-id-tests = { path = "../id-tests", default-features = false }
+
+# Examples
+[[example]]
+name = "minecraft"
+required-features = ["games"]
+
+[[example]]
+name = "teamfortress2"
+required-features = ["games"]
+
+[[example]]
+name = "valve_master_server_query"
+required-features = ["services"]
+
+[[example]]
+name = "test_eco"
+required-features = ["games"]
+
+[[example]]
+name = "generic"
+required-features = ["games", "game_defs"]
diff --git a/crates/lib/README.md b/crates/lib/README.md
new file mode 100644
index 0000000..6f4e1f7
--- /dev/null
+++ b/crates/lib/README.md
@@ -0,0 +1,114 @@
+
rust-GameDig
+
+
The fast library for querying game servers/services.
+ This library brings what
+
+ node-GameDig
+
+ does (and not only), to pure Rust!
+
+
+**Warning**: This project goes through frequent API breaking changes and hasn't been thoroughly tested.
+
+## Community
+
+Checkout the GameDig Community Discord Server [here](https://discord.gg/NVCMn3tnxH).
+Note that it isn't be a replacement for GitHub issues, if you have found a problem
+within the library or want to request a feature, it's better to do so here rather than
+on Discord.
+
+## Usage
+
+Minimum Supported Rust Version is `1.85.1` and the code is cross-platform.
+
+Pick a game/service/protocol (check
+the [GAMES](https://github.com/gamedig/rust-gamedig/blob/main/GAMES.md), [SERVICES](https://github.com/gamedig/rust-gamedig/blob/main/SERVICES.md)
+and [PROTOCOLS](https://github.com/gamedig/rust-gamedig/blob/main/PROTOCOLS.md) files to see the currently supported
+ones), provide the ip and the port (be aware that some game servers use a separate port for the info queries, the port
+can also be optional if the server is running the default ports) then query on it.
+
+[Team Fortress 2](https://store.steampowered.com/app/440/Team_Fortress_2/) query example:
+
+```rust
+use gamedig::games::teamfortress2;
+
+fn main() {
+ let response = teamfortress2::query(&"127.0.0.1".parse().unwrap(), None);
+ // None is the default port (which is 27015), could also be Some(27015)
+
+ match response { // Result type, must check what it is...
+ Err(error) => println!("Couldn't query, error: {}", error),
+ Ok(r) => println!("{:#?}", r)
+ }
+}
+```
+
+Response (note that some games have a different structure):
+
+```json5
+{
+ protocol: 17,
+ name: "Team Fortress 2 Dedicated Server.",
+ map: "ctf_turbine",
+ game: "tf2",
+ appid: 440,
+ players_online: 0,
+ players_details: [],
+ players_maximum: 69,
+ players_bots: 0,
+ server_type: Dedicated,
+ has_password: false,
+ vac_secured: true,
+ version: "7638371",
+ port: Some(27015),
+ steam_id: Some(69753253289735296),
+ tv_port: None,
+ tv_name: None,
+ keywords: Some(
+ "alltalk,nocrits"
+ ),
+ rules: [
+ "mp_autoteambalance"
+ :
+ "1",
+ "mp_maxrounds"
+ :
+ "5",
+ //....
+ ]
+}
+```
+
+Want to see more examples? Checkout
+the [examples](https://github.com/gamedig/rust-gamedig/tree/main/crates/lib/examples) folder.
+
+## Documentation
+
+The documentation is available at [docs.rs](https://docs.rs/gamedig/latest/gamedig/).
+Curious about the history and what changed between versions? Everything is in
+the [CHANGELOG](https://github.com/gamedig/rust-gamedig/blob/main/crates/lib/CHANGELOG.md) file.
+
+## Contributing
+
+If you want to see your favorite game/service being supported here, open an issue, and I'll prioritize it (or do a pull
+request if you want to implement it yourself)!
+
+Before contributing please read [CONTRIBUTING](https://github.com/gamedig/rust-gamedig/blob/main/CONTRIBUTING.md).
+
diff --git a/crates/lib/examples/generic.rs b/crates/lib/examples/generic.rs
new file mode 100644
index 0000000..d54a0b3
--- /dev/null
+++ b/crates/lib/examples/generic.rs
@@ -0,0 +1,129 @@
+use gamedig::{
+ protocols::types::CommonResponse,
+ query_with_timeout_and_extra_settings,
+ ExtraRequestSettings,
+ GDResult,
+ Game,
+ TimeoutSettings,
+ GAMES,
+};
+
+use std::net::{IpAddr, SocketAddr, ToSocketAddrs};
+
+/// Make a query given the name of a game
+/// The `game` argument is taken from the [GAMES](gamedig::GAMES) map.
+fn generic_query(
+ game: &Game,
+ addr: &IpAddr,
+ port: Option,
+ timeout_settings: Option,
+ extra_settings: Option,
+) -> GDResult> {
+ println!("Querying {:#?} with game {:#?}.", addr, game);
+
+ let response = query_with_timeout_and_extra_settings(game, addr, port, timeout_settings, extra_settings)?;
+ println!("Response: {:#?}", response.as_json());
+
+ let common = response.as_original();
+ println!("Common response: {:#?}", common);
+
+ Ok(response)
+}
+
+fn main() {
+ let mut args = std::env::args().skip(1);
+
+ // Handle arguments
+ if let Some(game_name) = args.next() {
+ let hostname = args.next().expect("Must provide an address");
+ // Use to_socket_addrs to resolve hostname to IP
+ let addr: SocketAddr = format!("{}:0", hostname)
+ .to_socket_addrs()
+ .unwrap()
+ .next()
+ .expect("Could not lookup host");
+ let port: Option = args.next().map(|s| s.parse().unwrap());
+
+ let timeout_settings = TimeoutSettings::new(
+ TimeoutSettings::default().get_read(),
+ TimeoutSettings::default().get_write(),
+ TimeoutSettings::default().get_connect(),
+ 2,
+ )
+ .unwrap();
+
+ let game = GAMES
+ .get(&game_name)
+ .expect("Game doesn't exist, run without arguments to see a list of games");
+
+ let extra_settings = game
+ .request_settings
+ .clone()
+ .set_hostname(hostname.to_string())
+ .set_check_app_id(false);
+
+ generic_query(
+ game,
+ &addr.ip(),
+ port,
+ Some(timeout_settings),
+ Some(extra_settings),
+ )
+ .unwrap();
+ } else {
+ // Without arguments print a list of games
+ for (name, game) in GAMES.entries() {
+ println!("{}\t{}", name, game.name);
+ }
+ }
+}
+
+#[cfg(test)]
+mod test {
+ use gamedig::{protocols::types::TimeoutSettings, GAMES};
+ use std::{
+ net::{IpAddr, Ipv4Addr},
+ time::Duration,
+ };
+
+ use super::generic_query;
+
+ const ADDR: IpAddr = IpAddr::V4(Ipv4Addr::LOCALHOST);
+
+ fn test_game(game_name: &str) {
+ let timeout_settings = Some(
+ TimeoutSettings::new(
+ Some(Duration::from_nanos(1)),
+ Some(Duration::from_nanos(1)),
+ Some(Duration::from_nanos(1)),
+ 0,
+ )
+ .unwrap(),
+ );
+
+ let game = GAMES
+ .get(game_name)
+ .expect("Game doesn't exist, run without arguments to see a list of games");
+
+ assert!(generic_query(game, &ADDR, None, timeout_settings, None).is_err());
+ }
+
+ #[test]
+ fn battlefield1942() { test_game("battlefield1942"); }
+
+ #[test]
+ fn minecraft() { test_game("minecraft"); }
+
+ #[test]
+ fn teamfortress2() { test_game("teamfortress2"); }
+
+ #[test]
+ fn quake2() { test_game("quake2"); }
+
+ #[test]
+ fn all_games() {
+ for game_name in GAMES.keys() {
+ test_game(game_name);
+ }
+ }
+}
diff --git a/crates/lib/examples/minecraft.rs b/crates/lib/examples/minecraft.rs
new file mode 100644
index 0000000..ca49a5e
--- /dev/null
+++ b/crates/lib/examples/minecraft.rs
@@ -0,0 +1,31 @@
+use gamedig::minecraft;
+use gamedig::minecraft::types::RequestSettings;
+
+fn main() {
+ // or Some(), None is the default protocol port (which is 25565 for java
+ // and 19132 for bedrock)
+ let response = minecraft::query(&"127.0.0.1".parse().unwrap(), None);
+ // This will fail if no server is available locally!
+
+ match response {
+ Err(error) => println!("Couldn't query, error: {}", error),
+ Ok(r) => println!("{:#?}", r),
+ }
+
+ // This is an example to query a server with a hostname to be specified in the
+ // packet. Passing -1 on the protocol_version means anything, note that
+ // an invalid value here might result in server not responding.
+ let response = minecraft::query_java(
+ &"209.222.114.62".parse().unwrap(),
+ Some(25565),
+ Some(RequestSettings {
+ hostname: "mc.hypixel.net".to_string(),
+ protocol_version: -1,
+ }),
+ );
+
+ match response {
+ Err(error) => println!("Couldn't query, error: {}", error),
+ Ok(r) => println!("{:#?}", r),
+ }
+}
diff --git a/crates/lib/examples/teamfortress2.rs b/crates/lib/examples/teamfortress2.rs
new file mode 100644
index 0000000..787497c
--- /dev/null
+++ b/crates/lib/examples/teamfortress2.rs
@@ -0,0 +1,12 @@
+use gamedig::games::teamfortress2;
+
+fn main() {
+ let response = teamfortress2::query(&"127.0.0.1".parse().unwrap(), None);
+ // or Some(27015), None is the default protocol port (which is 27015)
+
+ match response {
+ // Result type, must check what it is...
+ Err(error) => println!("Couldn't query, error: {}", error),
+ Ok(r) => println!("{:#?}", r),
+ }
+}
diff --git a/crates/lib/examples/test_eco.rs b/crates/lib/examples/test_eco.rs
new file mode 100644
index 0000000..735fe9c
--- /dev/null
+++ b/crates/lib/examples/test_eco.rs
@@ -0,0 +1,10 @@
+use gamedig::games::eco;
+use std::net::IpAddr;
+use std::str::FromStr;
+
+fn main() {
+ let ip = IpAddr::from_str("142.132.154.69").unwrap();
+ let port = 31111;
+ let r = eco::query(&ip, Some(port));
+ println!("{:#?}", r);
+}
diff --git a/crates/lib/examples/valve_master_server_query.rs b/crates/lib/examples/valve_master_server_query.rs
new file mode 100644
index 0000000..7b33ac1
--- /dev/null
+++ b/crates/lib/examples/valve_master_server_query.rs
@@ -0,0 +1,14 @@
+use gamedig::valve_master_server::{query, Filter, Region, SearchFilters};
+
+fn main() {
+ let search_filters = SearchFilters::new()
+ .insert(Filter::RunsAppID(440))
+ .insert(Filter::CanBeEmpty(false))
+ .insert(Filter::CanBeFull(false))
+ .insert(Filter::CanHavePassword(false))
+ .insert(Filter::IsSecured(true))
+ .insert(Filter::HasTags(vec!["minecraft".to_string()]));
+
+ let ips = query(Region::Europe, Some(search_filters)).unwrap();
+ println!("Servers: {:?} \n Amount: {}", ips, ips.len());
+}
diff --git a/crates/lib/examples/valve_protocol_query.rs b/crates/lib/examples/valve_protocol_query.rs
new file mode 100644
index 0000000..368453c
--- /dev/null
+++ b/crates/lib/examples/valve_protocol_query.rs
@@ -0,0 +1,36 @@
+use gamedig::protocols::types::GatherToggle;
+use gamedig::protocols::valve;
+use gamedig::protocols::valve::{Engine, GatheringSettings};
+use gamedig::TimeoutSettings;
+use std::net::{IpAddr, Ipv4Addr, SocketAddr};
+use std::time::Duration;
+
+fn main() {
+ let address = &SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), 27015);
+ let engine = Engine::Source(None); // We don't specify a steam app id, let the query try to find it.
+ let gather_settings = GatheringSettings {
+ players: GatherToggle::Enforce, // We want to query for players
+ rules: GatherToggle::Skip, // We don't want to query for rules
+ check_app_id: false, // Loosen up the query a bit by not checking app id
+ };
+
+ let read_timeout = Duration::from_secs(2);
+ let write_timeout = Duration::from_secs(3);
+ let connect_timeout = Duration::from_secs(4);
+ let retries = 1; // does another request if the first one fails.
+ let timeout_settings = TimeoutSettings::new(
+ Some(read_timeout),
+ Some(write_timeout),
+ Some(connect_timeout),
+ retries,
+ )
+ .unwrap();
+
+ let response = valve::query(
+ address,
+ engine,
+ Some(gather_settings),
+ Some(timeout_settings),
+ );
+ println!("{response:#?}");
+}
diff --git a/crates/lib/src/buffer.rs b/crates/lib/src/buffer.rs
new file mode 100644
index 0000000..dff7cf0
--- /dev/null
+++ b/crates/lib/src/buffer.rs
@@ -0,0 +1,612 @@
+use crate::GDErrorKind::PacketBad;
+use crate::GDErrorKind::PacketUnderflow;
+use crate::GDResult;
+use byteorder::{BigEndian, ByteOrder, LittleEndian};
+use std::{convert::TryInto, marker::PhantomData};
+
+/// A struct representing a buffer with a specific byte order.
+///
+/// It's comprised of a byte slice that it reads from, a cursor to keep track of
+/// the current position within the byte slice, and a `PhantomData` marker to
+/// bind it to a specific byte order (BigEndian or LittleEndian).
+///
+/// The byte order is defined by the `B: ByteOrder` generic parameter.
+pub struct Buffer<'a, B: ByteOrder> {
+ /// The byte slice that the buffer reads from.
+ data: &'a [u8],
+ /// The cursor marking our current position in the buffer.
+ cursor: usize,
+ /// A phantom field used to bind the `Buffer` to a specific `ByteOrder`.
+ _marker: PhantomData,
+}
+
+impl<'a, B: ByteOrder> Buffer<'a, B> {
+ /// Creates and returns a new `Buffer` with the given data.
+ ///
+ /// The cursor is set to the start of the buffer (position 0) upon
+ /// initialization.
+ ///
+ /// # Arguments
+ ///
+ /// * `data` - A byte slice that the buffer will read from.
+ pub const fn new(data: &'a [u8]) -> Self {
+ Self {
+ data,
+ cursor: 0,
+ _marker: PhantomData,
+ }
+ }
+
+ pub const fn current_position(&self) -> usize { self.cursor }
+
+ /// Returns the length of the remaining bytes from the current cursor
+ /// position.
+ pub const fn remaining_length(&self) -> usize { self.data.len() - self.cursor }
+
+ /// Returns the length of the buffer data.
+ pub const fn data_length(&self) -> usize { self.data.len() }
+
+ // TODO: Look into this to make it take ownership of data, not borrowing it
+ // There are many instances where we transform this to a vector.
+ /// Returns the remaining bytes that have not been read.
+ pub fn remaining_bytes(&self) -> &[u8] { &self.data[self.cursor ..] }
+
+ /// Moves the cursor forward or backward by a specified offset.
+ ///
+ /// # Arguments
+ ///
+ /// * `offset` - The amount to move the cursor. Use a negative value to move
+ /// backwards.
+ ///
+ /// # Errors
+ ///
+ /// Returns a `BufferError` if the attempted move would position the cursor
+ /// out of bounds.
+ pub fn move_cursor(&mut self, offset: isize) -> GDResult<()> {
+ // Compute the new cursor position by adding the offset to the current cursor
+ // position. The checked_add method is used for safe addition,
+ // preventing overflow and underflow.
+ let new_cursor = (self.cursor as isize).checked_add(offset);
+
+ match new_cursor {
+ // If the addition was not successful (i.e., it resulted in an overflow or underflow),
+ // return an error indicating that the cursor is out of bounds.
+ None => Err(PacketBad.into()),
+
+ // If the new cursor position is either less than zero (i.e., before the start of the buffer)
+ // or greater than the remaining length of the buffer (i.e., past the end of the buffer),
+ // return an error indicating that the cursor is out of bounds.
+ Some(x) if x < 0 || x as usize > self.data_length() => Err(PacketBad.into()),
+
+ // If the new cursor position is within the bounds of the buffer, update the cursor
+ // position and return Ok.
+ Some(x) => {
+ self.cursor = x as usize;
+ Ok(())
+ }
+ }
+ }
+
+ /// Reads a value of type `T` from the buffer, and advances the cursor by
+ /// the size of `T`.
+ ///
+ /// # Type Parameters
+ ///
+ /// * `T` - The type of value to be read from the buffer. This type must
+ /// implement the `BufferRead` trait with the same byte order as the
+ /// buffer.
+ ///
+ /// # Errors
+ ///
+ /// Returns a `BufferError` if there is not enough data remaining in the
+ /// buffer to read a value of type `T`.
+ pub fn read>(&mut self) -> GDResult {
+ // Get the size of `T` in bytes.
+ let size = std::mem::size_of::();
+ // Calculate remaining length of the buffer.
+ let remaining = self.remaining_length();
+
+ // If the size of `T` is larger than the remaining length, return an error
+ // because we don't have enough data left to read.
+ if size > remaining {
+ return Err(PacketUnderflow.context(format!(
+ "Size requested {size} was larger than remaining bytes {remaining}"
+ )));
+ }
+
+ // Slice the data array from the current cursor position for `size` amount of
+ // bytes.
+ let bytes = &self.data[self.cursor .. self.cursor + size];
+
+ // Move the cursor forward by `size`.
+ self.cursor += size;
+
+ // Use the `read_from_buffer` function of the `BufferRead` implementation for
+ // `T` to convert the bytes into an instance of `T`.
+ T::read_from_buffer(bytes)
+ }
+
+ /// Reads a string from the buffer using a specified `StringDecoder`, until
+ /// an optional delimiter.
+ ///
+ /// # Type Parameters
+ ///
+ /// * `D` - The type of string decoder to use. This type must implement the
+ /// `StringDecoder` trait with the same byte order as the buffer.
+ ///
+ /// # Arguments
+ ///
+ /// * `until` - An optional delimiter. If provided, the method will read
+ /// until this delimiter is encountered. If not provided, the method will
+ /// read until the default delimiter of the decoder.
+ ///
+ /// # Errors
+ ///
+ /// Returns a `BufferError` if there is an error decoding the string.
+ pub fn read_string(&mut self, until: Option) -> GDResult {
+ // Check if the cursor is out of bounds.
+ if self.cursor > self.data_length() {
+ return Err(PacketUnderflow.context(format!(
+ "Cursor position {} is out of bounds when reading string. Buffer length: {}",
+ self.cursor,
+ self.data_length()
+ )));
+ }
+
+ // Slice the data array from the current cursor position to the end.
+ let data_slice = &self.data[self.cursor ..];
+
+ // Use the provided delimiter if one was given, or default to the
+ // delimiter specified by the StringDecoder.
+ let delimiter = until.unwrap_or(D::DELIMITER);
+
+ // Invoke the decode_string function of the provided StringDecoder,
+ // passing in the remaining data slice, the mutable reference to the
+ // cursor, and the delimiter.
+ let result = D::decode_string(data_slice, &mut self.cursor, delimiter)?;
+
+ // If decoding was successful, return the decoded string. The cursor
+ // position has been updated within the decode_string call to reflect
+ // the new position after reading.
+ Ok(result)
+ }
+}
+
+/// A trait that provides an interface to switch endianness.
+///
+/// The trait `SwitchEndian` is used for types that have a specific
+/// byte order (endianness) and can switch to another byte order.
+/// The type of the switched endianness is determined by the associated
+/// type `Output`.
+///
+/// The associated type `Output` must implement the `ByteOrder` trait.
+pub trait SwitchEndian {
+ type Output: ByteOrder;
+}
+
+/// An implementation of `SwitchEndian` for `LittleEndian`.
+///
+/// The switched endianness type is `BigEndian`.
+impl SwitchEndian for LittleEndian {
+ type Output = BigEndian;
+}
+
+/// An implementation of `SwitchEndian` for `BigEndian`.
+///
+/// The switched endianness type is `LittleEndian`.
+impl SwitchEndian for BigEndian {
+ type Output = LittleEndian;
+}
+
+impl<'a, B: SwitchEndian + ByteOrder> Buffer<'a, B> {
+ /// Switches the byte order of a chunk in the buffer.
+ ///
+ /// This method consumes the buffer and returns a new buffer
+ /// with a chunk of the original buffer's data, starting from the
+ /// original cursor position and of the given size, where the byte
+ /// order is switched according to the implementation
+ /// of `SwitchEndian` for `B`.
+ ///
+ /// Note: The method also advances the cursor of the original buffer
+ /// by `size`.
+ ///
+ /// # Parameters
+ ///
+ /// * `size`: The size of the chunk to be taken from the original buffer.
+ pub fn switch_endian_chunk(&mut self, size: usize) -> GDResult> {
+ let old_cursor = self.cursor;
+ self.move_cursor(size as isize)?;
+
+ Ok(Buffer {
+ data: &self.data[old_cursor .. old_cursor + size],
+ cursor: 0,
+ _marker: PhantomData,
+ })
+ }
+}
+
+/// A trait defining a protocol for reading values of a certain type from a
+/// buffer.
+///
+/// Implementors of this trait provide a method for reading their type from a
+/// byte buffer with a specific byte order.
+pub trait BufferRead: Sized {
+ fn read_from_buffer(data: &[u8]) -> GDResult;
+}
+
+/// Macro to implement the `BufferRead` trait for byte types.
+///
+/// This macro generates an implementation of the `BufferRead` trait for a
+/// specified byte type. The implementation will read a single byte from the
+/// buffer and convert it to the target type using the provided map function.
+///
+/// # Arguments
+///
+/// * `$type` - The target type to implement `BufferRead` for.
+/// * `$map_func` - The function to map a byte to the target type.
+macro_rules! impl_buffer_read_byte {
+ ($type:ty, $map_func:expr) => {
+ impl BufferRead for $type {
+ fn read_from_buffer(data: &[u8]) -> GDResult {
+ // Use the `first` method to get the first byte from the data array.
+ data.first()
+ // Apply the $map_func function to convert the raw byte to the $type.
+ .map($map_func)
+ // If the data array is empty (and thus `first` returns None),
+ // `ok_or_else` will return a BufferError.
+ .ok_or_else(|| PacketBad.into())
+ }
+ }
+ };
+}
+
+/// Macro to implement the `BufferRead` trait for multi-byte types.
+///
+/// This macro generates an implementation of the `BufferRead` trait for a
+/// specified multi-byte type. The implementation will read the appropriate
+/// number of bytes from the buffer and convert them to the target type using
+/// the provided read function.
+///
+/// # Arguments
+///
+/// * `$type` - The target type to implement `BufferRead` for.
+/// * `$read_func` - The function to read the bytes into the target type.
+macro_rules! impl_buffer_read {
+ ($type:ty, $read_func:ident) => {
+ impl BufferRead for $type {
+ fn read_from_buffer(data: &[u8]) -> GDResult {
+ // Convert the byte slice into an array of the appropriate type.
+ let array = data.try_into().map_err(|e| {
+ // If conversion fails, return an error indicating the required and provided
+ // lengths.
+ PacketBad.context(e)
+ })?;
+
+ // Use the provided function to read the data from the array into the given
+ // type.
+ Ok(B::$read_func(array))
+ }
+ }
+ };
+}
+
+impl_buffer_read_byte!(u8, |&b| b);
+impl_buffer_read_byte!(i8, |&b| b as i8);
+
+impl_buffer_read!(u16, read_u16);
+impl_buffer_read!(i16, read_i16);
+impl_buffer_read!(u32, read_u32);
+impl_buffer_read!(i32, read_i32);
+impl_buffer_read!(u64, read_u64);
+impl_buffer_read!(i64, read_i64);
+impl_buffer_read!(f32, read_f32);
+impl_buffer_read!(f64, read_f64);
+
+/// A trait defining a protocol for decoding strings from a buffer.
+///
+/// This trait should be implemented by types that can decode strings from a
+/// byte buffer with a specific byte order and delimiter.
+pub trait StringDecoder {
+ /// The type of the delimiter used by the decoder.
+ type Delimiter: AsRef<[u8]>;
+
+ /// The default delimiter used by the decoder.
+ const DELIMITER: Self::Delimiter;
+
+ /// Decodes a string from the provided byte slice, and updates the cursor
+ /// position accordingly.
+ ///
+ /// # Arguments
+ ///
+ /// * `data` - The byte slice to decode the string from.
+ /// * `cursor` - The current position in the byte slice.
+ /// * `delimiter` - The delimiter to use for decoding the string.
+ ///
+ /// # Errors
+ ///
+ /// Returns a `BufferError` if there is an error decoding the string.
+ fn decode_string(data: &[u8], cursor: &mut usize, delimiter: Self::Delimiter) -> GDResult;
+}
+
+/// A decoder for UTF-8 encoded strings.
+///
+/// This decoder uses a single null byte (`0x00`) as the default delimiter.
+pub struct Utf8Decoder;
+
+impl StringDecoder for Utf8Decoder {
+ type Delimiter = [u8; 1];
+
+ const DELIMITER: Self::Delimiter = [0x00];
+
+ /// Decodes a UTF-8 string from the given data, updating the cursor position
+ /// accordingly.
+ fn decode_string(data: &[u8], cursor: &mut usize, delimiter: Self::Delimiter) -> GDResult {
+ // Find the position of the delimiter in the data. If the delimiter is not
+ // found, the length of the data is returned.
+ let position = data
+ // Create an iterator over the data.
+ .iter()
+ // Find the position of the delimiter
+ .position(|&b| b == delimiter.as_ref()[0])
+ // If the delimiter is not found, use the whole data slice.
+ .unwrap_or(data.len());
+
+ // Convert the data until the found position into a UTF-8 string.
+ let result = std::str::from_utf8(
+ // Take a slice of data until the position.
+ &data[.. position]
+ )
+ // If the data cannot be converted into a UTF-8 string, return an error
+ .map_err(|e| PacketBad.context(e))?
+ // Convert the resulting &str into a String
+ .to_owned();
+
+ // Update the cursor position
+ // The +1 is to skip the delimiter
+ *cursor += position + 1;
+
+ Ok(result)
+ }
+}
+
+/// A decoder for UTF-8 encoded strings prefixed by a single byte denoting the
+/// string's length.
+///
+/// This decoder uses a single null byte (`0x00`) as the default delimiter.
+pub struct Utf8LengthPrefixedDecoder;
+
+impl StringDecoder for Utf8LengthPrefixedDecoder {
+ type Delimiter = [u8; 1];
+
+ const DELIMITER: Self::Delimiter = [0x00];
+
+ /// Decodes a UTF-8 string from the given data, updating the cursor position
+ /// accordingly.
+ fn decode_string(data: &[u8], cursor: &mut usize, delimiter: Self::Delimiter) -> GDResult {
+ // Find the maximum length of the string
+ let length = *data
+ .first()
+ .ok_or_else(|| PacketBad.context("Length of string not found"))?;
+
+ // Find the position of the delimiter in the data. If the delimiter is not
+ // found, the length is returned.
+ let position = data
+ // Create an iterator over the data.
+ .iter()
+ .skip(1)
+ .take(length as usize)
+ // Find the position of the delimiter
+ .position(|&b| b == delimiter.as_ref()[0])
+ // If the delimiter is not found, use the whole data slice.
+ .unwrap_or(length as usize);
+
+ // Convert the data until the found position into a UTF-8 string.
+ let result = std::str::from_utf8(
+ // Take a slice of data until the position.
+ &data[1 .. position + 1]
+ )
+ // If the data cannot be converted into a UTF-8 string, return an error
+ .map_err(|e| PacketBad.context(e))?
+ // Convert the resulting &str into a String
+ .to_owned();
+
+ // Update the cursor position
+ // The +1 is to skip t length
+ *cursor += position + 1;
+
+ Ok(result)
+ }
+}
+
+/// A decoder for UTF-16 encoded strings.
+///
+/// This decoder uses a pair of null bytes (`0x00, 0x00`) as the default
+/// delimiter.
+///
+/// # Type Parameters
+///
+/// * `B` - The byte order to use when decoding the string.
+pub struct Utf16Decoder {
+ _marker: PhantomData,
+}
+
+impl StringDecoder for Utf16Decoder {
+ type Delimiter = [u8; 2];
+
+ const DELIMITER: Self::Delimiter = [0x00, 0x00];
+
+ /// Decodes a UTF-16 string from the given data, updating the cursor
+ /// position accordingly.
+ fn decode_string(data: &[u8], cursor: &mut usize, delimiter: Self::Delimiter) -> GDResult {
+ // Try to find the position of the delimiter in the data
+ let position = data
+ // Split the data into 2-byte chunks (as UTF-16 uses 2 bytes per character)
+ .chunks_exact(2)
+ // Find the position of the delimiter
+ .position(|chunk| chunk == delimiter.as_ref())
+ // If the delimiter is not found, use the whole data, otherwise use the position of the delimiter
+ .map_or(data.len(), |pos| pos * 2);
+
+ // Create a buffer of u16 values to hold the decoded characters
+ let mut paired_buf: Vec = vec![0; position / 2];
+
+ // Decode the data into the buffer
+ B::read_u16_into(&data[.. position], &mut paired_buf);
+
+ // Convert the buffer of u16 values into a String
+ let result = String::from_utf16(&paired_buf).map_err(|e| PacketBad.context(e))?;
+
+ // Update the cursor position
+ // The +2 accounts for the delimiter
+ *cursor += position + 2;
+
+ Ok(result)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use byteorder::BigEndian;
+
+ #[test]
+ fn test_new_buffer() {
+ let data: &[u8] = &[1, 2, 3, 4];
+ let buffer = Buffer::::new(data);
+
+ assert_eq!(buffer.data, data);
+ assert_eq!(buffer.cursor, 0);
+ }
+
+ #[test]
+ fn test_remaining_length() {
+ let data: &[u8] = &[1, 2, 3, 4];
+ let mut buffer = Buffer::::new(data);
+
+ assert_eq!(buffer.remaining_length(), 4);
+
+ buffer.cursor = 2;
+ assert_eq!(buffer.remaining_length(), 2);
+ }
+
+ #[test]
+ fn test_move_cursor() {
+ let data: &[u8] = &[1, 2, 3, 4];
+ let mut buffer = Buffer::::new(data);
+
+ // Test moving forward
+ assert!(buffer.move_cursor(2).is_ok());
+ assert_eq!(buffer.cursor, 2);
+
+ // Test moving backward
+ assert!(buffer.move_cursor(-1).is_ok());
+ assert_eq!(buffer.cursor, 1);
+
+ // Test moving beyond data limits
+ assert!(buffer.move_cursor(5).is_err());
+ assert!(buffer.move_cursor(-2).is_err());
+ }
+
+ #[test]
+ fn test_switch_endian_chunk_le_be() {
+ let data = [0x01, 0x02, 0x03, 0x04];
+ let mut buffer = Buffer::::new(&data[..]);
+
+ let switched_buffer = buffer.switch_endian_chunk(2).unwrap();
+
+ assert_eq!(switched_buffer.data, [0x01, 0x02]);
+ assert_eq!(switched_buffer.cursor, 0);
+
+ assert_eq!(buffer.remaining_bytes(), [0x03, 0x04]);
+ assert_eq!(buffer.cursor, 2);
+ }
+
+ #[test]
+ fn test_switch_endian_chunk_be_le() {
+ let data = [0x01, 0x02, 0x03, 0x04];
+ let mut buffer = Buffer::::new(&data[..]);
+
+ let switched_buffer = buffer.switch_endian_chunk(2).unwrap();
+
+ assert_eq!(switched_buffer.data, [0x01, 0x02]);
+ assert_eq!(switched_buffer.cursor, 0);
+
+ assert_eq!(buffer.remaining_bytes(), [0x03, 0x04]);
+ assert_eq!(buffer.cursor, 2);
+ }
+
+ #[test]
+ fn test_buffer_read_u8() {
+ let data: &[u8] = &[1, 2, 3, 4];
+ let mut buffer = Buffer::::new(data);
+
+ let result: Result = buffer.read();
+ assert_eq!(result.unwrap(), 1);
+ assert_eq!(buffer.cursor, 1);
+ }
+
+ #[test]
+ fn test_buffer_read_u16() {
+ let data: &[u8] = &[1, 2, 3, 4];
+ let mut buffer = Buffer::::new(data);
+
+ let result: Result = buffer.read();
+ assert_eq!(result.unwrap(), 0x0201);
+ assert_eq!(buffer.cursor, 2);
+ }
+
+ #[test]
+ fn test_buffer_read_u16_big_endian() {
+ let data: &[u8] = &[1, 2, 3, 4];
+ let mut buffer = Buffer::::new(data);
+
+ let result: Result = buffer.read();
+ assert_eq!(result.unwrap(), 0x0102);
+ assert_eq!(buffer.cursor, 2);
+ }
+
+ #[test]
+ fn test_decode_string_utf8() {
+ let data: &[u8] = b"Hello\0World\0";
+ let mut cursor = 0;
+ let delimiter = [0x00];
+
+ let result = Utf8Decoder::decode_string(data, &mut cursor, delimiter);
+ assert_eq!(result.unwrap(), "Hello");
+ assert_eq!(cursor, 6);
+ }
+
+ #[test]
+ fn test_decode_string_utf16_le() {
+ let data: &[u8] = &[0x48, 0x00, 0x65, 0x00, 0x00, 0x00];
+ let mut cursor = 0;
+ let delimiter = [0x00, 0x00];
+
+ let result = Utf16Decoder::::decode_string(data, &mut cursor, delimiter);
+ assert_eq!(result.unwrap(), "He");
+ assert_eq!(cursor, 6);
+ }
+
+ #[test]
+ fn test_decode_string_utf16_be() {
+ let data: &[u8] = &[0x00, 0x48, 0x00, 0x65, 0x00, 0x00];
+ let mut cursor = 0;
+ let delimiter = [0x00, 0x00];
+
+ let result = Utf16Decoder::::decode_string(data, &mut cursor, delimiter);
+ assert_eq!(result.unwrap(), "He");
+ assert_eq!(cursor, 6);
+ }
+
+ #[test]
+ fn test_buffer_underflow_error() {
+ let data: &[u8] = &[1, 2];
+ let mut buffer = Buffer::::new(data);
+
+ let result: Result = buffer.read();
+ assert_eq!(
+ result.unwrap_err(),
+ crate::GDErrorKind::PacketUnderflow.into()
+ );
+ }
+}
diff --git a/crates/lib/src/capture/mod.rs b/crates/lib/src/capture/mod.rs
new file mode 100644
index 0000000..583ebd8
--- /dev/null
+++ b/crates/lib/src/capture/mod.rs
@@ -0,0 +1,39 @@
+pub(crate) mod packet;
+mod pcap;
+pub(crate) mod socket;
+pub(crate) mod writer;
+
+use self::{pcap::Pcap, writer::Writer};
+use pcap_file::pcapng::{blocks::interface_description::InterfaceDescriptionBlock, PcapNgBlock, PcapNgWriter};
+use std::path::PathBuf;
+
+pub fn setup_capture(file_path: Option) {
+ if let Some(file_path) = file_path {
+ let file = std::fs::OpenOptions::new()
+ .create_new(true)
+ .write(true)
+ .open(file_path.with_extension("pcap"))
+ .unwrap();
+
+ let mut pcap_writer = PcapNgWriter::new(file).unwrap();
+
+ // Write headers
+ let _ = pcap_writer.write_block(
+ &InterfaceDescriptionBlock {
+ linktype: pcap_file::DataLink::ETHERNET,
+ snaplen: 0xFFFF,
+ options: vec![],
+ }
+ .into_block(),
+ );
+
+ let writer = Box::new(Pcap::new(pcap_writer));
+ attach(writer)
+ }
+}
+
+/// Attaches a writer to the capture module.
+///
+/// # Errors
+/// Returns an Error if the writer is already set.
+fn attach(writer: Box) { crate::capture::socket::set_writer(writer); }
diff --git a/crates/lib/src/capture/packet.rs b/crates/lib/src/capture/packet.rs
new file mode 100644
index 0000000..ef3ae62
--- /dev/null
+++ b/crates/lib/src/capture/packet.rs
@@ -0,0 +1,203 @@
+use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
+
+/// Size of a standard network packet.
+pub(crate) const PACKET_SIZE: usize = 5012;
+/// Size of an Ethernet header.
+pub(crate) const HEADER_SIZE_ETHERNET: usize = 14;
+/// Size of an IPv4 header.
+pub(crate) const HEADER_SIZE_IP4: usize = 20;
+/// Size of an IPv6 header.
+pub(crate) const HEADER_SIZE_IP6: usize = 40;
+/// Size of a UDP header.
+pub(crate) const HEADER_SIZE_UDP: usize = 4;
+
+/// Represents the direction of a network packet.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub(crate) enum Direction {
+ /// Packet is outgoing (sent by us).
+ Send,
+ /// Packet is incoming (received by us).
+ Receive,
+}
+
+/// Defines the protocol of a network packet.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub(crate) enum Protocol {
+ /// Transmission Control Protocol.
+ Tcp,
+ /// User Datagram Protocol.
+ Udp,
+}
+
+/// Trait for handling different types of IP addresses (IPv4, IPv6).
+pub(crate) trait IpAddress: Sized {
+ /// Creates an instance from a standard `IpAddr`, returning `None` if the
+ /// types are incompatible.
+ fn from_std(ip: IpAddr) -> Option;
+}
+
+/// Represents a captured network packet with metadata.
+#[derive(Clone, Debug, PartialEq)]
+pub(crate) struct CapturePacket<'a> {
+ /// Direction of the packet (Send/Receive).
+ pub(crate) direction: Direction,
+ /// Protocol of the packet (Tcp/UDP).
+ pub(crate) protocol: Protocol,
+ /// Remote socket address.
+ pub(crate) remote_address: &'a SocketAddr,
+ /// Local socket address.
+ pub(crate) local_address: &'a SocketAddr,
+}
+
+impl CapturePacket<'_> {
+ /// Retrieves the local and remote ports based on the packet's direction.
+ ///
+ /// Returns:
+ /// - (u16, u16): Tuple of (source port, destination port).
+ pub(super) fn ports_by_direction(&self) -> (u16, u16) {
+ let (local, remote) = (self.local_address.port(), self.remote_address.port());
+ self.direction.order(local, remote)
+ }
+
+ /// Retrieves the local and remote IP addresses.
+ ///
+ /// Returns:
+ /// - (IpAddr, IpAddr): Tuple of (local IP, remote IP).
+ pub(super) fn ip_addr(&self) -> (IpAddr, IpAddr) {
+ let (local, remote) = (self.local_address.ip(), self.remote_address.ip());
+ (local, remote)
+ }
+
+ /// Retrieves IP addresses of a specific type (IPv4 or IPv6) based on the
+ /// packet's direction.
+ ///
+ /// Panics if the IP type of the addresses does not match the requested
+ /// type.
+ ///
+ /// Returns:
+ /// - (T, T): Tuple of (source IP, destination IP) of the specified type in
+ /// order.
+ pub(super) fn ipvt_by_direction(&self) -> (T, T) {
+ let (local, remote) = (
+ T::from_std(self.local_address.ip()).expect("Incorrect IP type for local address"),
+ T::from_std(self.remote_address.ip()).expect("Incorrect IP type for remote address"),
+ );
+
+ self.direction.order(local, remote)
+ }
+}
+
+impl Direction {
+ /// Orders two elements (source and destination) based on the packet's
+ /// direction.
+ ///
+ /// Returns:
+ /// - (T, T): Ordered tuple (source, destination).
+ pub(self) const fn order(&self, source: T, remote: T) -> (T, T) {
+ match self {
+ Direction::Send => (source, remote),
+ Direction::Receive => (remote, source),
+ }
+ }
+}
+
+/// Implements the `IpAddress` trait for `Ipv4Addr`.
+impl IpAddress for Ipv4Addr {
+ /// Creates an `Ipv4Addr` from a standard `IpAddr`, if it's IPv4.
+ fn from_std(ip: IpAddr) -> Option {
+ match ip {
+ IpAddr::V4(ipv4) => Some(ipv4),
+ _ => None,
+ }
+ }
+}
+
+/// Implements the `IpAddress` trait for `Ipv6Addr`.
+impl IpAddress for Ipv6Addr {
+ /// Creates an `Ipv6Addr` from a standard `IpAddr`, if it's IPv6.
+ fn from_std(ip: IpAddr) -> Option {
+ match ip {
+ IpAddr::V6(ipv6) => Some(ipv6),
+ _ => None,
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use std::str::FromStr;
+
+ // Helper function to create a SocketAddr from a string
+ fn socket_addr(addr: &str) -> SocketAddr { SocketAddr::from_str(addr).unwrap() }
+
+ #[test]
+ fn test_ports_by_direction() {
+ let packet_send = CapturePacket {
+ direction: Direction::Send,
+ protocol: Protocol::Tcp,
+ local_address: &socket_addr("127.0.0.1:8080"),
+ remote_address: &socket_addr("192.168.1.1:80"),
+ };
+
+ let packet_receive = CapturePacket {
+ direction: Direction::Receive,
+ protocol: Protocol::Tcp,
+ local_address: &socket_addr("127.0.0.1:8080"),
+ remote_address: &socket_addr("192.168.1.1:80"),
+ };
+
+ assert_eq!(packet_send.ports_by_direction(), (8080, 80));
+ assert_eq!(packet_receive.ports_by_direction(), (80, 8080));
+ }
+
+ #[test]
+ fn test_ip_addr() {
+ let packet_send = CapturePacket {
+ direction: Direction::Send,
+ protocol: Protocol::Tcp,
+ local_address: &socket_addr("127.0.0.1:8080"),
+ remote_address: &socket_addr("192.168.1.1:80"),
+ };
+
+ let packet_receive = CapturePacket {
+ direction: Direction::Receive,
+ protocol: Protocol::Tcp,
+ local_address: &socket_addr("127.0.0.1:8080"),
+ remote_address: &socket_addr("192.168.1.1:80"),
+ };
+
+ assert_eq!(
+ packet_send.ip_addr(),
+ (
+ IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)),
+ IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1))
+ )
+ );
+ assert_eq!(
+ packet_receive.ip_addr(),
+ (
+ IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)),
+ IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1))
+ )
+ );
+ }
+
+ #[test]
+ fn test_ip_by_direction_type_specific() {
+ let packet = CapturePacket {
+ direction: Direction::Send,
+ protocol: Protocol::Tcp,
+ local_address: &socket_addr("127.0.0.1:8080"),
+ remote_address: &socket_addr("192.168.1.1:80"),
+ };
+
+ let ipv4_result: Result<(Ipv4Addr, Ipv4Addr), _> =
+ std::panic::catch_unwind(|| packet.ipvt_by_direction::());
+ assert!(ipv4_result.is_ok());
+
+ let ipv6_result: Result<(Ipv6Addr, Ipv6Addr), _> =
+ std::panic::catch_unwind(|| packet.ipvt_by_direction::());
+ assert!(ipv6_result.is_err());
+ }
+}
diff --git a/crates/lib/src/capture/pcap.rs b/crates/lib/src/capture/pcap.rs
new file mode 100644
index 0000000..acb653f
--- /dev/null
+++ b/crates/lib/src/capture/pcap.rs
@@ -0,0 +1,383 @@
+use pcap_file::pcapng::{blocks::enhanced_packet::EnhancedPacketOption, PcapNgBlock, PcapNgWriter};
+use pnet_packet::{
+ ethernet::{EtherType, MutableEthernetPacket},
+ ip::{IpNextHeaderProtocol, IpNextHeaderProtocols},
+ ipv4::MutableIpv4Packet,
+ ipv6::MutableIpv6Packet,
+ tcp::{MutableTcpPacket, TcpFlags},
+ udp::MutableUdpPacket,
+ PacketSize,
+};
+use std::{io::Write, net::IpAddr, time::Instant};
+
+use super::packet::{
+ CapturePacket,
+ Direction,
+ Protocol,
+ HEADER_SIZE_ETHERNET,
+ HEADER_SIZE_IP4,
+ HEADER_SIZE_IP6,
+ HEADER_SIZE_UDP,
+ PACKET_SIZE,
+};
+
+const BUFFER_SIZE: usize = PACKET_SIZE - HEADER_SIZE_IP6 - HEADER_SIZE_ETHERNET;
+
+pub(crate) struct Pcap {
+ writer: PcapNgWriter,
+ pub(crate) state: State,
+}
+
+pub(crate) struct State {
+ pub(crate) start_time: Instant,
+ pub(crate) send_seq: u32,
+ pub(crate) rec_seq: u32,
+ pub(crate) has_sent_handshake: bool,
+ pub(crate) stream_count: u32,
+}
+
+impl Pcap {
+ pub(crate) fn new(writer: PcapNgWriter) -> Self {
+ Self {
+ writer,
+ state: State::default(),
+ }
+ }
+
+ pub(crate) fn write_transport_packet(&mut self, info: &CapturePacket, payload: &[u8]) {
+ let mut buffer_array: [u8; BUFFER_SIZE] = [0; BUFFER_SIZE];
+ let buf: &mut [u8] = &mut buffer_array[..];
+
+ let (source_port, dest_port) = info.ports_by_direction();
+
+ match info.protocol {
+ Protocol::Tcp => {
+ let buf_size = {
+ let mut tcp = MutableTcpPacket::new(buf).unwrap();
+ tcp.set_source(source_port);
+ tcp.set_destination(dest_port);
+ tcp.set_payload(payload);
+ tcp.set_data_offset(5);
+ tcp.set_window(43440);
+ match info.direction {
+ Direction::Send => {
+ tcp.set_sequence(self.state.send_seq);
+ tcp.set_acknowledgement(self.state.rec_seq);
+
+ self.state.send_seq = self.state.send_seq.wrapping_add(payload.len() as u32);
+ }
+ Direction::Receive => {
+ tcp.set_sequence(self.state.rec_seq);
+ tcp.set_acknowledgement(self.state.send_seq);
+
+ self.state.rec_seq = self.state.rec_seq.wrapping_add(payload.len() as u32);
+ }
+ }
+ tcp.set_flags(TcpFlags::PSH | TcpFlags::ACK);
+
+ tcp.packet_size()
+ };
+
+ self.write_transport_payload(
+ info,
+ IpNextHeaderProtocols::Tcp,
+ &buf[.. buf_size + payload.len()],
+ vec![],
+ );
+
+ let mut info = info.clone();
+ let buf_size = {
+ let mut tcp = MutableTcpPacket::new(buf).unwrap();
+ tcp.set_source(dest_port);
+ tcp.set_destination(source_port);
+ tcp.set_data_offset(5);
+ tcp.set_window(43440);
+ match &info.direction {
+ Direction::Send => {
+ tcp.set_sequence(self.state.rec_seq);
+ tcp.set_acknowledgement(self.state.send_seq);
+
+ info.direction = Direction::Receive;
+ }
+ Direction::Receive => {
+ tcp.set_sequence(self.state.send_seq);
+ tcp.set_acknowledgement(self.state.rec_seq);
+
+ info.direction = Direction::Send;
+ }
+ }
+ tcp.set_flags(TcpFlags::ACK);
+
+ tcp.packet_size()
+ };
+
+ self.write_transport_payload(
+ &info,
+ IpNextHeaderProtocols::Tcp,
+ &buf[.. buf_size],
+ vec![EnhancedPacketOption::Comment("Generated TCP ACK".into())],
+ );
+ }
+ Protocol::Udp => {
+ let buf_size = {
+ let mut udp = MutableUdpPacket::new(buf).unwrap();
+ udp.set_source(source_port);
+ udp.set_destination(dest_port);
+ udp.set_length((payload.len() + HEADER_SIZE_UDP) as u16);
+ udp.set_payload(payload);
+
+ udp.packet_size()
+ };
+
+ self.write_transport_payload(
+ info,
+ IpNextHeaderProtocols::Udp,
+ &buf[.. buf_size + payload.len()],
+ vec![],
+ );
+ }
+ }
+ }
+
+ /// Encode a network layer (IP) packet with a payload.
+ fn encode_ip_packet(
+ &self,
+ buf: &mut [u8],
+ info: &CapturePacket,
+ protocol: IpNextHeaderProtocol,
+ payload: &[u8],
+ ) -> (usize, EtherType) {
+ match info.ip_addr() {
+ (IpAddr::V4(_), IpAddr::V4(_)) => {
+ let (source, destination) = info.ipvt_by_direction();
+
+ let header_size = HEADER_SIZE_IP4 + (32 / 8);
+
+ let mut ip = MutableIpv4Packet::new(buf).unwrap();
+ ip.set_version(4);
+ ip.set_total_length((payload.len() + header_size) as u16);
+ ip.set_next_level_protocol(protocol);
+ // https://en.wikipedia.org/wiki/Internet_Protocol_version_4#Total_Length
+
+ ip.set_header_length((header_size / 4) as u8);
+ ip.set_source(source);
+ ip.set_destination(destination);
+ ip.set_payload(payload);
+ ip.set_ttl(64);
+ ip.set_flags(pnet_packet::ipv4::Ipv4Flags::DontFragment);
+
+ let mut options_writer =
+ pnet_packet::ipv4::MutableIpv4OptionPacket::new(ip.get_options_raw_mut()).unwrap();
+ options_writer.set_copied(1);
+ options_writer.set_class(0);
+ options_writer.set_number(pnet_packet::ipv4::Ipv4OptionNumbers::SID);
+ options_writer.set_length(&[4]);
+ options_writer.set_data(&(self.state.stream_count as u16).to_be_bytes());
+
+ ip.set_checksum(pnet_packet::ipv4::checksum(&ip.to_immutable()));
+
+ (ip.packet_size(), pnet_packet::ethernet::EtherTypes::Ipv4)
+ }
+ (IpAddr::V6(_), IpAddr::V6(_)) => {
+ let (source, destination) = info.ipvt_by_direction();
+
+ let mut ip = MutableIpv6Packet::new(buf).unwrap();
+ ip.set_version(6);
+ ip.set_payload_length(payload.len() as u16);
+ ip.set_next_header(protocol);
+ ip.set_source(source);
+ ip.set_destination(destination);
+ ip.set_hop_limit(64);
+ ip.set_payload(payload);
+ ip.set_flow_label(self.state.stream_count);
+
+ (ip.packet_size(), pnet_packet::ethernet::EtherTypes::Ipv6)
+ }
+ _ => unreachable!(),
+ }
+ }
+
+ /// Encode a physical layer (ethernet) packet with a payload.
+ fn encode_ethernet_packet(
+ &self,
+ buf: &mut [u8],
+ ethertype: pnet_packet::ethernet::EtherType,
+ payload: &[u8],
+ ) -> usize {
+ let mut ethernet = MutableEthernetPacket::new(buf).unwrap();
+ ethernet.set_ethertype(ethertype);
+ ethernet.set_payload(payload);
+
+ ethernet.packet_size()
+ }
+
+ /// Write a TCP handshake.
+ pub(crate) fn write_tcp_handshake(&mut self, info: &CapturePacket) {
+ let (source_port, dest_port) = (info.local_address.port(), info.remote_address.port());
+
+ let mut info = info.clone();
+ info.direction = Direction::Send;
+ let mut buffer_array: [u8; BUFFER_SIZE] = [0; BUFFER_SIZE];
+ let buf: &mut [u8] = &mut buffer_array[..];
+ // Add a generated comment to all packets
+ let options = vec![
+ pcap_file::pcapng::blocks::enhanced_packet::EnhancedPacketOption::Comment("Generated TCP handshake".into()),
+ ];
+
+ // SYN
+ let buf_size = {
+ let mut tcp = MutableTcpPacket::new(buf).unwrap();
+ self.state.send_seq = 500;
+ tcp.set_sequence(self.state.send_seq);
+ tcp.set_flags(TcpFlags::SYN);
+ tcp.set_source(source_port);
+ tcp.set_destination(dest_port);
+ tcp.set_window(43440);
+ tcp.set_data_offset(5);
+
+ tcp.packet_size()
+ };
+ self.write_transport_payload(
+ &info,
+ IpNextHeaderProtocols::Tcp,
+ &buf[.. buf_size],
+ options.clone(),
+ );
+
+ // SYN + ACK
+ info.direction = Direction::Receive;
+ let buf_size = {
+ let mut tcp = MutableTcpPacket::new(buf).unwrap();
+ self.state.send_seq = self.state.send_seq.wrapping_add(1);
+ tcp.set_acknowledgement(self.state.send_seq);
+ self.state.rec_seq = 1000;
+ tcp.set_sequence(self.state.rec_seq);
+ tcp.set_flags(TcpFlags::SYN | TcpFlags::ACK);
+ tcp.set_source(dest_port);
+ tcp.set_destination(source_port);
+ tcp.set_window(43440);
+ tcp.set_data_offset(5);
+
+ tcp.packet_size()
+ };
+ self.write_transport_payload(
+ &info,
+ IpNextHeaderProtocols::Tcp,
+ &buf[.. buf_size],
+ options.clone(),
+ );
+
+ // ACK
+ info.direction = Direction::Send;
+ let buf_size = {
+ let mut tcp = MutableTcpPacket::new(buf).unwrap();
+ tcp.set_sequence(self.state.send_seq);
+ self.state.rec_seq = self.state.rec_seq.wrapping_add(1);
+ tcp.set_acknowledgement(self.state.rec_seq);
+ tcp.set_flags(TcpFlags::ACK);
+ tcp.set_source(source_port);
+ tcp.set_destination(dest_port);
+ tcp.set_window(43440);
+ tcp.set_data_offset(5);
+
+ tcp.packet_size()
+ };
+ self.write_transport_payload(
+ &info,
+ IpNextHeaderProtocols::Tcp,
+ &buf[.. buf_size],
+ options,
+ );
+
+ self.state.has_sent_handshake = true;
+ }
+
+ pub(crate) fn send_tcp_fin(&mut self, info: &CapturePacket) {
+ let mut buffer_array: [u8; BUFFER_SIZE] = [0; BUFFER_SIZE];
+ let buf: &mut [u8] = &mut buffer_array[..];
+ let (source_port, dest_port) = info.ports_by_direction();
+
+ let buf_size = {
+ let mut tcp = MutableTcpPacket::new(buf).unwrap();
+ tcp.set_source(source_port);
+ tcp.set_destination(dest_port);
+ tcp.set_data_offset(5);
+ tcp.set_window(43440);
+
+ match info.direction {
+ Direction::Send => {
+ tcp.set_sequence(self.state.send_seq);
+ tcp.set_acknowledgement(self.state.rec_seq);
+ }
+ Direction::Receive => {
+ tcp.set_sequence(self.state.rec_seq);
+ tcp.set_acknowledgement(self.state.send_seq);
+ }
+ }
+
+ tcp.set_flags(TcpFlags::FIN | TcpFlags::ACK);
+ tcp.packet_size()
+ };
+
+ self.write_transport_payload(
+ info,
+ IpNextHeaderProtocols::Tcp,
+ &buf[.. buf_size],
+ vec![EnhancedPacketOption::Comment("Generated TCP FIN".into())],
+ );
+
+ // Update sequence number
+ match info.direction {
+ Direction::Send => {
+ self.state.send_seq = self.state.send_seq.wrapping_add(1);
+ }
+ Direction::Receive => {
+ self.state.rec_seq = self.state.rec_seq.wrapping_add(1);
+ }
+ }
+ }
+
+ fn write_transport_payload(
+ &mut self,
+ info: &CapturePacket,
+ protocol: IpNextHeaderProtocol,
+ payload: &[u8],
+ options: Vec,
+ ) {
+ let mut network_packet = vec![0; PACKET_SIZE - HEADER_SIZE_ETHERNET];
+ let (network_size, ethertype) = self.encode_ip_packet(&mut network_packet, info, protocol, payload);
+ let network_size = network_size + payload.len();
+ network_packet.truncate(network_size);
+
+ let mut physical_packet = vec![0; PACKET_SIZE];
+ let physical_size =
+ self.encode_ethernet_packet(&mut physical_packet, ethertype, &network_packet) + network_size;
+
+ physical_packet.truncate(physical_size);
+
+ self.writer
+ .write_block(
+ &pcap_file::pcapng::blocks::enhanced_packet::EnhancedPacketBlock {
+ original_len: physical_size as u32,
+ data: physical_packet.into(),
+ interface_id: 0,
+ timestamp: self.state.start_time.elapsed(),
+ options,
+ }
+ .into_block(),
+ )
+ .unwrap();
+ }
+}
+
+impl Default for State {
+ fn default() -> Self {
+ Self {
+ start_time: Instant::now(),
+ send_seq: 0,
+ rec_seq: 0,
+ has_sent_handshake: false,
+ stream_count: 0,
+ }
+ }
+}
diff --git a/crates/lib/src/capture/socket.rs b/crates/lib/src/capture/socket.rs
new file mode 100644
index 0000000..546e9b8
--- /dev/null
+++ b/crates/lib/src/capture/socket.rs
@@ -0,0 +1,214 @@
+use std::{marker::PhantomData, net::SocketAddr};
+
+use crate::{
+ capture::{
+ packet::CapturePacket,
+ packet::{Direction, Protocol},
+ writer::{Writer, CAPTURE_WRITER},
+ },
+ protocols::types::TimeoutSettings,
+ socket::{Socket, TcpSocketImpl, UdpSocketImpl},
+ GDResult,
+};
+
+/// Sets a global capture writer for handling all packet data.
+///
+/// # Panics
+/// Panics if a capture writer is already set.
+///
+/// # Arguments
+/// * `writer` - A boxed writer that implements the `Writer` trait.
+pub(crate) fn set_writer(writer: Box) {
+ let mut lock = CAPTURE_WRITER.lock().unwrap();
+
+ if lock.is_some() {
+ panic!("Capture writer already set");
+ }
+
+ *lock = Some(writer);
+}
+
+/// A trait representing a provider of a network protocol.
+pub(crate) trait ProtocolProvider {
+ /// Returns the protocol used by the provider.
+ fn protocol() -> Protocol;
+}
+
+/// Represents the TCP protocol provider.
+pub(crate) struct ProtocolTCP;
+impl ProtocolProvider for ProtocolTCP {
+ fn protocol() -> Protocol { Protocol::Tcp }
+}
+
+/// Represents the UDP protocol provider.
+pub(crate) struct ProtocolUDP;
+impl ProtocolProvider for ProtocolUDP {
+ fn protocol() -> Protocol { Protocol::Udp }
+}
+
+/// A socket wrapper that allows capturing packets.
+///
+/// # Type parameters
+/// * `I` - The inner socket type.
+/// * `P` - The protocol provider.
+#[derive(Clone, Debug)]
+pub(crate) struct WrappedCaptureSocket {
+ inner: I,
+ remote_address: SocketAddr,
+ _protocol: PhantomData
,
+}
+
+impl Socket for WrappedCaptureSocket {
+ /// Creates a new wrapped socket for capturing packets.
+ ///
+ /// Initializes a new socket of type `I`, wrapping it to enable packet
+ /// capturing. Capturing is protocol-specific, as indicated by
+ /// the `ProtocolProvider`.
+ ///
+ /// # Arguments
+ /// * `address` - The address to connect the socket to.
+ /// * `timeout_settings` - Optional timeout settings for the socket.
+ ///
+ /// # Returns
+ /// A `GDResult` containing either the wrapped socket or an error.
+ fn new(address: &SocketAddr, timeout_settings: &Option) -> GDResult
+ where Self: Sized {
+ let v = Self {
+ inner: I::new(address, timeout_settings)?,
+ remote_address: *address,
+ _protocol: PhantomData,
+ };
+
+ let info = CapturePacket {
+ direction: Direction::Send,
+ protocol: P::protocol(),
+ remote_address: address,
+ local_address: &v.local_addr().unwrap(),
+ };
+
+ if let Some(writer) = CAPTURE_WRITER.lock().unwrap().as_mut() {
+ writer.new_connect(&info)?;
+ }
+
+ Ok(v)
+ }
+
+ /// Sends data over the socket and captures the packet.
+ ///
+ /// The method sends data using the inner socket and captures the sent
+ /// packet if a capture writer is set.
+ ///
+ /// # Arguments
+ /// * `data` - Data to be sent.
+ ///
+ /// # Returns
+ /// A result indicating success or error in sending data.
+ fn send(&mut self, data: &[u8]) -> GDResult<()> {
+ let info = CapturePacket {
+ direction: Direction::Send,
+ protocol: P::protocol(),
+ remote_address: &self.remote_address,
+ local_address: &self.local_addr().unwrap(),
+ };
+
+ if let Some(writer) = CAPTURE_WRITER.lock().unwrap().as_mut() {
+ writer.write(&info, data)?;
+ }
+
+ self.inner.send(data)
+ }
+
+ /// Receives data from the socket and captures the packet.
+ ///
+ /// The method receives data using the inner socket and captures the
+ /// incoming packet if a capture writer is set.
+ ///
+ /// # Arguments
+ /// * `size` - Optional size of data to receive.
+ ///
+ /// # Returns
+ /// A result containing received data or an error.
+ fn receive(&mut self, size: Option) -> crate::GDResult> {
+ let data = self.inner.receive(size)?;
+ let info = CapturePacket {
+ direction: Direction::Receive,
+ protocol: P::protocol(),
+ remote_address: &self.remote_address,
+ local_address: &self.local_addr().unwrap(),
+ };
+
+ if let Some(writer) = CAPTURE_WRITER.lock().unwrap().as_mut() {
+ writer.write(&info, &data)?;
+ }
+
+ Ok(data)
+ }
+
+ /// Applies timeout settings to the wrapped socket.
+ ///
+ /// Delegates the operation to the inner socket implementation.
+ ///
+ /// # Arguments
+ /// * `timeout_settings` - Optional timeout settings to apply.
+ ///
+ /// # Returns
+ /// A result indicating success or error in applying timeouts.
+ fn apply_timeout(
+ &self,
+ timeout_settings: &Option,
+ ) -> crate::GDResult<()> {
+ self.inner.apply_timeout(timeout_settings)
+ }
+
+ /// Returns the remote port of the wrapped socket.
+ ///
+ /// Delegates the operation to the inner socket implementation.
+ ///
+ /// # Returns
+ /// The remote port number.
+ fn port(&self) -> u16 { self.inner.port() }
+
+ /// Returns the local SocketAddr of the wrapped socket.
+ ///
+ /// Delegates the operation to the inner socket implementation.
+ ///
+ /// # Returns
+ /// The local SocketAddr.
+ fn local_addr(&self) -> std::io::Result { self.inner.local_addr() }
+}
+
+// this seems a bad way to do this, but its safe
+impl Drop for WrappedCaptureSocket {
+ fn drop(&mut self) {
+ // Construct the CapturePacket info
+ let info = CapturePacket {
+ direction: Direction::Send,
+ protocol: P::protocol(),
+ remote_address: &self.remote_address,
+ local_address: &self
+ .local_addr()
+ .unwrap_or_else(|_| SocketAddr::new(std::net::IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED), 0)),
+ };
+
+ // If a capture writer is set, close the connection and capture the packet.
+ if let Some(writer) = CAPTURE_WRITER.lock().unwrap().as_mut() {
+ let _ = writer.close_connection(&info);
+ }
+ }
+}
+
+/// A specialized `WrappedCaptureSocket` for UDP, using `UdpSocketImpl` as
+/// the inner socket and `ProtocolUDP` as the protocol provider.
+///
+/// This type captures and processes UDP packets, wrapping around standard
+/// UDP socket functionalities with additional packet capture
+/// capabilities.
+pub(crate) type CapturedUdpSocket = WrappedCaptureSocket;
+
+/// A specialized `WrappedCaptureSocket` for TCP, using `TcpSocketImpl` as
+/// the inner socket and `ProtocolTCP` as the protocol provider.
+///
+/// This type captures and processes TCP packets, wrapping around standard
+/// TCP socket functionalities with additional packet capture
+/// capabilities.
+pub(crate) type CapturedTcpSocket = WrappedCaptureSocket;
diff --git a/crates/lib/src/capture/writer.rs b/crates/lib/src/capture/writer.rs
new file mode 100644
index 0000000..d3a9db0
--- /dev/null
+++ b/crates/lib/src/capture/writer.rs
@@ -0,0 +1,86 @@
+use std::{io::Write, sync::Mutex};
+
+use super::{
+ packet::{CapturePacket, Protocol},
+ pcap::Pcap,
+};
+use crate::GDResult;
+use lazy_static::lazy_static;
+
+lazy_static! {
+ /// A globally accessible, lazily-initialized static writer instance.
+ /// This writer is intended for capturing and recording network packets.
+ /// The writer is wrapped in a Mutex to ensure thread-safe access and modification.
+ pub(crate) static ref CAPTURE_WRITER: Mutex