├── .eslintrc.json ├── .gitattributes ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── PULL_REQUEST_TEMPLATE │ ├── feature.md │ └── fix.md ├── dependabot.yml └── workflows │ ├── bun.yml │ ├── deno.yml │ ├── games_list.yml │ ├── id-tests.yml │ └── node.yml ├── .gitignore ├── .nvmrc ├── CHANGELOG.md ├── CONTRIBUTING.md ├── GAMES_LIST.md ├── LICENSE ├── MIGRATE_IDS.md ├── README.md ├── bin └── gamedig.js ├── examples ├── import.mjs └── require.cjs ├── lib ├── DnsResolver.js ├── GlobalUdpSocket.js ├── HexUtil.js ├── Logger.js ├── Promises.js ├── ProtocolResolver.js ├── QueryRunner.js ├── Results.js ├── game-resolver.js ├── gamedig.js ├── games.js ├── index.js └── reader.js ├── package-lock.json ├── package.json ├── protocols ├── altvmp.js ├── armagetron.js ├── asa.js ├── ase.js ├── assettocorsa.js ├── battlefield.js ├── beammp.js ├── beammpmaster.js ├── brokeprotocol.js ├── brokeprotocolmaster.js ├── buildandshoot.js ├── core.js ├── cs2d.js ├── dayz.js ├── discord.js ├── doom3.js ├── eco.js ├── eldewrito.js ├── epic.js ├── factorio.js ├── farmingsimulator.js ├── ffow.js ├── fivem.js ├── gamespy1.js ├── gamespy2.js ├── gamespy3.js ├── geneshift.js ├── goldsrc.js ├── gtasao.js ├── hawakening.js ├── hawakeningmaster.js ├── hexen2.js ├── index.js ├── jc2mp.js ├── kspdmp.js ├── mafia2mp.js ├── mafia2online.js ├── minecraft.js ├── minecraftbedrock.js ├── minecraftvanilla.js ├── minetest.js ├── mumble.js ├── mumbleping.js ├── nadeo.js ├── openttd.js ├── palworld.js ├── quake1.js ├── quake2.js ├── quake3.js ├── ragemp.js ├── renegadex.js ├── renegadexmaster.js ├── renown.js ├── rfactor.js ├── samp.js ├── satisfactory.js ├── savage2.js ├── sdtd.js ├── soldat.js ├── starmade.js ├── starsiege.js ├── teamspeak2.js ├── teamspeak3.js ├── terraria.js ├── theisleevrima.js ├── toxikk.js ├── tribes1.js ├── tribes1master.js ├── unreal2.js ├── ut3.js ├── valve.js ├── vcmp.js ├── ventrilo.js ├── vintagestory.js ├── vintagestorymaster.js ├── warsow.js └── xonotic.js └── tools ├── README.md ├── attempt_protocols.js ├── esbuild.js ├── find-id-changes.js ├── find_id_duplicates.js ├── generate_games_list.js └── run-id-tests.js /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": false, 4 | "es2021": true 5 | }, 6 | "extends": "standard", 7 | "parserOptions": { 8 | "ecmaVersion": 2021, 9 | "sourceType": "module" 10 | }, 11 | "rules": { 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: 'bug: something is not working' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **Steps To Reproduce** 14 | Please provide the steps to reproduce the behavior, this should include what version of the library you are using, a code example and (if applicable and possible) the server IP you are trying to query. 15 | 16 | **Expected behavior** 17 | A clear and concise description of what you expected to happen. 18 | 19 | **Screenshots or Data** 20 | If applicable, add screenshots/data to help explain your problem, if this is about a query, run the CLI version with the `--debug` option and put the output in a collapsible section. 21 |
22 | ``` 23 | How to do a collapsible section: 24 |
25 | This is hidden until it is not! 26 |
27 | ``` 28 |
29 | 30 | **Additional context** 31 | Add any other context about the problem here. 32 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: 'feat: new stuff!' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | **What is this feature about?** 11 | Shortly explain what your requested feature is about. 12 | 13 | **Additional context/references** 14 | Add any other context or references about the feature request here. 15 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE/feature.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature 3 | about: Add something new and hopefully useful 4 | title: 'feat: add this game' 5 | labels: feature 6 | assignees: '' 7 | 8 | --- 9 | 10 | 11 | **Describe the changes** 12 | - [ ] I have added a line in the [CHANGELOG.md](https://github.com/gamedig/node-gamedig/blob/master/CHANGELOG.md) file. 13 | 14 | 15 | 16 | A clear and concise summary of the changes. 17 | 18 | **Screenshots or Data** 19 | If applicable, add screenshots/data to help explain the changes. 20 |
21 | This contains lots and lots of logs. 22 | ``` 23 | How to do a collapsible section: 24 |
25 | This is hidden until it is not! 26 |
27 | ``` 28 |
29 | 30 | **Additional context** 31 | Add any other context about the changes here, for example: 32 | * 'Closes issue #T, lore-related to issue #F, mentioned in pr #Two' 33 | * 'To validate the changes, one can test on this address: 127.255.0.256' 34 | * 'I couldn't find any live servers to validate these changes, here are the docs on how to start a local server: www.google.com' 35 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE/fix.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Fix 3 | about: Change something broken to make it not broken anymore 4 | title: 'fix: 1 === 1 is false' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | 11 | **Describe the changes** 12 | - [ ] I have added a line in the [CHANGELOG.md](https://github.com/gamedig/node-gamedig/blob/master/CHANGELOG.md) file. 13 | 14 | 15 | 16 | A clear and concise summary of the changes. 17 | 18 | **Screenshots or Data** 19 | If applicable, add screenshots/data to help explain the changes. 20 |
21 | This contains lots and lots of logs. 22 | ``` 23 | How to do a collapsible section: 24 |
25 | This is hidden until it is not! 26 |
27 | ``` 28 |
29 | 30 | **Additional context** 31 | Add any other context about the changes here, for example: 32 | * 'Closes issue #T, lore-related to issue #F, mentioned in pr #Two' 33 | * 'To validate the changes, one can test on this address: 127.255.0.256' 34 | * 'I couldn't find any live servers to validate these changes, here are the docs on how to start a local server: www.google.com' 35 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | 2 | version: 2 3 | updates: 4 | - package-ecosystem: "npm" 5 | directory: "/" 6 | schedule: 7 | interval: "weekly" 8 | versioning-strategy: increase 9 | -------------------------------------------------------------------------------- /.github/workflows/bun.yml: -------------------------------------------------------------------------------- 1 | 2 | name: Bun 3 | 4 | on: 5 | push: 6 | branches: ["master"] 7 | paths: 8 | - "**.js" # Any JS file 9 | - "package.json" 10 | - "package-lock.json" 11 | - ".github/workflows/bun.yml" # This action 12 | pull_request: 13 | branches: ["master"] 14 | paths: 15 | - "**.js" # Any JS file 16 | - "package.json" 17 | - "package-lock.json" 18 | - ".github/workflows/bun.yml" # This action 19 | 20 | permissions: 21 | contents: read 22 | 23 | jobs: 24 | build: 25 | runs-on: ubuntu-latest 26 | 27 | steps: 28 | - name: Setup repo 29 | uses: actions/checkout@v3 30 | 31 | - uses: oven-sh/setup-bun@v2 32 | with: 33 | bun-version: 1.1.21 34 | 35 | - name: Install Dependencies 36 | run: bun install 37 | 38 | - name: Compile 39 | run: bun build bin/gamedig.js --target=bun 40 | -------------------------------------------------------------------------------- /.github/workflows/deno.yml: -------------------------------------------------------------------------------- 1 | 2 | name: Deno 3 | 4 | on: 5 | push: 6 | branches: ["master"] 7 | paths: 8 | - "**.js" # Any JS file 9 | - "package.json" 10 | - "package-lock.json" 11 | - ".github/workflows/deno.yml" # This action 12 | pull_request: 13 | branches: ["master"] 14 | paths: 15 | - "**.js" # Any JS file 16 | - "package.json" 17 | - "package-lock.json" 18 | - ".github/workflows/deno.yml" # This action 19 | 20 | permissions: 21 | contents: read 22 | 23 | jobs: 24 | build: 25 | runs-on: ubuntu-latest 26 | 27 | steps: 28 | - name: Setup repo 29 | uses: actions/checkout@v3 30 | 31 | - name: Setup Deno 32 | # uses: denoland/setup-deno@v1 33 | uses: denoland/setup-deno@61fe2df320078202e33d7d5ad347e7dcfa0e8f31 # v1.1.2 34 | with: 35 | deno-version: v1.39.2 36 | 37 | - name: Compile 38 | run: deno compile --allow-net bin/gamedig.js 39 | -------------------------------------------------------------------------------- /.github/workflows/games_list.yml: -------------------------------------------------------------------------------- 1 | name: Games List Markdown Validation 2 | 3 | on: 4 | push: 5 | paths: 6 | - "lib/games.js" 7 | - "GAMES_LIST.md" 8 | - ".github/workflows/games_list.yml" # This action 9 | pull_request: 10 | paths: 11 | - "lib/games.js" 12 | - "GAMES_LIST.md" 13 | - ".github/workflows/games_list.yml" # This action 14 | workflow_dispatch: 15 | 16 | permissions: 17 | contents: read 18 | 19 | jobs: 20 | check_file: 21 | 22 | runs-on: ubuntu-latest 23 | 24 | steps: 25 | - uses: actions/checkout@v3 26 | 27 | - name: Use Node 28 | uses: actions/setup-node@v3 29 | with: 30 | node-version: 18.x 31 | 32 | - name: Run games list generation 33 | run: node tools/generate_games_list.js 34 | 35 | - name: Check for changes 36 | run: git diff --exit-code GAMES_LIST.md 37 | -------------------------------------------------------------------------------- /.github/workflows/id-tests.yml: -------------------------------------------------------------------------------- 1 | name: ID tests 2 | 3 | on: 4 | push: 5 | paths: 6 | - "lib/games.js" 7 | - ".github/workflows/id-tests.yml" # This action 8 | pull_request: 9 | paths: 10 | - "lib/games.js" 11 | - ".github/workflows/id-tests.yml" # This action 12 | workflow_dispatch: 13 | 14 | permissions: 15 | contents: read 16 | 17 | jobs: 18 | test: 19 | 20 | runs-on: ubuntu-latest 21 | 22 | steps: 23 | - uses: actions/checkout@v3 24 | 25 | - name: Use Node 26 | uses: actions/setup-node@v3 27 | with: 28 | node-version: 18.x 29 | 30 | - name: Cache rust dependencies 31 | uses: Swatinem/rust-cache@v2 32 | with: 33 | cache-on-failure: 'true' 34 | 35 | - name: Install ID tester 36 | run: cargo install --git https://github.com/gamedig/rust-gamedig.git gamedig-id-tests 37 | 38 | - name: Run ID tests 39 | run: node tools/run-id-tests.js 40 | -------------------------------------------------------------------------------- /.github/workflows/node.yml: -------------------------------------------------------------------------------- 1 | 2 | name: Node 3 | 4 | on: 5 | push: 6 | branches: [ "master" ] 7 | paths: 8 | - "**.js" # Any JS file 9 | - "package.json" 10 | - "package-lock.json" 11 | - ".github/workflows/node.yml" # This action 12 | pull_request: 13 | branches: [ "master" ] 14 | paths: 15 | - "**.js" # Any JS file 16 | - "package.json" 17 | - "package-lock.json" 18 | - ".github/workflows/node.yml" # This action 19 | 20 | permissions: 21 | contents: read 22 | 23 | jobs: 24 | build: 25 | 26 | runs-on: ubuntu-latest 27 | 28 | strategy: 29 | matrix: 30 | node-version: [16.20.0, 18.x, 20.x, 22.x] 31 | 32 | steps: 33 | - uses: actions/checkout@v3 34 | - name: Use Node ${{ matrix.node-version }} 35 | uses: actions/setup-node@v3 36 | with: 37 | node-version: ${{ matrix.node-version }} 38 | cache: 'npm' 39 | - run: npm ci 40 | - run: npm run build --if-present 41 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /npm-debug.log 3 | /*.iml 4 | /.idea 5 | /dist 6 | # Deno bin/gamedig executable 7 | gamedig 8 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gamedig/node-gamedig/96c8e725d8fd7780166fde30f36c2d2bb0cc90ad/.nvmrc -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to node-GameDig 2 | This project is very open to new suggestions, additions and/or changes, these 3 | can come in the form of *discussions* about the project's state, *proposing a 4 | new feature*, *holding a few points on why we shall do X breaking change* or 5 | *submitting a fix*. 6 | 7 | ## Communications 8 | GitHub is the place we use to track bugs and discuss new features/changes, 9 | although we have a [Discord](https://discord.gg/NVCMn3tnxH) server for the 10 | community, all bugs, suggestions and changes will be reported on GitHub 11 | alongside with their backing points to ensure the transparency of the project's 12 | development. 13 | 14 | ## Issues 15 | Before opening an issue, check if there is an existing relevant issue first, 16 | someone might just have had your issue already, or you might find something 17 | related that could be of help. 18 | 19 | When opening a new issue, make sure to fill the issue template. They are made 20 | to make the subject to be as understandable as possible, not doing so may result 21 | in your issue not being managed right away, if you don't understand something 22 | (be it regarding your own problem/the issue template/the library), please state 23 | so. 24 | 25 | ## Development 26 | Note before contributing that everything done here is under the [MIT](https://opensource.org/license/mit/) license. 27 | ### Naming 28 | Naming is an important matter, and it shouldn't be changed unless necessary. 29 | 30 | Game **names** should be added as they appear on steam (or other storefront 31 | if not listed there) with the release year appended in brackets (except when the 32 | release year is already part of the name). 33 | If there is a mod that needs to be added (or it adds the support for server 34 | queries for the game), its name should be composed of the game name, a separating 35 | **bracket**, the mod name and the release year as specified previously 36 | (e.g. `Grand Theft Auto V - FiveM (2013)`). 37 | 38 | A game's **identification** is a lowercase alphanumeric string will and be forged 39 | following these rules: 40 | 1. Names composed of a maximum of two words (unless #4 applies) will result in an 41 | id where the words are concatenated (`Dead Cells` -> `deadcells`), acronyms in 42 | the name count as a single word (`S.T.A.L.K.E.R.` -> `stalker`). 43 | 2. Names of more than two words shall be made into an acronym made of the 44 | initial letters (`The Binding of Isaac` -> `tboi`), [hypenation composed words](https://prowritingaid.com/hyphenated-words) 45 | don't count as a single word, but of how many parts they are made of 46 | (`Dino D-Day`, 3 words, so `ddd`). 47 | 3. If a game has the exact name as a previously existing id's game 48 | (`Star Wars Battlefront 2`, the 2005 and 2017 one), append the release year to 49 | the newer id (2005 would be `swb2` (suppose we already have this one supported) 50 | and 2017 would be `swb22017`). 51 | 4. If a new id (`Day of Dragons` -> `dod`) results in an id that already exists 52 | (`Day of Defeat` -> `dod`), then the new name should ignore rule #2 53 | (`Day of Dragons` -> `dayofdragons`). 54 | 5. Roman numbering will be converted to arabic numbering (`XIV` -> `14`). 55 | 6. Unless numbers (years included) are at the end of a name, they will be considered 56 | words. If a number is not in the first position, its entire numeric digits will be 57 | used instead of the acronym of that number's digits (`Left 4 Dead` -> `l4d`). If the 58 | number is in the first position the longhand (words: 5 -> five) representation of the 59 | number will be used to create an acronym (`7 Days to Die` -> `sdtd`). Other examples: 60 | `Team Fortress 2` -> `teamfortress2`, `Unreal Tournament 2003` -> 61 | `unrealtournament2003`. 62 | 7. If a game supports multiple protocols, multiple entries will be done for said game 63 | where the edition/protocol name (first disposable in this order) will be appended to 64 | the base game id's: `` (where the protocol id will follow all 65 | rules except #2) (Minecraft is mainly divided by 2 editions, Java and Bedrock 66 | which will be `minecraftjava` and `minecraftbedrock` respectively, but it also has 67 | legacy versions, which use another protocol, an example would be the one for `1.6`, 68 | so the name would be `Legacy 1.6` which its id will be `legacy16`, resulting in the 69 | entry of `minecraftlegacy16`). One more entry can be added by the base name of the 70 | game, which queries in a group said supported protocols to make generic queries 71 | easier and disposable. 72 | 8. If its actually about a mod that adds the ability for queries to be performed, 73 | process only the mod name. 74 | 75 | ### Priorities 76 | Game suggestions will be prioritized by maintainers based on whether the game 77 | uses a protocol already implemented in the library (games that use already 78 | implemented protocols will be added first), except in the case where a 79 | contribution is made with the protocol needed to implement the game. 80 | 81 | The same goes for protocols, if 2 were to be requested, the one implemented in 82 | the most games will be prioritized. 83 | 84 | ### Releases 85 | Currently, there is no exact release schedule. 86 | We use following versioning: MAJOR.MINOR.PATCH 87 | 88 | Whereas: 89 | MAJOR: Brings incompatible API changes. 90 | MINOR: Adding functionality in a backward compatible manner. 91 | PATCH: Bug fixes, games support, docs, dependencies patches. 92 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) node-gamedig developers 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. -------------------------------------------------------------------------------- /MIGRATE_IDS.md: -------------------------------------------------------------------------------- 1 | # Migrating game ids from v4 to v5 2 | 3 | **Tip**: Checkout the [changelog](CHANGELOG.md) file to see all changes. 4 | 5 | ## Game IDs 6 | The naming system used to determine the Game IDs have been updated in `v5` and some IDs have been changed. 7 | This means some ids will not work as they could have been changed to something else, see the table below to check which one is which. 8 | (*Don't see your id here? That means it stayed the same.*) 9 | 10 | **Note**: If you are heavily using the old ids, just pass the `checkOldIDs` as true in options (or `--checkOldIDs` via the CLI) to also use them. 11 | **Warning**: We strongly recommend that you update your game id, as these older IDs will eventually not be supported anymore and will be removed. 12 | 13 | ### Old IDs Table 14 | | v4 | | v5 | 15 | |:---------------------|:--|:---------------------| 16 | | americasarmypg | → | aapg | 17 | | 7d2d | → | sdtd | 18 | | americasarmypg | → | aapg | 19 | | as | → | actionsource | 20 | | ageofchivalry | → | aoc | 21 | | arkse | → | ase | 22 | | arcasimracing | → | asr08 | 23 | | arma | → | aaa | 24 | | arma2oa | → | a2oa | 25 | | armacwa | → | acwa | 26 | | armar | → | armaresistance | 27 | | armare | → | armareforger | 28 | | armagetron | → | armagetronadvanced | 29 | | bat1944 | → | battalion1944 | 30 | | bf1942 | → | battlefield1942 | 31 | | bfv | → | battlefieldvietnam | 32 | | bf2 | → | battlefield2 | 33 | | bf2142 | → | battlefield2142 | 34 | | bfbc2 | → | bbc2 | 35 | | bf3 | → | battlefield3 | 36 | | bf4 | → | battlefield4 | 37 | | bfh | → | battlefieldhardline | 38 | | bd | → | basedefense | 39 | | bs | → | bladesymphony | 40 | | buildandshoot | → | bas | 41 | | cod4 | → | cod4mw | 42 | | callofjuarez | → | coj | 43 | | chivalry | → | cmw | 44 | | commandos3 | → | c3db | 45 | | cacrenegade | → | cacr | 46 | | contactjack | → | contractjack | 47 | | cs15 | → | counterstrike15 | 48 | | cs16 | → | counterstrike16 | 49 | | cs2 | → | counterstrike2 | 50 | | crossracing | → | crce | 51 | | darkesthour | → | dhe4445 | 52 | | daysofwar | → | dow | 53 | | deadlydozenpt | → | ddpt | 54 | | dh2005 | → | deerhunter2005 | 55 | | dinodday | → | ddd | 56 | | dirttrackracing2 | → | dtr2 | 57 | | dmc | → | deathmatchclassic | 58 | | dnl | → | dal | 59 | | drakan | → | dootf | 60 | | dys | → | dystopia | 61 | | em | → | empiresmod | 62 | | empyrion | → | egs | 63 | | f12002 | → | formulaone2002 | 64 | | flashpointresistance | → | ofr | 65 | | fivem | → | gta5f | 66 | | forrest | → | theforrest | 67 | | graw | → | tcgraw | 68 | | graw2 | → | tcgraw2 | 69 | | giantscitizenkabuto | → | gck | 70 | | ges | → | goldeneyesource | 71 | | gore | → | gus | 72 | | hldm | → | hld | 73 | | hldms | → | hlds | 74 | | hlopfor | → | hlof | 75 | | hl2dm | → | hl2d | 76 | | hidden | → | thehidden | 77 | | had2 | → | hiddendangerous2 | 78 | | igi2 | → | i2cs | 79 | | il2 | → | il2sturmovik | 80 | | insurgencymic | → | imic | 81 | | isle | → | theisle | 82 | | jamesbondnightfire | → | jb007n | 83 | | jc2mp | → | jc2m | 84 | | jc3mp | → | jc3m | 85 | | kingpin | → | kloc | 86 | | kisspc | → | kpctnc | 87 | | kspdmp | → | kspd | 88 | | kzmod | → | kreedzclimbing | 89 | | left4dead | → | l4d | 90 | | left4dead2 | → | l4d2 | 91 | | m2mp | → | m2m | 92 | | mohsh | → | mohaas | 93 | | mohbt | → | mohaab | 94 | | mohab | → | moha | 95 | | moh2010 | → | moh | 96 | | mohwf | → | mohw | 97 | | minecraftbe | → | mbe | 98 | | mtavc | → | gtavcmta | 99 | | mtasa | → | gtasamta | 100 | | ns | → | naturalselection | 101 | | ns2 | → | naturalselection2 | 102 | | nwn | → | neverwinternights | 103 | | nwn2 | → | neverwinternights2 | 104 | | nolf | → | tonolf | 105 | | nolf2 | → | nolf2asihw | 106 | | pvkii | → | pvak2 | 107 | | ps | → | postscriptum | 108 | | primalcarnage | → | pce | 109 | | pc | → | projectcars | 110 | | pc2 | → | projectcars2 | 111 | | prbf2 | → | prb2 | 112 | | przomboid | → | projectzomboid | 113 | | quake1 | → | quake | 114 | | quake3 | → | q3a | 115 | | ragdollkungfu | → | rdkf | 116 | | r6 | → | rainbowsix | 117 | | r6roguespear | → | rs2rs | 118 | | r6ravenshield | → | rs3rs | 119 | | redorchestraost | → | roo4145 | 120 | | redm | → | rdr2r | 121 | | riseofnations | → | ron | 122 | | rs2 | → | rs2v | 123 | | samp | → | gtasam | 124 | | saomp | → | gtasao | 125 | | savage2 | → | s2ats | 126 | | ss | → | serioussam | 127 | | ss2 | → | serioussam2 | 128 | | ship | → | theship | 129 | | sinep | → | sinepisodes | 130 | | sonsoftheforest | → | sotf | 131 | | swbf | → | swb | 132 | | swbf2 | → | swb2 | 133 | | swjk | → | swjkja | 134 | | swjk2 | → | swjk2jo | 135 | | takeonhelicopters | → | toh | 136 | | tf2 | → | teamfortress2 | 137 | | terraria | → | terrariatshock | 138 | | tribes1 | → | t1s | 139 | | ut | → | unrealtournament | 140 | | ut2003 | → | unrealtournament2003 | 141 | | ut2004 | → | unrealtournament2004 | 142 | | ut3 | → | unrealtournament3 | 143 | | v8supercar | → | v8sc | 144 | | vcmp | → | vcm | 145 | | vs | → | vampireslayer | 146 | | wheeloftime | → | wot | 147 | | wolfenstein2009 | → | wolfenstein | 148 | | wolfensteinet | → | wet | 149 | | wurm | → | wurmunlimited | 150 | -------------------------------------------------------------------------------- /bin/gamedig.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import * as process from 'node:process' 4 | 5 | import Minimist from 'minimist' 6 | import { GameDig } from './../lib/index.js' 7 | 8 | const argv = Minimist(process.argv.slice(2), { 9 | boolean: ['pretty', 'debug', 'givenPortOnly', 'requestRules', 'requestPlayers', 'requestRulesRequired', 'requestPlayersRequired', 'stripColors', 'portCache', 'noBreadthOrder', 'checkOldIDs', 'rejectUnauthorized'], 10 | string: ['guildId', 'serverId', 'listenUdpPort', 'ipFamily', 'token'], 11 | default: { 12 | stripColors: true, 13 | portCache: true, 14 | requestPlayers: true 15 | } 16 | }) 17 | 18 | const options = {} 19 | for (const key of Object.keys(argv)) { 20 | const value = argv[key] 21 | 22 | if (key === '_' || key.charAt(0) === '$') { continue } 23 | 24 | options[key] = value 25 | } 26 | 27 | // Separate host and port 28 | if (argv._.length >= 1) { 29 | const target = argv._[0] 30 | const split = target.split(':') 31 | options.host = split[0] 32 | if (split.length > 1) { 33 | options.port = split[1] 34 | } 35 | } 36 | 37 | const { debug, pretty } = options 38 | 39 | const printOnPretty = (object) => { 40 | if (!!pretty || debug) { 41 | console.log(JSON.stringify(object, null, ' ')) 42 | } else { 43 | console.log(JSON.stringify(object)) 44 | } 45 | } 46 | 47 | const gamedig = new GameDig(options) 48 | gamedig.query(options) 49 | .then(printOnPretty) 50 | .catch((error) => { 51 | if (debug) { 52 | if (error instanceof Error) { 53 | console.log(error.stack) 54 | } else { 55 | console.log(error) 56 | } 57 | } else { 58 | if (error instanceof Error) { 59 | error = error.message 60 | } 61 | 62 | printOnPretty({ error }) 63 | } 64 | }) 65 | -------------------------------------------------------------------------------- /examples/import.mjs: -------------------------------------------------------------------------------- 1 | import { GameDig } from '../lib/index.js' 2 | // Instead of '../lib/index.js' you would have here 'gamedig'. 3 | 4 | GameDig.query({ 5 | type: 'minecraft', 6 | host: 'mc.hypixel.net', 7 | port: 25565, // lets us explicitly specify the query port of this server 8 | givenPortOnly: true // the library will attempt multiple ports in order to ensure success, to avoid this pass this option 9 | }).then((state) => { 10 | console.log(state) 11 | }).catch((error) => { 12 | console.log(`Server is offline, error: ${error}`) 13 | }) 14 | -------------------------------------------------------------------------------- /examples/require.cjs: -------------------------------------------------------------------------------- 1 | const { GameDig } = require('../dist/index.cjs') 2 | // Instead of '../dist/index.cjs' you would have here 'gamedig'. 3 | 4 | GameDig.query({ 5 | type: 'minecraft', 6 | host: 'mc.hypixel.net', 7 | port: 25565, // lets us explicitly specify the query port of this server 8 | givenPortOnly: true // the library will attempt multiple ports in order to ensure success, to avoid this pass this option 9 | }).then((state) => { 10 | console.log(state) 11 | }).catch((error) => { 12 | console.log(`Server is offline, error: ${error}`) 13 | }) 14 | -------------------------------------------------------------------------------- /lib/DnsResolver.js: -------------------------------------------------------------------------------- 1 | import dns from 'node:dns' 2 | import { isIP } from 'node:net' 3 | import { domainToASCII } from 'node:url' 4 | 5 | export default class DnsResolver { 6 | /** 7 | * @param {Logger} logger 8 | */ 9 | constructor (logger) { 10 | this.logger = logger 11 | } 12 | 13 | /** 14 | * Resolve a host name to its IP, if the given host name is already 15 | * an IP address no request is made. 16 | * 17 | * If a srvRecordPrefix is provided a SRV request will be made and the 18 | * port returned will be included in the output. 19 | * @param {string} host 20 | * @param {number} ipFamily 21 | * @param {string=} srvRecordPrefix 22 | * @returns {Promise<{address:string, port:number=}>} 23 | */ 24 | async resolve (host, ipFamily, srvRecordPrefix) { 25 | this.logger.debug('DNS Lookup: ' + host) 26 | 27 | // Check if host is IPv4 or IPv6 28 | if (isIP(host) === 4 || isIP(host) === 6) { 29 | this.logger.debug('Raw IP Address: ' + host) 30 | return { address: host } 31 | } 32 | 33 | const asciiForm = domainToASCII(host) 34 | if (!asciiForm) { 35 | throw new Error('Invalid domain') 36 | } 37 | 38 | if (asciiForm !== host) { 39 | this.logger.debug('Encoded punycode: ' + host + ' -> ' + asciiForm) 40 | host = asciiForm 41 | } 42 | 43 | if (srvRecordPrefix) { 44 | this.logger.debug('SRV Resolve: ' + srvRecordPrefix + '.' + host) 45 | let records 46 | try { 47 | records = await dns.promises.resolve(srvRecordPrefix + '.' + host, 'SRV') 48 | if (records.length >= 1) { 49 | this.logger.debug('Found SRV Records: ', records) 50 | const record = records[0] 51 | const srvPort = record.port 52 | const srvHost = record.name 53 | if (srvHost === host) { 54 | throw new Error('Loop in DNS SRV records') 55 | } 56 | return { 57 | port: srvPort, 58 | ...await this.resolve(srvHost, ipFamily, srvRecordPrefix) 59 | } 60 | } 61 | this.logger.debug('No SRV Record') 62 | } catch (e) { 63 | this.logger.debug(e) 64 | } 65 | } 66 | 67 | this.logger.debug('Standard Resolve: ' + host) 68 | const dnsResult = await dns.promises.lookup(host, ipFamily) 69 | // For some reason, this sometimes returns a string address rather than an object. 70 | // I haven't been able to reproduce, but it's been reported on the issue tracker. 71 | let address 72 | if (typeof dnsResult === 'string') { 73 | address = dnsResult 74 | } else { 75 | address = dnsResult.address 76 | } 77 | this.logger.debug('Found address: ' + address) 78 | return { address } 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /lib/GlobalUdpSocket.js: -------------------------------------------------------------------------------- 1 | import { createSocket } from 'node:dgram' 2 | import { debugDump } from './HexUtil.js' 3 | import { promisify } from 'node:util' 4 | import Logger from './Logger.js' 5 | 6 | export default class GlobalUdpSocket { 7 | constructor ({ port }) { 8 | this.socket = null 9 | this.callbacks = new Set() 10 | this.debuggingCallbacks = new Set() 11 | this.logger = new Logger() 12 | this.port = port 13 | } 14 | 15 | async _getSocket () { 16 | if (!this.socket) { 17 | const udpSocket = createSocket({ 18 | type: 'udp4', 19 | reuseAddr: true 20 | }) 21 | udpSocket.unref() 22 | udpSocket.on('message', (buffer, rinfo) => { 23 | const fromAddress = rinfo.address 24 | const fromPort = rinfo.port 25 | this.logger.debug(log => { 26 | log(fromAddress + ':' + fromPort + ' <--UDP(' + this.port + ')') 27 | log(debugDump(buffer)) 28 | }) 29 | for (const callback of this.callbacks) { 30 | callback(fromAddress, fromPort, buffer) 31 | } 32 | }) 33 | udpSocket.on('error', e => { 34 | this.logger.debug('UDP ERROR:', e) 35 | }) 36 | await promisify(udpSocket.bind).bind(udpSocket)(this.port) 37 | this.port = udpSocket.address().port 38 | this.socket = udpSocket 39 | } 40 | return this.socket 41 | } 42 | 43 | async send (buffer, address, port, debug) { 44 | const socket = await this._getSocket() 45 | 46 | if (debug) { 47 | this.logger._print(log => { 48 | log(address + ':' + port + ' UDP(' + this.port + ')-->') 49 | log(debugDump(buffer)) 50 | }) 51 | } 52 | 53 | await promisify(socket.send).bind(socket)(buffer, 0, buffer.length, port, address) 54 | } 55 | 56 | addCallback (callback, debug) { 57 | this.callbacks.add(callback) 58 | if (debug) { 59 | this.debuggingCallbacks.add(callback) 60 | this.logger.debugEnabled = true 61 | } 62 | } 63 | 64 | removeCallback (callback) { 65 | this.callbacks.delete(callback) 66 | this.debuggingCallbacks.delete(callback) 67 | this.logger.debugEnabled = this.debuggingCallbacks.size > 0 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /lib/HexUtil.js: -------------------------------------------------------------------------------- 1 | /** @param {Buffer} buffer */ 2 | export const debugDump = (buffer) => { 3 | let hexLine = '' 4 | let chrLine = '' 5 | let out = '' 6 | out += 'Buffer length: ' + buffer.length + ' bytes\n' 7 | for (let i = 0; i < buffer.length; i++) { 8 | const sliced = buffer.slice(i, i + 1) 9 | hexLine += sliced.toString('hex') + ' ' 10 | let chr = sliced.toString() 11 | if (chr < ' ' || chr > '~') chr = ' ' 12 | chrLine += chr + ' ' 13 | if (hexLine.length > 60 || i === buffer.length - 1) { 14 | out += hexLine + '\n' 15 | out += chrLine + '\n' 16 | hexLine = chrLine = '' 17 | } 18 | } 19 | return out 20 | } 21 | -------------------------------------------------------------------------------- /lib/Logger.js: -------------------------------------------------------------------------------- 1 | import { debugDump } from './HexUtil.js' 2 | import { Buffer } from 'node:buffer' 3 | 4 | export default class Logger { 5 | constructor () { 6 | this.debugEnabled = false 7 | this.prefix = '' 8 | } 9 | 10 | debug (...args) { 11 | if (!this.debugEnabled) return 12 | this._print(...args) 13 | } 14 | 15 | _print (...args) { 16 | try { 17 | const strings = this._convertArgsToStrings(...args) 18 | if (strings.length) { 19 | if (this.prefix) { 20 | strings.unshift(this.prefix) 21 | } 22 | console.log(...strings) 23 | } 24 | } catch (e) { 25 | console.log('Error while logging: ' + e) 26 | } 27 | } 28 | 29 | _convertArgsToStrings (...args) { 30 | const out = [] 31 | for (const arg of args) { 32 | if (arg instanceof Error) { 33 | out.push(arg.stack) 34 | } else if (arg instanceof Buffer) { 35 | out.push(debugDump(arg)) 36 | } else if (typeof arg === 'function') { 37 | const result = arg.call(undefined, (...args) => this._print(...args)) 38 | if (result !== undefined) out.push(...this._convertArgsToStrings(result)) 39 | } else { 40 | out.push(arg) 41 | } 42 | } 43 | return out 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /lib/Promises.js: -------------------------------------------------------------------------------- 1 | export default class Promises { 2 | static createTimeout (timeoutMs, timeoutMsg) { 3 | let cancel = null 4 | const wrapped = new Promise((resolve, reject) => { 5 | const timeout = setTimeout( 6 | () => { 7 | reject(new Error(timeoutMsg + ' - Timed out after ' + timeoutMs + 'ms')) 8 | }, 9 | timeoutMs 10 | ) 11 | cancel = () => { 12 | clearTimeout(timeout) 13 | } 14 | }) 15 | wrapped.cancel = cancel 16 | return wrapped 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /lib/ProtocolResolver.js: -------------------------------------------------------------------------------- 1 | import * as protocols from '../protocols/index.js' 2 | 3 | export const getProtocol = (protocolId) => { 4 | if (!(protocolId in protocols)) { throw Error('Protocol definition file missing: ' + protocolId) } 5 | 6 | return new protocols[protocolId]() 7 | } 8 | -------------------------------------------------------------------------------- /lib/QueryRunner.js: -------------------------------------------------------------------------------- 1 | import { lookup } from './game-resolver.js' 2 | import { getProtocol } from './ProtocolResolver.js' 3 | import GlobalUdpSocket from './GlobalUdpSocket.js' 4 | 5 | const defaultOptions = { 6 | socketTimeout: 2000, 7 | attemptTimeout: 10000, 8 | maxRetries: 1, 9 | stripColors: true, 10 | portCache: true, 11 | noBreadthOrder: false, 12 | ipFamily: 0, 13 | requestPlayers: true 14 | } 15 | 16 | export default class QueryRunner { 17 | constructor (runnerOpts = {}) { 18 | this.udpSocket = new GlobalUdpSocket({ 19 | port: runnerOpts.listenUdpPort 20 | }) 21 | this.portCache = {} 22 | } 23 | 24 | async run (userOptions) { 25 | for (const key of Object.keys(userOptions)) { 26 | const value = userOptions[key] 27 | if (['port', 'ipFamily'].includes(key)) { 28 | userOptions[key] = parseInt(value) 29 | } 30 | } 31 | 32 | const { 33 | port_query: gameQueryPort, 34 | port_query_offset: gameQueryPortOffset, 35 | ...gameOptions 36 | } = lookup(userOptions) 37 | const attempts = [] 38 | 39 | const optionsCollection = { 40 | ...defaultOptions, 41 | ...gameOptions, 42 | ...userOptions 43 | } 44 | 45 | const addAttemptWithPort = port => { 46 | attempts.push({ 47 | ...optionsCollection, 48 | port 49 | }) 50 | } 51 | 52 | let portOffsetArray = gameQueryPortOffset 53 | if (!Array.isArray(portOffsetArray)) { 54 | gameQueryPortOffset ? portOffsetArray = [gameQueryPortOffset] : portOffsetArray = [0] 55 | } 56 | 57 | const cachedPort = this.portCache[`${userOptions.address}:${userOptions.port}`] 58 | 59 | // Use any cached port if port caching is enabled and user is not explicitly enforcing their given port 60 | if (cachedPort && optionsCollection.portCache && !userOptions.givenPortOnly) { 61 | addAttemptWithPort(cachedPort) 62 | } 63 | 64 | if (userOptions.port) { 65 | if (!userOptions.givenPortOnly) { 66 | portOffsetArray.forEach((portOffset) => { addAttemptWithPort(userOptions.port + portOffset) }) 67 | if (userOptions.port === gameOptions.port && gameQueryPort) { addAttemptWithPort(gameQueryPort) } 68 | } 69 | 70 | attempts.push(optionsCollection) 71 | } else if (gameQueryPort) { 72 | addAttemptWithPort(gameQueryPort) 73 | } else if (gameOptions.port) { 74 | portOffsetArray.forEach((portOffset) => { addAttemptWithPort(gameOptions.port + portOffset) }) 75 | } else { 76 | // Hopefully the request doesn't need a port. If it does, it'll fail when making the request. 77 | attempts.push(optionsCollection) 78 | } 79 | 80 | const numRetries = userOptions.maxRetries || gameOptions.maxRetries || defaultOptions.maxRetries 81 | 82 | const retries = Array.from({ length: numRetries }, (x, i) => i) 83 | 84 | const attemptOrder = [] 85 | if (optionsCollection.noBreadthOrder) { 86 | attempts.forEach(attempt => retries.forEach(retry => attemptOrder.push({ attempt, retry }))) 87 | } else { 88 | retries.forEach(retry => attempts.forEach(attempt => attemptOrder.push({ attempt, retry }))) 89 | } 90 | 91 | let attemptNum = 0 92 | const errors = [] 93 | for (const { attempt, retry } of attemptOrder) { 94 | attemptNum++ 95 | 96 | try { 97 | const response = await this._attempt(attempt) 98 | if (attempt.portCache) { 99 | this.portCache[`${userOptions.address}:${userOptions.port}`] = attempt.port 100 | } 101 | return response 102 | } catch (e) { 103 | e.stack = 'Attempt #' + attemptNum + ' - Port=' + attempt.port + ' Retry=' + (retry) + ':\n' + e.stack 104 | errors.push(e) 105 | } 106 | } 107 | 108 | const err = new Error('Failed all ' + errors.length + ' attempts') 109 | for (const e of errors) { 110 | err.stack += '\n' + e.stack 111 | } 112 | 113 | throw err 114 | } 115 | 116 | async _attempt (options) { 117 | const core = getProtocol(options.protocol) 118 | core.options = options 119 | core.udpSocket = this.udpSocket 120 | return await core.runOnceSafe() 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /lib/Results.js: -------------------------------------------------------------------------------- 1 | export class Player { 2 | name = '' 3 | raw = {} 4 | 5 | constructor (data) { 6 | if (typeof data === 'string') { 7 | this.name = data 8 | } else { 9 | const { name, ...raw } = data 10 | if (name) this.name = name 11 | if (raw) this.raw = raw 12 | } 13 | } 14 | } 15 | 16 | export class Players extends Array { 17 | push (data) { 18 | super.push(new Player(data)) 19 | } 20 | } 21 | 22 | export class Results { 23 | name = '' 24 | map = '' 25 | password = false 26 | 27 | raw = {} 28 | version = '' 29 | 30 | maxplayers = 0 31 | numplayers = 0 32 | players = new Players() 33 | bots = new Players() 34 | 35 | queryPort = 0 36 | } 37 | -------------------------------------------------------------------------------- /lib/game-resolver.js: -------------------------------------------------------------------------------- 1 | import { games } from './games.js' 2 | 3 | export const lookup = (options) => { 4 | const type = options.type 5 | 6 | if (!type) { throw Error('No game specified') } 7 | 8 | if (type.startsWith('protocol-')) { 9 | return { 10 | protocol: type.substring(9) 11 | } 12 | } 13 | 14 | let game = games[type] 15 | 16 | if (options.checkOldIDs) { 17 | Object.keys(games).forEach((id) => { 18 | if (games[id]?.extra?.old_id === type) { 19 | game = games[id] 20 | } 21 | }) 22 | } 23 | 24 | if (!game) { throw Error('Invalid game: ' + type) } 25 | 26 | return game.options 27 | } 28 | -------------------------------------------------------------------------------- /lib/gamedig.js: -------------------------------------------------------------------------------- 1 | import QueryRunner from './QueryRunner.js' 2 | 3 | let singleton = null 4 | 5 | export class GameDig { 6 | constructor (runnerOpts) { 7 | this.queryRunner = new QueryRunner(runnerOpts) 8 | } 9 | 10 | async query (userOptions) { 11 | return await this.queryRunner.run(userOptions) 12 | } 13 | 14 | static getInstance () { 15 | if (!singleton) { singleton = new GameDig() } 16 | 17 | return singleton 18 | } 19 | 20 | static async query (...args) { 21 | return await GameDig.getInstance().query(...args) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /lib/index.js: -------------------------------------------------------------------------------- 1 | import { GameDig } from './gamedig.js' 2 | import { games } from './games.js' 3 | import * as protocols from '../protocols/index.js' 4 | 5 | export { GameDig, games, protocols } 6 | -------------------------------------------------------------------------------- /lib/reader.js: -------------------------------------------------------------------------------- 1 | import Iconv from 'iconv-lite' 2 | import Long from 'long' 3 | import { Buffer } from 'node:buffer' 4 | import Varint from 'varint' 5 | 6 | function readUInt64BE (buffer, offset) { 7 | const high = buffer.readUInt32BE(offset) 8 | const low = buffer.readUInt32BE(offset + 4) 9 | return new Long(low, high, true) 10 | } 11 | function readUInt64LE (buffer, offset) { 12 | const low = buffer.readUInt32LE(offset) 13 | const high = buffer.readUInt32LE(offset + 4) 14 | return new Long(low, high, true) 15 | } 16 | 17 | export default class Reader { 18 | /** 19 | * @param {Core} query 20 | * @param {Buffer} buffer 21 | **/ 22 | constructor (query, buffer) { 23 | this.defaultEncoding = query.options.encoding || query.encoding 24 | this.defaultDelimiter = query.delimiter 25 | this.defaultByteOrder = query.byteorder 26 | this.buffer = buffer 27 | this.i = 0 28 | } 29 | 30 | setOffset (offset) { 31 | this.i = offset 32 | } 33 | 34 | offset () { 35 | return this.i 36 | } 37 | 38 | skip (i) { 39 | this.i += i 40 | } 41 | 42 | pascalString (bytesForSize, adjustment = 0) { 43 | const length = this.uint(bytesForSize) + adjustment 44 | return this.string(length) 45 | } 46 | 47 | string (arg) { 48 | let encoding = this.defaultEncoding 49 | let length = null 50 | let delimiter = this.defaultDelimiter 51 | 52 | if (typeof arg === 'string') delimiter = arg 53 | else if (typeof arg === 'number') length = arg 54 | else if (typeof arg === 'object') { 55 | if ('encoding' in arg) encoding = arg.encoding 56 | if ('length' in arg) length = arg.length 57 | if ('delimiter' in arg) delimiter = arg.delimiter 58 | } 59 | 60 | if (encoding === 'latin1') encoding = 'win1252' 61 | 62 | const start = this.i 63 | let end = start 64 | if (length === null) { 65 | // terminated by the delimiter 66 | let delim = delimiter 67 | if (typeof delim === 'string') delim = delim.charCodeAt(0) 68 | while (true) { 69 | if (end >= this.buffer.length) { 70 | end = this.buffer.length 71 | break 72 | } 73 | if (this.buffer.readUInt8(end) === delim) break 74 | end++ 75 | } 76 | this.i = end + 1 77 | } else if (length <= 0) { 78 | return '' 79 | } else { 80 | end = start + length 81 | if (end >= this.buffer.length) { 82 | end = this.buffer.length 83 | } 84 | this.i = end 85 | } 86 | 87 | const slice = this.buffer.slice(start, end) 88 | const enc = encoding 89 | if (enc === 'utf8' || enc === 'ucs2' || enc === 'binary') { 90 | return slice.toString(enc) 91 | } else { 92 | return Iconv.decode(slice, enc) 93 | } 94 | } 95 | 96 | int (bytes) { 97 | let r = 0 98 | if (this.remaining() >= bytes) { 99 | if (this.defaultByteOrder === 'be') { 100 | if (bytes === 1) r = this.buffer.readInt8(this.i) 101 | else if (bytes === 2) r = this.buffer.readInt16BE(this.i) 102 | else if (bytes === 4) r = this.buffer.readInt32BE(this.i) 103 | } else { 104 | if (bytes === 1) r = this.buffer.readInt8(this.i) 105 | else if (bytes === 2) r = this.buffer.readInt16LE(this.i) 106 | else if (bytes === 4) r = this.buffer.readInt32LE(this.i) 107 | } 108 | } 109 | this.i += bytes 110 | return r 111 | } 112 | 113 | /** @returns {number} */ 114 | uint (bytes) { 115 | let r = 0 116 | if (this.remaining() >= bytes) { 117 | if (this.defaultByteOrder === 'be') { 118 | if (bytes === 1) r = this.buffer.readUInt8(this.i) 119 | else if (bytes === 2) r = this.buffer.readUInt16BE(this.i) 120 | else if (bytes === 4) r = this.buffer.readUInt32BE(this.i) 121 | else if (bytes === 8) r = readUInt64BE(this.buffer, this.i) 122 | } else { 123 | if (bytes === 1) r = this.buffer.readUInt8(this.i) 124 | else if (bytes === 2) r = this.buffer.readUInt16LE(this.i) 125 | else if (bytes === 4) r = this.buffer.readUInt32LE(this.i) 126 | else if (bytes === 8) r = readUInt64LE(this.buffer, this.i) 127 | } 128 | } 129 | this.i += bytes 130 | return r 131 | } 132 | 133 | float () { 134 | let r = 0 135 | if (this.remaining() >= 4) { 136 | if (this.defaultByteOrder === 'be') r = this.buffer.readFloatBE(this.i) 137 | else r = this.buffer.readFloatLE(this.i) 138 | } 139 | this.i += 4 140 | return r 141 | } 142 | 143 | varint () { 144 | const out = Varint.decode(this.buffer, this.i) 145 | this.i += Varint.decode.bytes 146 | return out 147 | } 148 | 149 | /** @returns Buffer */ 150 | part (bytes) { 151 | let r 152 | if (this.remaining() >= bytes) { 153 | r = this.buffer.slice(this.i, this.i + bytes) 154 | } else { 155 | r = Buffer.from([]) 156 | } 157 | this.i += bytes 158 | return r 159 | } 160 | 161 | remaining () { 162 | return this.buffer.length - this.i 163 | } 164 | 165 | rest () { 166 | return this.buffer.slice(this.i) 167 | } 168 | 169 | done () { 170 | return this.i >= this.buffer.length 171 | } 172 | } 173 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "gamedig", 3 | "description": "Query for the status of any game server in Node.JS", 4 | "scripts": { 5 | "lint:check": "eslint .", 6 | "lint:fix": "eslint --fix .", 7 | "prepare": "npm run build", 8 | "build": "node tools/esbuild.js" 9 | }, 10 | "keywords": [ 11 | "srcds", 12 | "query", 13 | "game", 14 | "utility", 15 | "util", 16 | "server", 17 | "gameserver", 18 | "game-server-query", 19 | "game server query", 20 | "server query", 21 | "game server", 22 | "gameserverquery", 23 | "serverquery", 24 | "terraria", 25 | "counter strike", 26 | "csgo", 27 | "minecraft" 28 | ], 29 | "type": "module", 30 | "exports": { 31 | "import": "./lib/index.js", 32 | "require": "./dist/index.cjs" 33 | }, 34 | "author": "GameDig Contributors", 35 | "version": "5.3.1", 36 | "repository": { 37 | "type": "git", 38 | "url": "https://github.com/gamedig/node-gamedig.git" 39 | }, 40 | "bugs": { 41 | "url": "https://github.com/gamedig/node-gamedig/issues" 42 | }, 43 | "license": "MIT", 44 | "engines": { 45 | "node": ">=16.20.0" 46 | }, 47 | "bin": { 48 | "gamedig": "bin/gamedig.js" 49 | }, 50 | "files": [ 51 | "dist/index.cjs", 52 | "bin/gamedig.js", 53 | "lib/", 54 | "protocols/", 55 | "LICENSE", 56 | "GAMES_LIST.md", 57 | "README.md" 58 | ], 59 | "dependencies": { 60 | "fast-xml-parser": "5.2.5", 61 | "gbxremote": "0.2.1", 62 | "got": "13.0.0", 63 | "iconv-lite": "0.6.3", 64 | "long": "5.3.2", 65 | "minimist": "1.2.8", 66 | "seek-bzip": "2.0.0", 67 | "telnet-client": "2.2.5", 68 | "varint": "6.0.0" 69 | }, 70 | "devDependencies": { 71 | "@types/node": "^16.18.58", 72 | "esbuild": "^0.19.10", 73 | "esbuild-node-externals": "^1.12.0", 74 | "eslint": "^8.49.0", 75 | "eslint-config-standard": "^17.1.0", 76 | "eslint-plugin-import": "^2.28.1", 77 | "eslint-plugin-n": "15.7.0", 78 | "eslint-plugin-promise": "^6.1.1" 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /protocols/altvmp.js: -------------------------------------------------------------------------------- 1 | import Core from './core.js' 2 | 3 | export default class altvmp extends Core { 4 | constructor() { 5 | super() 6 | this.usedTcp = true 7 | } 8 | 9 | async getServerFromMasterList() { 10 | const targetID = `${this.options.host}:${this.options.port}` 11 | 12 | const results = await this.request({ 13 | url: 'https://api.alt-mp.com/servers', 14 | responseType: 'json' 15 | }) 16 | 17 | if (results == null) { 18 | throw new Error('Unable to retrieve master server list') 19 | } 20 | 21 | const serverInfo = results.find((server) => { 22 | // If the server uses a CDN, there could be occasional paths in the address, so we are checking for them. 23 | // If the server does not use a CDN, there will be no paths in the address and direct comparison will work. 24 | const address = server.useCdn 25 | ? server.address 26 | : server.address.replace(/(https?:\/\/)?\/?/g, '') 27 | return address === targetID 28 | }) 29 | 30 | return serverInfo 31 | } 32 | 33 | async getServerById(targetID) { 34 | const serverInfo = await this.request({ 35 | url: `https://api.alt-mp.com/servers/${targetID}`, 36 | responseType: 'json' 37 | }) 38 | 39 | if (serverInfo == null) { 40 | throw new Error('Unable to retrieve server info') 41 | } 42 | 43 | return serverInfo 44 | } 45 | 46 | async run(state) { 47 | const serverInfo = this.options.serverId 48 | ? await this.getServerById(this.options.serverId) 49 | : await this.getServerFromMasterList() 50 | 51 | if (!serverInfo) { 52 | throw new Error('No server info was found.') 53 | } 54 | 55 | state.name = serverInfo.name 56 | state.numplayers = serverInfo.playersCount 57 | state.maxplayers = serverInfo.maxPlayersCount 58 | state.password = serverInfo.passworded 59 | state.version = serverInfo.version 60 | state.connect = `altv://${serverInfo.address}` 61 | state.raw = serverInfo 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /protocols/armagetron.js: -------------------------------------------------------------------------------- 1 | import Core from './core.js' 2 | 3 | export default class armagetron extends Core { 4 | constructor () { 5 | super() 6 | this.encoding = 'latin1' 7 | this.byteorder = 'be' 8 | } 9 | 10 | async run (state) { 11 | const b = Buffer.from([0, 0x35, 0, 0, 0, 0, 0, 0x11]) 12 | 13 | const buffer = await this.udpSend(b, b => b) 14 | const reader = this.reader(buffer) 15 | 16 | reader.skip(6) 17 | 18 | state.gamePort = this.readUInt(reader) 19 | state.raw.hostname = this.readString(reader) 20 | state.name = this.stripColorCodes(this.readString(reader)) 21 | state.numplayers = this.readUInt(reader) 22 | state.raw.versionmin = this.readUInt(reader) 23 | state.raw.versionmax = this.readUInt(reader) 24 | state.version = this.readString(reader) 25 | state.maxplayers = this.readUInt(reader) 26 | 27 | const players = this.readString(reader) 28 | const list = players.split('\n') 29 | for (const name of list) { 30 | if (!name) continue 31 | state.players.push({ 32 | name: this.stripColorCodes(name) 33 | }) 34 | } 35 | 36 | state.raw.options = this.stripColorCodes(this.readString(reader)) 37 | state.raw.uri = this.readString(reader) 38 | state.raw.globalids = this.readString(reader) 39 | } 40 | 41 | readUInt (reader) { 42 | const a = reader.uint(2) 43 | const b = reader.uint(2) 44 | return (b << 16) + a 45 | } 46 | 47 | readString (reader) { 48 | const len = reader.uint(2) 49 | if (!len) return '' 50 | 51 | let out = '' 52 | for (let i = 0; i < len; i += 2) { 53 | const hi = reader.uint(1) 54 | const lo = reader.uint(1) 55 | if (i + 1 < len) out += String.fromCharCode(lo) 56 | if (i + 2 < len) out += String.fromCharCode(hi) 57 | } 58 | 59 | return out 60 | } 61 | 62 | stripColorCodes (str) { 63 | return this.options.stripColors ? str.replace(/0x[0-9a-f]{6}/g, '') : str 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /protocols/asa.js: -------------------------------------------------------------------------------- 1 | import Epic from './epic.js' 2 | 3 | export default class asa extends Epic { 4 | constructor () { 5 | super() 6 | 7 | // OAuth2 credentials extracted from ARK: Survival Ascended files. 8 | this.clientId = 'xyza7891muomRmynIIHaJB9COBKkwj6n' 9 | this.clientSecret = 'PP5UGxysEieNfSrEicaD1N2Bb3TdXuD7xHYcsdUHZ7s' 10 | this.deploymentId = 'ad9a8feffb3b4b2ca315546f038c3ae2' 11 | } 12 | 13 | async run (state) { 14 | await super.run(state) 15 | state.version = state.raw.attributes.BUILDID_s + '.' + state.raw.attributes.MINORBUILDID_s 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /protocols/ase.js: -------------------------------------------------------------------------------- 1 | import Core from './core.js' 2 | 3 | export default class ase extends Core { 4 | async run (state) { 5 | const buffer = await this.udpSend('s', (buffer) => { 6 | const reader = this.reader(buffer) 7 | const header = reader.string(4) 8 | if (header === 'EYE1') return reader.rest() 9 | }) 10 | 11 | const reader = this.reader(buffer) 12 | state.raw.gamename = this.readString(reader) 13 | state.gamePort = parseInt(this.readString(reader)) 14 | state.name = this.readString(reader) 15 | state.raw.gametype = this.readString(reader) 16 | state.map = this.readString(reader) 17 | state.version = this.readString(reader) 18 | state.password = this.readString(reader) === '1' 19 | state.numplayers = parseInt(this.readString(reader)) 20 | state.maxplayers = parseInt(this.readString(reader)) 21 | 22 | while (!reader.done()) { 23 | const key = this.readString(reader) 24 | if (!key) break 25 | const value = this.readString(reader) 26 | state.raw[key] = value 27 | } 28 | 29 | while (!reader.done()) { 30 | const flags = reader.uint(1) 31 | const player = {} 32 | if (flags & 1) player.name = this.readString(reader) 33 | if (flags & 2) player.team = this.readString(reader) 34 | if (flags & 4) player.skin = this.readString(reader) 35 | if (flags & 8) player.score = parseInt(this.readString(reader)) 36 | if (flags & 16) player.ping = parseInt(this.readString(reader)) 37 | if (flags & 32) player.time = parseInt(this.readString(reader)) 38 | state.players.push(player) 39 | } 40 | } 41 | 42 | readString (reader) { 43 | return reader.pascalString(1, -1) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /protocols/assettocorsa.js: -------------------------------------------------------------------------------- 1 | import Core from './core.js' 2 | 3 | export default class assettocorsa extends Core { 4 | async run (state) { 5 | const serverInfo = await this.request({ 6 | url: `http://${this.options.address}:${this.options.port}/INFO`, 7 | responseType: 'json' 8 | }) 9 | const carInfo = await this.request({ 10 | url: `http://${this.options.address}:${this.options.port}/JSON|${Math.floor(Math.random() * 999999999999999)}`, 11 | responseType: 'json' 12 | }) 13 | 14 | if (!serverInfo || !carInfo || !carInfo.Cars) { 15 | throw new Error('Query not successful') 16 | } 17 | 18 | state.maxplayers = serverInfo.maxclients 19 | state.name = serverInfo.name 20 | state.map = serverInfo.track 21 | state.password = serverInfo.pass 22 | state.gamePort = serverInfo.port 23 | state.numplayers = serverInfo.clients 24 | state.version = serverInfo.poweredBy 25 | 26 | state.raw.carInfo = carInfo.Cars 27 | state.raw.serverInfo = serverInfo 28 | 29 | for (const car of carInfo.Cars) { 30 | if (car.IsConnected) { 31 | state.players.push({ 32 | name: car.DriverName, 33 | car: car.Model, 34 | skin: car.Skin, 35 | nation: car.DriverNation, 36 | team: car.DriverTeam 37 | }) 38 | } 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /protocols/battlefield.js: -------------------------------------------------------------------------------- 1 | import Core from './core.js' 2 | 3 | export default class battlefield extends Core { 4 | constructor () { 5 | super() 6 | this.encoding = 'latin1' 7 | } 8 | 9 | async run (state) { 10 | await this.withTcp(async socket => { 11 | { 12 | const data = await this.query(socket, ['serverInfo']) 13 | state.name = data.shift() 14 | state.numplayers = parseInt(data.shift()) 15 | state.maxplayers = parseInt(data.shift()) 16 | state.raw.gametype = data.shift() 17 | state.map = data.shift() 18 | state.raw.roundsplayed = parseInt(data.shift()) 19 | state.raw.roundstotal = parseInt(data.shift()) 20 | 21 | const teamCount = data.shift() 22 | state.raw.teams = [] 23 | for (let i = 0; i < teamCount; i++) { 24 | const tickets = parseFloat(data.shift()) 25 | state.raw.teams.push({ 26 | tickets 27 | }) 28 | } 29 | 30 | state.raw.targetscore = parseInt(data.shift()) 31 | state.raw.status = data.shift() 32 | 33 | // Seems like the fields end at random places beyond this point 34 | // depending on the server version 35 | 36 | if (data.length) state.raw.ranked = (data.shift() === 'true') 37 | if (data.length) state.raw.punkbuster = (data.shift() === 'true') 38 | if (data.length) state.password = (data.shift() === 'true') 39 | if (data.length) state.raw.uptime = parseInt(data.shift()) 40 | if (data.length) state.raw.roundtime = parseInt(data.shift()) 41 | 42 | const isBadCompany2 = data[0] === 'BC2' 43 | if (isBadCompany2) { 44 | if (data.length) data.shift() 45 | if (data.length) data.shift() 46 | } 47 | if (data.length) { 48 | state.raw.ip = data.shift() 49 | const split = state.raw.ip.split(':') 50 | state.gameHost = split[0] 51 | state.gamePort = split[1] 52 | } else { 53 | // best guess if the server doesn't tell us what the server port is 54 | // these are just the default game ports for different default query ports 55 | if (this.options.port === 48888) state.gamePort = 7673 56 | if (this.options.port === 22000) state.gamePort = 25200 57 | } 58 | if (data.length) state.raw.punkbusterversion = data.shift() 59 | if (data.length) state.raw.joinqueue = (data.shift() === 'true') 60 | if (data.length) state.raw.region = data.shift() 61 | if (data.length) state.raw.pingsite = data.shift() 62 | if (data.length) state.raw.country = data.shift() 63 | if (data.length) state.raw.quickmatch = (data.shift() === 'true') 64 | } 65 | 66 | { 67 | const data = await this.query(socket, ['version']) 68 | data.shift() 69 | state.version = data.shift() 70 | } 71 | 72 | { 73 | const data = await this.query(socket, ['listPlayers', 'all']) 74 | const fieldCount = parseInt(data.shift()) 75 | const fields = [] 76 | for (let i = 0; i < fieldCount; i++) { 77 | fields.push(data.shift()) 78 | } 79 | const numplayers = data.shift() 80 | for (let i = 0; i < numplayers; i++) { 81 | const player = {} 82 | for (let key of fields) { 83 | let value = data.shift() 84 | 85 | if (key === 'teamId') key = 'team' 86 | else if (key === 'squadId') key = 'squad' 87 | 88 | if (['kills', 'deaths', 'score', 'rank', 'team', 'squad', 'ping', 'type'].includes(key)) { 89 | value = parseInt(value) 90 | } 91 | 92 | player[key] = value 93 | } 94 | state.players.push(player) 95 | } 96 | } 97 | }) 98 | } 99 | 100 | async query (socket, params) { 101 | const outPacket = this.buildPacket(params) 102 | return await this.tcpSend(socket, outPacket, (data) => { 103 | const decoded = this.decodePacket(data) 104 | if (decoded) { 105 | this.logger.debug(decoded) 106 | if (decoded.shift() !== 'OK') throw new Error('Missing OK') 107 | return decoded 108 | } 109 | }) 110 | } 111 | 112 | buildPacket (params) { 113 | const paramBuffers = [] 114 | for (const param of params) { 115 | paramBuffers.push(Buffer.from(param, 'utf8')) 116 | } 117 | 118 | let totalLength = 12 119 | for (const paramBuffer of paramBuffers) { 120 | totalLength += paramBuffer.length + 1 + 4 121 | } 122 | 123 | const b = Buffer.alloc(totalLength) 124 | b.writeUInt32LE(0, 0) 125 | b.writeUInt32LE(totalLength, 4) 126 | b.writeUInt32LE(params.length, 8) 127 | let offset = 12 128 | for (const paramBuffer of paramBuffers) { 129 | b.writeUInt32LE(paramBuffer.length, offset); offset += 4 130 | paramBuffer.copy(b, offset); offset += paramBuffer.length 131 | b.writeUInt8(0, offset); offset += 1 132 | } 133 | 134 | return b 135 | } 136 | 137 | decodePacket (buffer) { 138 | if (buffer.length < 8) return false 139 | const reader = this.reader(buffer) 140 | const header = reader.uint(4) 141 | const totalLength = reader.uint(4) 142 | // Venice Unleashed servers "broadcast" in-game events to any connected rcon client 143 | // If we get such a non-response packet, skip it and decode any remaining data 144 | // Note: We will receive the broadcast ticket again next time the socket receives data, as data is concatenated 145 | if (!(header & 0x40000000)) { 146 | // Skip total length minus already read bytes (4 header + 4 length) 147 | reader.skip(totalLength - 8) 148 | if (reader.done()) { 149 | this.logger.debug('Skipping packet, type mismatch') 150 | return 151 | } else { 152 | return this.decodePacket(reader.rest()) 153 | } 154 | } 155 | if (buffer.length < totalLength) return false 156 | this.logger.debug('Expected ' + totalLength + ' bytes, have ' + buffer.length) 157 | 158 | const paramCount = reader.uint(4) 159 | const params = [] 160 | for (let i = 0; i < paramCount; i++) { 161 | params.push(reader.pascalString(4)) 162 | reader.uint(1) // strNull 163 | } 164 | return params 165 | } 166 | } 167 | -------------------------------------------------------------------------------- /protocols/beammp.js: -------------------------------------------------------------------------------- 1 | import Core from './core.js' 2 | import beammpmaster from './beammpmaster.js' 3 | 4 | export default class beammp extends Core { 5 | async run (state) { 6 | const master = new beammpmaster() 7 | master.options = this.options 8 | const masterState = await master.runOnceSafe() 9 | const servers = masterState.raw.servers 10 | const server = servers.find(s => s.ip === this.options.address) 11 | 12 | if (!server) { 13 | throw new Error('Server not found in the master list') 14 | } 15 | 16 | state.name = server.sname.replace(/\^./g, '') 17 | state.map = server.map 18 | state.password = server.password 19 | state.numplayers = parseInt(server.players) 20 | state.maxplayers = parseInt(server.maxplayers) 21 | 22 | const players = server.playerslist.split(';') 23 | if (players[players.length - 1] === '') { 24 | players.pop() 25 | } 26 | players.forEach(player => { 27 | state.players.push({ name: player }) 28 | }) 29 | 30 | state.raw = server 31 | if ('version' in state.raw) state.version = state.raw.version 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /protocols/beammpmaster.js: -------------------------------------------------------------------------------- 1 | import Core from './core.js' 2 | 3 | export default class beammpmaster extends Core { 4 | constructor () { 5 | super() 6 | 7 | // Don't use the tcp ping probing 8 | this.usedTcp = true 9 | } 10 | 11 | async run (state) { 12 | state.raw.servers = await this.request({ 13 | url: 'https://backend.beammp.com/servers-info', 14 | responseType: 'json' 15 | }) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /protocols/brokeprotocol.js: -------------------------------------------------------------------------------- 1 | import brokeprotocolmaster from './brokeprotocolmaster.js' 2 | 3 | /** 4 | * Implements the protocol for BROKE PROTOCOL, a Unity based game 5 | * using a custom master server 6 | */ 7 | export default class brokeprotocol extends brokeprotocolmaster { 8 | constructor () { 9 | super() 10 | this.doQuerySingle = true 11 | this.requireToken = true 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /protocols/buildandshoot.js: -------------------------------------------------------------------------------- 1 | import Core from './core.js' 2 | 3 | // We are doing some shenanigans here as we are trying to support the stable version and the git master version 4 | // as in the latest (0.75) releases they are mixed up. 5 | export default class buildandshoot extends Core { 6 | async run (state) { 7 | const request = await this.request({ 8 | url: 'http://' + this.options.address + ':' + this.options.port + '/json', 9 | responseType: 'json' 10 | }) 11 | 12 | state.name = request.serverName 13 | state.map = request.map.name 14 | state.version = request.serverVersion 15 | 16 | const bluePlayers = request.players?.blue || [] 17 | const greenPlayers = request.players?.green || [] 18 | let players = bluePlayers.concat(greenPlayers) 19 | if (Array.isArray(request.players)) { 20 | players = players.concat(request.players) 21 | } 22 | 23 | state.numplayers = players.length 24 | state.maxplayers = request.maxPlayers || request.players?.maxPlayers 25 | 26 | state.players = [] 27 | for (const player of players) { 28 | if (typeof player === 'string') { 29 | state.players.push({ 30 | name: player 31 | }) 32 | } else { 33 | state.players.push({ 34 | ...player, 35 | name: player.name 36 | }) 37 | } 38 | } 39 | 40 | state.raw = request 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /protocols/cs2d.js: -------------------------------------------------------------------------------- 1 | import Core from './core.js' 2 | 3 | export default class cs2d extends Core { 4 | async run (state) { 5 | const reader = await this.sendQuery( 6 | Buffer.from('\x01\x00\xFB\x01\xF5\x03\xFB\x05', 'binary'), 7 | Buffer.from('\x01\x00\xFB\x01', 'binary') 8 | ) 9 | const flags = reader.uint(1) 10 | state.raw.flags = flags 11 | state.password = this.readFlag(flags, 0) 12 | state.raw.registeredOnly = this.readFlag(flags, 1) 13 | state.raw.fogOfWar = this.readFlag(flags, 2) 14 | state.raw.friendlyFire = this.readFlag(flags, 3) 15 | state.raw.botsEnabled = this.readFlag(flags, 5) 16 | state.raw.luaScripts = this.readFlag(flags, 6) 17 | state.raw.forceLight = this.readFlag(flags, 7) 18 | state.name = this.readString(reader) 19 | state.map = this.readString(reader) 20 | state.numplayers = reader.uint(1) 21 | state.maxplayers = reader.uint(1) 22 | if (flags & 32) { 23 | state.raw.gamemode = reader.uint(1) 24 | } else { 25 | state.raw.gamemode = 0 26 | } 27 | state.raw.numbots = reader.uint(1) 28 | const flags2 = reader.uint(1) 29 | state.raw.flags2 = flags2 30 | state.raw.recoil = this.readFlag(flags2, 0) 31 | state.raw.offScreenDamage = this.readFlag(flags2, 1) 32 | state.raw.hasDownloads = this.readFlag(flags2, 2) 33 | reader.skip(2) 34 | const players = reader.uint(1) 35 | for (let i = 0; i < players; i++) { 36 | const player = {} 37 | player.id = reader.uint(1) 38 | player.name = this.readString(reader) 39 | player.team = reader.uint(1) 40 | player.score = reader.uint(4) 41 | player.deaths = reader.uint(4) 42 | state.players.push(player) 43 | } 44 | } 45 | 46 | async sendQuery (request, expectedHeader) { 47 | // Send multiple copies of the request packet, because cs2d likes to just ignore them randomly 48 | await this.udpSend(request) 49 | await this.udpSend(request) 50 | return await this.udpSend(request, (buffer) => { 51 | const reader = this.reader(buffer) 52 | const header = reader.part(4) 53 | if (!header.equals(expectedHeader)) return 54 | return reader 55 | }) 56 | } 57 | 58 | readFlag (flags, offset) { 59 | return !!(flags & (1 << offset)) 60 | } 61 | 62 | readString (reader) { 63 | return reader.pascalString(1) 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /protocols/dayz.js: -------------------------------------------------------------------------------- 1 | import valve from './valve.js' 2 | import { Buffer } from 'node:buffer' 3 | 4 | export default class dayz extends valve { 5 | async run (state) { 6 | if (!this.options.port) this.options.port = 27016 7 | await super.queryInfo(state) 8 | await super.queryChallenge() 9 | await super.queryPlayers(state) 10 | await this.queryRules(state) 11 | 12 | this.processQueryInfo(state) 13 | await super.cleanup(state) 14 | } 15 | 16 | async queryRules (state) { 17 | if (!this.options.requestRules) { 18 | return 19 | } 20 | 21 | const rules = {} 22 | state.raw.rules = rules 23 | const dayZPayload = [] 24 | 25 | this.logger.debug('Requesting rules ...') 26 | 27 | const b = await this.sendPacket(0x56, null, 0x45, true) 28 | if (b === null && !this.options.requestRulesRequired) return // timed out - the server probably has rules disabled 29 | 30 | let dayZPayloadEnded = false 31 | 32 | const reader = this.reader(b) 33 | const num = reader.uint(2) 34 | for (let i = 0; i < num; i++) { 35 | if (!dayZPayloadEnded) { 36 | const one = reader.uint(1) 37 | const two = reader.uint(1) 38 | const three = reader.uint(1) 39 | if (one !== 0 && two !== 0 && three === 0) { 40 | while (true) { 41 | const byte = reader.uint(1) 42 | if (byte === 0) break 43 | dayZPayload.push(byte) 44 | } 45 | continue 46 | } else { 47 | reader.skip(-3) 48 | dayZPayloadEnded = true 49 | } 50 | } 51 | 52 | const key = reader.string() 53 | rules[key] = reader.string() 54 | } 55 | 56 | state.raw.dayzMods = this.readDayzMods(Buffer.from(dayZPayload)) 57 | } 58 | 59 | processQueryInfo (state) { 60 | // DayZ embeds some of the server information inside the tags attribute 61 | if (!state.raw.tags) { return } 62 | 63 | state.raw.dlcEnabled = false 64 | state.raw.firstPerson = false 65 | state.raw.privateHive = false 66 | state.raw.external = false 67 | state.raw.official = false 68 | 69 | for (const tag of state.raw.tags) { 70 | if (tag.startsWith('lqs')) { 71 | const value = parseInt(tag.replace('lqs', '')) 72 | if (!isNaN(value)) { 73 | state.raw.queue = value 74 | } 75 | } 76 | if (tag.includes('no3rd')) { 77 | state.raw.firstPerson = true 78 | } 79 | if (tag.includes('isDLC')) { 80 | state.raw.dlcEnabled = true 81 | } 82 | if (tag.includes('privHive')) { 83 | state.raw.privateHive = true 84 | } 85 | if (tag.includes('external')) { 86 | state.raw.external = true 87 | } 88 | if (tag.includes(':')) { 89 | state.raw.time = tag 90 | } 91 | if (tag.startsWith('etm')) { 92 | const value = parseInt(tag.replace('etm', '')) 93 | if (!isNaN(value)) { 94 | state.raw.dayAcceleration = value 95 | } 96 | } 97 | if (tag.startsWith('entm')) { 98 | const value = parseInt(tag.replace('entm', '')) 99 | if (!isNaN(value)) { 100 | state.raw.nightAcceleration = value 101 | } 102 | } 103 | } 104 | 105 | if (!state.raw.external && !state.raw.privateHive) { 106 | state.raw.official = true 107 | } 108 | } 109 | 110 | readDayzMods (/** Buffer */ buffer) { 111 | if (!buffer.length) { 112 | return {} 113 | } 114 | 115 | this.logger.debug('DAYZ BUFFER') 116 | this.logger.debug(buffer) 117 | 118 | const reader = this.reader(buffer) 119 | const version = this.readDayzByte(reader) 120 | const overflow = this.readDayzByte(reader) 121 | const dlc1 = this.readDayzByte(reader) 122 | const dlc2 = this.readDayzByte(reader) 123 | this.logger.debug('version ' + version) 124 | this.logger.debug('overflow ' + overflow) 125 | this.logger.debug('dlc1 ' + dlc1) 126 | this.logger.debug('dlc2 ' + dlc2) 127 | if (dlc1) { 128 | const unknown = this.readDayzUint(reader, 4) // ? 129 | this.logger.debug('unknown ' + unknown) 130 | } 131 | if (dlc2) { 132 | const unknown = this.readDayzUint(reader, 4) // ? 133 | this.logger.debug('unknown ' + unknown) 134 | } 135 | const mods = [] 136 | mods.push(...this.readDayzModsSection(reader, true)) 137 | mods.push(...this.readDayzModsSection(reader, false)) 138 | this.logger.debug('dayz buffer rest:', reader.rest()) 139 | return mods 140 | } 141 | 142 | readDayzModsSection (/** Reader */ reader, withHeader) { 143 | const out = [] 144 | const count = this.readDayzByte(reader) 145 | this.logger.debug('dayz mod section withHeader:' + withHeader + ' count:' + count) 146 | for (let i = 0; i < count; i++) { 147 | if (reader.done()) break 148 | const mod = {} 149 | if (withHeader) { 150 | mod.unknown = this.readDayzUint(reader, 4) // ? 151 | 152 | // For some reason this is 4 on all of them, but doesn't exist on the last one? but only sometimes? 153 | const offset = reader.offset() 154 | const flag = this.readDayzByte(reader) 155 | if (flag !== 4) reader.setOffset(offset) 156 | 157 | mod.workshopId = this.readDayzUint(reader, 4) 158 | } 159 | mod.title = this.readDayzString(reader) 160 | this.logger.debug(mod) 161 | out.push(mod) 162 | } 163 | return out 164 | } 165 | 166 | readDayzUint (reader, bytes) { 167 | const out = [] 168 | for (let i = 0; i < bytes; i++) { 169 | out.push(this.readDayzByte(reader)) 170 | } 171 | const buf = Buffer.from(out) 172 | const r2 = this.reader(buf) 173 | return r2.uint(bytes) 174 | } 175 | 176 | readDayzByte (reader) { 177 | const byte = reader.uint(1) 178 | if (byte === 1) { 179 | const byte2 = reader.uint(1) 180 | if (byte2 === 1) return 1 181 | if (byte2 === 2) return 0 182 | if (byte2 === 3) return 0xff 183 | return 0 // ? 184 | } 185 | return byte 186 | } 187 | 188 | readDayzString (reader) { 189 | const length = this.readDayzByte(reader) 190 | const out = [] 191 | for (let i = 0; i < length; i++) { 192 | out.push(this.readDayzByte(reader)) 193 | } 194 | return Buffer.from(out).toString('utf8') 195 | } 196 | } 197 | -------------------------------------------------------------------------------- /protocols/discord.js: -------------------------------------------------------------------------------- 1 | import Core from './core.js' 2 | 3 | export default class discord extends Core { 4 | async run (state) { 5 | const guildId = this.options.guildId 6 | if (typeof guildId !== 'string') { 7 | throw new Error('guildId option must be set when querying discord. Ensure the guildId is a string and not a number.' + 8 | " (It's too large of a number for javascript to store without losing precision)") 9 | } 10 | this.usedTcp = true 11 | const raw = await this.request({ 12 | url: 'https://discordapp.com/api/guilds/' + guildId + '/widget.json' 13 | }) 14 | const json = JSON.parse(raw) 15 | state.name = json.name 16 | if (json.instant_invite) { 17 | state.connect = json.instant_invite 18 | } else { 19 | state.connect = 'https://discordapp.com/channels/' + guildId 20 | } 21 | for (const member of json.members) { 22 | const { username: name, ...rest } = member 23 | state.players.push({ name, ...rest }) 24 | } 25 | delete json.members 26 | state.maxplayers = 500000 27 | state.raw = json 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /protocols/doom3.js: -------------------------------------------------------------------------------- 1 | import Core from './core.js' 2 | 3 | export default class doom3 extends Core { 4 | constructor () { 5 | super() 6 | this.encoding = 'latin1' 7 | } 8 | 9 | async run (state) { 10 | const body = await this.udpSend('\xff\xffgetInfo\x00PiNGPoNg\x00', packet => { 11 | const reader = this.reader(packet) 12 | const header = reader.uint(2) 13 | if (header !== 0xffff) return 14 | const header2 = reader.string() 15 | if (header2 !== 'infoResponse') return 16 | const challengePart1 = reader.string(4) 17 | if (challengePart1 !== 'PiNG') return 18 | // some doom3 implementations only return the first 4 bytes of the challenge 19 | const challengePart2 = reader.string(4) 20 | if (challengePart2 !== 'PoNg') reader.skip(-4) 21 | return reader.rest() 22 | }) 23 | 24 | let reader = this.reader(body) 25 | const protoVersion = reader.uint(4) 26 | state.raw.protocolVersion = (protoVersion >> 16) + '.' + (protoVersion & 0xffff) 27 | state.version = state.raw.protocolVersion 28 | 29 | // some doom implementations send us a packet size here, some don't (etqw does this) 30 | // we can tell if this is a packet size, because the third and fourth byte will be 0 (no packets are that massive) 31 | reader.skip(2) 32 | const packetContainsSize = (reader.uint(2) === 0) 33 | reader.skip(-4) 34 | 35 | if (packetContainsSize) { 36 | const size = reader.uint(4) 37 | this.logger.debug('Received packet size: ' + size) 38 | } 39 | 40 | while (!reader.done()) { 41 | const key = reader.string() 42 | let value = this.stripColors(reader.string()) 43 | if (key === 'si_map') { 44 | value = value.replace('maps/', '') 45 | value = value.replace('.entities', '') 46 | } 47 | if (!key) break 48 | state.raw[key] = value 49 | this.logger.debug(key + '=' + value) 50 | } 51 | 52 | const isEtqw = state.raw.gamename && state.raw.gamename.toLowerCase().includes('etqw') 53 | 54 | const rest = reader.rest() 55 | let playerResult = this.attemptPlayerParse(rest, isEtqw, false, false, false) 56 | if (!playerResult) playerResult = this.attemptPlayerParse(rest, isEtqw, true, false, false) 57 | if (!playerResult) playerResult = this.attemptPlayerParse(rest, isEtqw, true, true, true) 58 | if (!playerResult) { 59 | throw new Error('Unable to find a suitable parse strategy for player list') 60 | } 61 | let players; 62 | [players, reader] = playerResult 63 | 64 | state.numplayers = players.length 65 | for (const player of players) { 66 | if (!player.ping || player.typeflag) { state.bots.push(player) } else { state.players.push(player) } 67 | } 68 | 69 | state.raw.osmask = reader.uint(4) 70 | if (isEtqw) { 71 | state.raw.ranked = reader.uint(1) 72 | state.raw.timeleft = reader.uint(4) 73 | state.raw.gamestate = reader.uint(1) 74 | state.raw.servertype = reader.uint(1) 75 | // 0 = regular, 1 = tv 76 | if (state.raw.servertype === 0) { 77 | state.raw.interestedClients = reader.uint(1) 78 | } else if (state.raw.servertype === 1) { 79 | state.raw.connectedClients = reader.uint(4) 80 | state.raw.maxClients = reader.uint(4) 81 | } 82 | } 83 | 84 | if (state.raw.si_name) state.name = state.raw.si_name 85 | if (state.raw.si_map) state.map = state.raw.si_map 86 | if (state.raw.si_maxplayers) state.maxplayers = parseInt(state.raw.si_maxplayers) 87 | if (state.raw.si_maxPlayers) state.maxplayers = parseInt(state.raw.si_maxPlayers) 88 | if (state.raw.si_usepass === '1') state.password = true 89 | if (state.raw.si_needPass === '1') state.password = true 90 | if (this.options.port === 27733) state.gamePort = 3074 // etqw has a different query and game port 91 | } 92 | 93 | attemptPlayerParse (rest, isEtqw, hasClanTag, hasClanTagPos, hasTypeFlag) { 94 | this.logger.debug('starting player parse attempt:') 95 | this.logger.debug('isEtqw: ' + isEtqw) 96 | this.logger.debug('hasClanTag: ' + hasClanTag) 97 | this.logger.debug('hasClanTagPos: ' + hasClanTagPos) 98 | this.logger.debug('hasTypeFlag: ' + hasTypeFlag) 99 | const reader = this.reader(rest) 100 | let lastId = -1 101 | const players = [] 102 | while (true) { 103 | this.logger.debug('---') 104 | if (reader.done()) { 105 | this.logger.debug('* aborting attempt, overran buffer *') 106 | return null 107 | } 108 | const player = {} 109 | player.id = reader.uint(1) 110 | this.logger.debug('id: ' + player.id) 111 | if (player.id <= lastId || player.id > 0x20) { 112 | this.logger.debug('* aborting attempt, invalid player id *') 113 | return null 114 | } 115 | lastId = player.id 116 | if (player.id === 0x20) { 117 | this.logger.debug('* player parse successful *') 118 | break 119 | } 120 | player.ping = reader.uint(2) 121 | this.logger.debug('ping: ' + player.ping) 122 | if (!isEtqw) { 123 | player.rate = reader.uint(4) 124 | this.logger.debug('rate: ' + player.rate) 125 | } 126 | player.name = this.stripColors(reader.string()) 127 | this.logger.debug('name: ' + player.name) 128 | if (hasClanTag) { 129 | if (hasClanTagPos) { 130 | const clanTagPos = reader.uint(1) 131 | this.logger.debug('clanTagPos: ' + clanTagPos) 132 | } 133 | player.clantag = this.stripColors(reader.string()) 134 | this.logger.debug('clan tag: ' + player.clantag) 135 | } 136 | if (hasTypeFlag) { 137 | player.typeflag = reader.uint(1) 138 | this.logger.debug('type flag: ' + player.typeflag) 139 | } 140 | players.push(player) 141 | } 142 | return [players, reader] 143 | } 144 | 145 | stripColors (str) { 146 | // uses quake 3 color codes 147 | return this.options.stripColors ? str.replace(/\^(X.{6}|.)/g, '') : str 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /protocols/eco.js: -------------------------------------------------------------------------------- 1 | import Core from './core.js' 2 | 3 | export default class eco extends Core { 4 | async run (state) { 5 | if (!this.options.port) this.options.port = 3001 6 | 7 | const request = await this.request({ 8 | url: `http://${this.options.host}:${this.options.port}/frontpage`, 9 | responseType: 'json' 10 | }) 11 | const serverInfo = request.Info 12 | 13 | state.name = serverInfo.Description 14 | state.numplayers = serverInfo.OnlinePlayers 15 | state.maxplayers = serverInfo.TotalPlayers 16 | state.password = serverInfo.HasPassword 17 | state.gamePort = serverInfo.GamePort 18 | state.players = serverInfo.OnlinePlayersNames?.map(name => ({ name, raw: {} })) || [] 19 | state.raw = serverInfo 20 | state.version = state.raw.Version 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /protocols/eldewrito.js: -------------------------------------------------------------------------------- 1 | import Core from './core.js' 2 | 3 | export default class eldewrito extends Core { 4 | async run (state) { 5 | const json = await this.request({ 6 | url: 'http://' + this.options.address + ':' + this.options.port, 7 | responseType: 'json' 8 | }) 9 | 10 | for (const one of json.players) { 11 | state.players.push({ name: one.name, team: one.team }) 12 | } 13 | 14 | state.name = json.name 15 | state.map = json.map 16 | state.maxplayers = json.maxPlayers 17 | state.connect = this.options.address + ':' + json.port 18 | 19 | state.raw = json 20 | if ('eldewritoVersion' in state.raw) state.version = state.raw.eldewritoVersion 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /protocols/epic.js: -------------------------------------------------------------------------------- 1 | import Core from './core.js' 2 | 3 | export default class Epic extends Core { 4 | constructor () { 5 | super() 6 | 7 | /** 8 | * To get information about game servers using Epic's EOS, you need some credentials to authenticate using OAuth2. 9 | * 10 | * https://dev.epicgames.com/docs/web-api-ref/authentication 11 | * 12 | * These credentials can be provided by the game developers or extracted from the game's files. 13 | */ 14 | this.clientId = null 15 | this.clientSecret = null 16 | this.deploymentId = null 17 | this.epicApi = 'https://api.epicgames.dev' 18 | this.authByExternalToken = false // Some games require a client access token to POST to the matchmaking endpoint. 19 | 20 | this.deviceIdAccessToken = null 21 | this.accessToken = null 22 | 23 | // Don't use the tcp ping probing 24 | this.usedTcp = true 25 | } 26 | 27 | async run (state) { 28 | if (this.authByExternalToken) { 29 | await this.getExternalAccessToken() 30 | } else { 31 | await this.getClientAccessToken() 32 | } 33 | 34 | await this.queryInfo(state) 35 | await this.cleanup(state) 36 | } 37 | 38 | async getClientAccessToken () { 39 | this.logger.debug('Requesting client access token ...') 40 | 41 | const url = `${this.epicApi}/auth/v1/oauth/token` 42 | const body = `grant_type=client_credentials&deployment_id=${this.deploymentId}` 43 | const headers = { 44 | Authorization: `Basic ${Buffer.from(`${this.clientId}:${this.clientSecret}`).toString('base64')}`, 45 | 'Content-Type': 'application/x-www-form-urlencoded' 46 | } 47 | 48 | this.logger.debug(`POST: ${url}`) 49 | const response = await this.request({ url, body, headers, method: 'POST', responseType: 'json' }) 50 | 51 | this.accessToken = response.access_token 52 | } 53 | 54 | async _getDeviceIdToken () { 55 | this.logger.debug('Requesting deviceId access token ...') 56 | 57 | const url = `${this.epicApi}/auth/v1/accounts/deviceid` 58 | const body = 'deviceModel=PC' 59 | const headers = { 60 | Authorization: `Basic ${Buffer.from(`${this.clientId}:${this.clientSecret}`).toString('base64')}`, 61 | 'Content-Type': 'application/x-www-form-urlencoded' 62 | } 63 | 64 | this.logger.debug(`POST: ${url}`) 65 | const response = await this.request({ url, body, headers, method: 'POST', responseType: 'json' }) 66 | 67 | return response.access_token 68 | } 69 | 70 | async getExternalAccessToken () { 71 | this.logger.debug('Requesting external access token ...') 72 | 73 | const deviceIdToken = await this._getDeviceIdToken() 74 | 75 | const url = `${this.epicApi}/auth/v1/oauth/token` 76 | 77 | const bodyParts = [ 78 | 'grant_type=external_auth', 79 | 'external_auth_type=deviceid_access_token', 80 | `external_auth_token=${deviceIdToken}`, 81 | 'nonce=ABCHFA3qgUCJ1XTPAoGDEF', // This is required but can be set to anything 82 | `deployment_id=${this.deploymentId}`, 83 | 'display_name=User' 84 | ] 85 | 86 | const body = bodyParts.join('&') 87 | const headers = { 88 | Authorization: `Basic ${Buffer.from(`${this.clientId}:${this.clientSecret}`).toString('base64')}`, 89 | 'Content-Type': 'application/x-www-form-urlencoded' 90 | } 91 | 92 | this.logger.debug(`POST: ${url}`) 93 | const response = await this.request({ url, body, headers, method: 'POST', responseType: 'json' }) 94 | 95 | this.accessToken = response.access_token 96 | } 97 | 98 | async queryInfo (state) { 99 | const url = `${this.epicApi}/matchmaking/v1/${this.deploymentId}/filter` 100 | const body = { 101 | criteria: [ 102 | { 103 | key: 'attributes.ADDRESS_s', 104 | op: 'EQUAL', 105 | value: this.options.address 106 | } 107 | ] 108 | } 109 | const headers = { 110 | 'Content-Type': 'application/json', 111 | Accept: 'application/json', 112 | Authorization: `Bearer ${this.accessToken}` 113 | } 114 | 115 | this.logger.debug(`POST: ${url}`) 116 | const response = await this.request({ url, json: body, headers, method: 'POST', responseType: 'json' }) 117 | 118 | // Epic returns a list of sessions, we need to find the one with the desired port. 119 | const hasDesiredPort = (session) => session.attributes.ADDRESSBOUND_s === `0.0.0.0:${this.options.port}` || 120 | session.attributes.ADDRESSBOUND_s === `${this.options.address}:${this.options.port}` || 121 | session.attributes.GAMESERVER_PORT_l === this.options.port 122 | 123 | const desiredServer = response.sessions.find(hasDesiredPort) 124 | 125 | if (!desiredServer) { 126 | throw new Error('Server not found') 127 | } 128 | 129 | state.name = desiredServer.attributes.CUSTOMSERVERNAME_s 130 | state.map = desiredServer.attributes.MAPNAME_s 131 | state.password = desiredServer.attributes.SERVERPASSWORD_b 132 | state.numplayers = desiredServer.totalPlayers 133 | state.maxplayers = desiredServer.settings.maxPublicPlayers 134 | 135 | for (const player of desiredServer.publicPlayers) { 136 | state.players.push({ 137 | name: player.name, 138 | raw: player 139 | }) 140 | } 141 | 142 | state.raw = desiredServer 143 | } 144 | 145 | async cleanup (state) { 146 | this.accessToken = null 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /protocols/factorio.js: -------------------------------------------------------------------------------- 1 | import Core from './core.js' 2 | 3 | export default class factorio extends Core { 4 | async run (state) { 5 | if (!this.options.port) this.options.port = 34197 6 | this.usedTcp = true 7 | 8 | const serverInfo = await this.request({ 9 | url: `https://multiplayer.factorio.com/get-game-details/${this.options.address}:${this.options.port}`, 10 | responseType: 'json' 11 | }) 12 | 13 | const players = serverInfo.players || [] // the 'players' field is undefined if there are no players 14 | 15 | state.name = serverInfo.name 16 | state.password = serverInfo.has_password 17 | state.numplayers = players.length 18 | state.maxplayers = serverInfo.max_players 19 | state.players = players.map(player => ({ name: player, raw: {} })) 20 | 21 | state.raw = serverInfo 22 | state.version = state.raw.application_version.game_version + '.' + state.raw.application_version.build_version 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /protocols/farmingsimulator.js: -------------------------------------------------------------------------------- 1 | import Core from './core.js' 2 | import { XMLParser, XMLValidator } from 'fast-xml-parser' 3 | 4 | export default class farmingsimulator extends Core { 5 | async run (state) { 6 | if (!this.options.port) this.options.port = 8080 7 | if (!this.options.token) throw new Error(`No token provided. You can get it from http://${this.options.host}:${this.options.port}/settings.html`) 8 | 9 | const request = await this.request({ 10 | url: `http://${this.options.host}:${this.options.port}/feed/dedicated-server-stats.xml?code=${this.options.token}`, 11 | responseType: 'text' 12 | }) 13 | 14 | const isValidXML = XMLValidator.validate(request) 15 | if (!isValidXML) { 16 | throw new Error('Invalid XML received from Farming Simulator Server') 17 | } 18 | 19 | const parser = new XMLParser({ ignoreAttributes: false }) 20 | const parsed = parser.parse(request) 21 | 22 | const serverInfo = parsed.Server 23 | const playerInfo = serverInfo.Slots 24 | 25 | // Attributes in fast-xml-parser are prefixed with @_ 26 | 27 | state.name = serverInfo['@_name'] 28 | state.map = serverInfo['@_mapName'] 29 | state.numplayers = parseInt(playerInfo['@_numUsed'], 10) || 0 30 | state.maxplayers = parseInt(playerInfo['@_capacity'], 10) || 0 31 | 32 | const players = playerInfo.Player 33 | 34 | for (const player of players) { 35 | if (player['@_isUsed'] !== 'true') { continue } 36 | 37 | state.players.push({ 38 | name: player['#text'], 39 | isUsed: player['@_isUsed'] === 'true', 40 | isAdmin: player['@_isAdmin'] === 'true', 41 | uptime: parseInt(player['@_uptime'], 10), 42 | x: parseFloat(player['@_x']), 43 | y: parseFloat(player['@_y']), 44 | z: parseFloat(player['@_z']) 45 | }) 46 | } 47 | 48 | state.raw = { 49 | data: request, 50 | mods: [] 51 | } 52 | 53 | const mods = serverInfo.Mods.Mod 54 | 55 | for (const mod of mods) { 56 | if (mod['@_name'] == null) { continue } 57 | 58 | state.raw.mods.push({ 59 | name: mod['#text'], 60 | short_name: mod['@_name'], 61 | author: mod['@_author'], 62 | version: mod['@_version'], 63 | hash: mod['@_hash'] 64 | }) 65 | } 66 | 67 | state.version = serverInfo['@_version'] 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /protocols/ffow.js: -------------------------------------------------------------------------------- 1 | import valve from './valve.js' 2 | 3 | export default class ffow extends valve { 4 | constructor () { 5 | super() 6 | this.byteorder = 'be' 7 | this.legacyChallenge = true 8 | } 9 | 10 | async queryInfo (state) { 11 | this.logger.debug('Requesting ffow info ...') 12 | const b = await this.sendPacket( 13 | 0x46, 14 | 'LSQ', 15 | 0x49 16 | ) 17 | 18 | const reader = this.reader(b) 19 | state.raw.protocol = reader.uint(1) 20 | state.name = reader.string() 21 | state.map = reader.string() 22 | state.raw.mod = reader.string() 23 | state.raw.gamemode = reader.string() 24 | state.raw.description = reader.string() 25 | state.version = reader.string() 26 | state.gamePort = reader.uint(2) 27 | state.numplayers = reader.uint(1) 28 | state.maxplayers = reader.uint(1) 29 | state.raw.listentype = String.fromCharCode(reader.uint(1)) 30 | state.raw.environment = String.fromCharCode(reader.uint(1)) 31 | state.password = !!reader.uint(1) 32 | state.raw.secure = reader.uint(1) 33 | state.raw.averagefps = reader.uint(1) 34 | state.raw.round = reader.uint(1) 35 | state.raw.maxrounds = reader.uint(1) 36 | state.raw.timeleft = reader.uint(2) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /protocols/fivem.js: -------------------------------------------------------------------------------- 1 | import quake2 from './quake2.js' 2 | 3 | export default class fivem extends quake2 { 4 | constructor () { 5 | super() 6 | this.sendHeader = 'getinfo xxx' 7 | this.responseHeader = 'infoResponse' 8 | this.encoding = 'utf8' 9 | } 10 | 11 | async run (state) { 12 | await super.run(state) 13 | 14 | { 15 | const json = await this.request({ 16 | url: 'http://' + this.options.address + ':' + this.options.port + '/info.json', 17 | responseType: 'json' 18 | }) 19 | state.raw.info = json 20 | if ('version' in state.raw.info) state.version = state.raw.info.version 21 | } 22 | 23 | try { 24 | // TODO: #674, eventually add `requestPlayers` and `requestPlayersRequired`. 25 | const json = await this.request({ 26 | url: 'http://' + this.options.address + ':' + this.options.port + '/players.json', 27 | responseType: 'json' 28 | }) 29 | state.raw.players = json 30 | for (const player of json) { 31 | state.players.push({ name: player.name, ping: player.ping }) 32 | } 33 | } catch (_) {} 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /protocols/gamespy1.js: -------------------------------------------------------------------------------- 1 | import Core from './core.js' 2 | 3 | const stringKeys = new Set([ 4 | 'website', 5 | 'gametype', 6 | 'gamemode', 7 | 'player' 8 | ]) 9 | 10 | function normalizeEntry ([key, value]) { 11 | key = key.toLowerCase() 12 | const split = key.split('_') 13 | let keyType = key 14 | 15 | if (split.length === 2 && !isNaN(Number(split[1]))) { 16 | keyType = split[0] 17 | } 18 | 19 | if (!stringKeys.has(keyType) && !keyType.includes('name')) { // todo! the latter check might be problematic, fails on key "name_tag_distance_scope" 20 | if (value.toLowerCase() === 'true') { 21 | value = true 22 | } else if (value.toLowerCase() === 'false') { 23 | value = false 24 | } else if (value.length && !isNaN(Number(value))) { 25 | value = Number(value) 26 | } 27 | } 28 | 29 | return [key, value] 30 | } 31 | 32 | export default class gamespy1 extends Core { 33 | constructor () { 34 | super() 35 | this.encoding = 'latin1' 36 | this.byteorder = 'be' 37 | } 38 | 39 | async run (state) { 40 | const raw = await this.sendPacket('\\status\\xserverquery') 41 | // Convert all keys to lowercase and normalize value types 42 | const data = Object.fromEntries(Object.entries(raw).map(entry => normalizeEntry(entry))) 43 | state.raw = data 44 | if ('hostname' in data) state.name = data.hostname 45 | if ('mapname' in data) state.map = data.mapname 46 | if (this.trueTest(data.password)) state.password = true 47 | if ('maxplayers' in data) state.maxplayers = Number(data.maxplayers) 48 | if ('hostport' in data) state.gamePort = Number(data.hostport) 49 | 50 | const teamOffByOne = data.gamename === 'bfield1942' 51 | const playersById = {} 52 | const teamNamesById = {} 53 | for (const ident of Object.keys(data)) { 54 | const split = ident.split('_') 55 | if (split.length !== 2) continue 56 | let key = split[0].toLowerCase() 57 | const id = Number(split[1]) 58 | if (isNaN(id)) continue 59 | let value = data[ident] 60 | 61 | delete data[ident] 62 | 63 | if (key !== 'team' && key.startsWith('team')) { 64 | // Info about a team 65 | if (key === 'teamname') { 66 | teamNamesById[id] = value 67 | } else { 68 | // other team info which we don't track 69 | } 70 | } else { 71 | // Info about a player 72 | if (!(id in playersById)) playersById[id] = {} 73 | 74 | if (key === 'playername' || key === 'player') { 75 | key = 'name' 76 | } 77 | if (key === 'team' && !isNaN(value)) { // todo! technically, this NaN check isn't needed. 78 | key = 'teamId' 79 | value += teamOffByOne ? -1 : 0 80 | } 81 | 82 | playersById[id][key] = value 83 | } 84 | } 85 | state.raw.teams = teamNamesById 86 | 87 | const players = Object.values(playersById) 88 | 89 | const seenHashes = new Set() 90 | for (const player of players) { 91 | // Some servers (bf1942) report the same player multiple times (bug?) 92 | // Ignore these duplicates 93 | if (player.keyhash) { 94 | if (seenHashes.has(player.keyhash)) { 95 | this.logger.debug('Rejected player with hash ' + player.keyhash + ' (Duplicate keyhash)') 96 | continue 97 | } else { 98 | seenHashes.add(player.keyhash) 99 | } 100 | } 101 | 102 | // Convert player's team ID to team name if possible 103 | if (Object.prototype.hasOwnProperty.call(player, 'teamId')) { 104 | if (Object.keys(teamNamesById).length) { 105 | player.team = teamNamesById[player.teamId] || '' 106 | } else { 107 | player.team = player.teamId 108 | delete player.teamId 109 | } 110 | } 111 | 112 | state.players.push(player) 113 | } 114 | 115 | state.numplayers = state.players.length 116 | state.version = state.raw.gamever 117 | } 118 | 119 | async sendPacket (type) { 120 | let receivedQueryId 121 | const output = {} 122 | const parts = new Set() 123 | let maxPartNum = 0 124 | 125 | return await this.udpSend(type, buffer => { 126 | const reader = this.reader(buffer) 127 | const str = reader.string(buffer.length) 128 | const split = str.split('\\') 129 | split.shift() 130 | const data = {} 131 | while (split.length) { 132 | const key = split.shift() 133 | const value = split.shift() || '' 134 | data[key] = value 135 | } 136 | 137 | let queryId, partNum 138 | const partFinal = ('final' in data) 139 | if (data.queryid) { 140 | const split = data.queryid.split('.') 141 | if (split.length >= 2) { 142 | partNum = Number(split[1]) 143 | } 144 | queryId = split[0] 145 | } 146 | delete data.final 147 | delete data.queryid 148 | this.logger.debug('Received part num=' + partNum + ' queryId=' + queryId + ' final=' + partFinal) 149 | 150 | if (queryId) { 151 | if (receivedQueryId && receivedQueryId !== queryId) { 152 | this.logger.debug('Rejected packet (Wrong query ID)') 153 | return 154 | } else if (!receivedQueryId) { 155 | receivedQueryId = queryId 156 | } 157 | } 158 | if (!partNum) { 159 | partNum = parts.size 160 | this.logger.debug('No part number received (assigned #' + partNum + ')') 161 | } 162 | if (parts.has(partNum)) { 163 | this.logger.debug('Rejected packet (Duplicate part)') 164 | return 165 | } 166 | parts.add(partNum) 167 | if (partFinal) { 168 | maxPartNum = partNum 169 | } 170 | 171 | this.logger.debug('Received part #' + partNum + ' of ' + (maxPartNum || '?')) 172 | for (const i of Object.keys(data)) { 173 | output[i] = data[i] 174 | } 175 | if (maxPartNum && parts.size === maxPartNum) { 176 | this.logger.debug('Received all parts') 177 | this.logger.debug(output) 178 | return output 179 | } 180 | }) 181 | } 182 | } 183 | -------------------------------------------------------------------------------- /protocols/gamespy2.js: -------------------------------------------------------------------------------- 1 | import Core from './core.js' 2 | 3 | export default class gamespy2 extends Core { 4 | constructor () { 5 | super() 6 | this.encoding = 'latin1' 7 | this.byteorder = 'be' 8 | } 9 | 10 | async run (state) { 11 | // Parse info 12 | { 13 | const body = await this.sendPacket([0xff, 0, 0]) 14 | const reader = this.reader(body) 15 | while (!reader.done()) { 16 | const key = reader.string() 17 | const value = reader.string() 18 | if (!key) break 19 | state.raw[key] = value 20 | } 21 | if ('hostname' in state.raw) state.name = state.raw.hostname 22 | if ('mapname' in state.raw) state.map = state.raw.mapname 23 | if (this.trueTest(state.raw.password)) state.password = true 24 | if ('maxplayers' in state.raw) state.maxplayers = parseInt(state.raw.maxplayers) 25 | if ('hostport' in state.raw) state.gamePort = parseInt(state.raw.hostport) 26 | if ('gamever' in state.raw) state.version = state.raw.gamever 27 | } 28 | 29 | // Parse players 30 | { 31 | const body = await this.sendPacket([0, 0xff, 0]) 32 | const reader = this.reader(body) 33 | for (const rawPlayer of this.readFieldData(reader)) { 34 | state.players.push(rawPlayer) 35 | } 36 | 37 | if ('numplayers' in state.raw) state.numplayers = parseInt(state.raw.numplayers) 38 | else state.numplayers = state.players.length 39 | } 40 | 41 | // Parse teams 42 | { 43 | const body = await this.sendPacket([0, 0, 0xff]) 44 | const reader = this.reader(body) 45 | state.raw.teams = this.readFieldData(reader) 46 | } 47 | 48 | // Special case for america's army 1 and 2 49 | // both use gamename = "armygame" 50 | if (state.raw.gamename === 'armygame') { 51 | const stripColor = (str) => { 52 | // uses unreal 2 color codes 53 | return this.options.stripColors ? str.replace(/\x1b...|[\x00-\x1a]/g, '') : str 54 | } 55 | state.name = stripColor(state.name) 56 | state.map = stripColor(state.map) 57 | for (const key of Object.keys(state.raw)) { 58 | if (typeof state.raw[key] === 'string') { 59 | state.raw[key] = stripColor(state.raw[key]) 60 | } 61 | } 62 | for (const player of state.players) { 63 | if (!('name' in player)) continue 64 | player.name = stripColor(player.name) 65 | } 66 | } 67 | } 68 | 69 | async sendPacket (type) { 70 | const request = Buffer.concat([ 71 | Buffer.from([0xfe, 0xfd, 0x00]), // gamespy2 72 | Buffer.from([0x00, 0x00, 0x00, 0x01]), // ping ID 73 | Buffer.from(type) 74 | ]) 75 | return await this.udpSend(request, buffer => { 76 | const reader = this.reader(buffer) 77 | const header = reader.uint(1) 78 | if (header !== 0) return 79 | const pingId = reader.uint(4) 80 | if (pingId !== 1) return 81 | return reader.rest() 82 | }) 83 | } 84 | 85 | readFieldData (reader) { 86 | reader.uint(1) // always 0 87 | const count = reader.uint(1) // number of rows in this data 88 | 89 | // some games omit the count byte entirely if it's 0 or at random (like americas army) 90 | // Luckily, count should always be <64, and ascii characters will typically be >64, 91 | // so we can detect this. 92 | if (count > 64) { 93 | reader.skip(-1) 94 | this.logger.debug('Detected missing count byte, rewinding by 1') 95 | } else { 96 | this.logger.debug('Detected row count: ' + count) 97 | } 98 | 99 | this.logger.debug(() => 'Reading fields, starting at: ' + reader.rest()) 100 | 101 | const fields = [] 102 | while (!reader.done()) { 103 | const field = reader.string() 104 | if (!field) break 105 | fields.push(field) 106 | this.logger.debug('field:' + field) 107 | } 108 | 109 | if (!fields.length) return [] 110 | 111 | const units = [] 112 | while (!reader.done()) { 113 | const unit = {} 114 | for (let index = 0; index < fields.length; index++) { 115 | let key = fields[index] 116 | let value = reader.string() 117 | if (!value && index === 0) return units 118 | 119 | this.logger.debug('value:' + value) 120 | 121 | // many fields end with "_" 122 | if (key.endsWith('_')) { 123 | key = key.slice(0, -1) 124 | } 125 | 126 | if (key === 'player') key = 'name' 127 | else if (key === 'team_t') key = 'name' 128 | else if (key === 'tickets_t') key = 'tickets' 129 | 130 | if (['score', 'deaths', 'ping', 'team', 'kills', 'tickets'].includes(key)) { 131 | if (value === '') continue 132 | value = parseInt(value) 133 | } 134 | 135 | unit[key] = value 136 | } 137 | units.push(unit) 138 | } 139 | 140 | return units 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /protocols/gamespy3.js: -------------------------------------------------------------------------------- 1 | import Core from './core.js' 2 | 3 | export default class gamespy3 extends Core { 4 | constructor () { 5 | super() 6 | this.sessionId = 1 7 | this.encoding = 'latin1' 8 | this.byteorder = 'be' 9 | this.useOnlySingleSplit = false 10 | this.isJc2mp = false 11 | } 12 | 13 | async run (state) { 14 | const buffer = await this.sendPacket(9, false, false, false) 15 | const reader = this.reader(buffer) 16 | let challenge = parseInt(reader.string()) 17 | this.logger.debug('Received challenge key: ' + challenge) 18 | if (challenge === 0) { 19 | // Some servers send us a 0 if they don't want a challenge key used 20 | // BF2 does this. 21 | challenge = null 22 | } 23 | 24 | let requestPayload 25 | if (this.isJc2mp) { 26 | // they completely alter the protocol. because why not. 27 | requestPayload = Buffer.from([0xff, 0xff, 0xff, 0x02]) 28 | } else { 29 | requestPayload = Buffer.from([0xff, 0xff, 0xff, 0x01]) 30 | } 31 | /** @type Buffer[] */ 32 | const packets = await this.sendPacket(0, challenge, requestPayload, true) 33 | 34 | // iterate over the received packets 35 | // the first packet will start off with k/v pairs, followed with data fields 36 | // the following packets will only have data fields 37 | state.raw.playerTeamInfo = {} 38 | 39 | for (let iPacket = 0; iPacket < packets.length; iPacket++) { 40 | const packet = packets[iPacket] 41 | const reader = this.reader(packet) 42 | 43 | this.logger.debug('Parsing packet #' + iPacket) 44 | this.logger.debug(packet) 45 | 46 | // Parse raw server key/values 47 | 48 | if (iPacket === 0) { 49 | while (!reader.done()) { 50 | const key = reader.string() 51 | if (!key) break 52 | 53 | let value = reader.string() 54 | while (value.match(/^p[0-9]+$/)) { 55 | // fix a weird ut3 bug where some keys don't have values 56 | value = reader.string() 57 | } 58 | 59 | state.raw[key] = value 60 | this.logger.debug(key + ' = ' + value) 61 | } 62 | } 63 | 64 | // Parse player, team, item array state 65 | 66 | if (this.isJc2mp) { 67 | state.raw.numPlayers2 = reader.uint(2) 68 | while (!reader.done()) { 69 | const player = {} 70 | player.name = reader.string() 71 | player.steamid = reader.string() 72 | player.ping = reader.uint(2) 73 | state.players.push(player) 74 | } 75 | } else { 76 | while (!reader.done()) { 77 | if (reader.uint(1) <= 2) continue 78 | reader.skip(-1) 79 | const fieldId = reader.string() 80 | if (!fieldId) continue 81 | const fieldIdSplit = fieldId.split('_') 82 | const fieldName = fieldIdSplit[0] 83 | const itemType = fieldIdSplit.length > 1 ? fieldIdSplit[1] : 'no_' 84 | 85 | if (!(itemType in state.raw.playerTeamInfo)) { 86 | state.raw.playerTeamInfo[itemType] = [] 87 | } 88 | const items = state.raw.playerTeamInfo[itemType] 89 | 90 | let offset = reader.uint(1) 91 | 92 | this.logger.debug(() => 'Parsing new field: itemType=' + itemType + ' fieldName=' + fieldName + ' startOffset=' + offset) 93 | 94 | while (!reader.done()) { 95 | const item = reader.string() 96 | if (!item) break 97 | 98 | while (items.length <= offset) { items.push({}) } 99 | items[offset][fieldName] = item 100 | this.logger.debug('* ' + item) 101 | offset++ 102 | } 103 | } 104 | } 105 | } 106 | 107 | // Turn all that raw state into something useful 108 | 109 | if ('hostname' in state.raw) state.name = state.raw.hostname 110 | else if ('servername' in state.raw) state.name = state.raw.servername 111 | if ('mapname' in state.raw) state.map = state.raw.mapname 112 | if (state.raw.password === '1') state.password = true 113 | if ('maxplayers' in state.raw) state.maxplayers = parseInt(state.raw.maxplayers) 114 | if ('hostport' in state.raw) state.gamePort = parseInt(state.raw.hostport) 115 | if ('gamever' in state.raw) state.version = state.raw.gamever 116 | 117 | if ('' in state.raw.playerTeamInfo) { 118 | for (const playerInfo of state.raw.playerTeamInfo['']) { 119 | const player = {} 120 | for (const from of Object.keys(playerInfo)) { 121 | let key = from 122 | let value = playerInfo[from] 123 | 124 | if (key === 'player') key = 'name' 125 | if (['score', 'ping', 'team', 'deaths', 'pid'].includes(key)) value = parseInt(value) 126 | player[key] = value 127 | } 128 | state.players.push(player) 129 | } 130 | } 131 | 132 | if ('numplayers' in state.raw) state.numplayers = parseInt(state.raw.numplayers) 133 | else state.numplayers = state.players.length 134 | } 135 | 136 | async sendPacket (type, challenge, payload, assemble) { 137 | const challengeLength = challenge === null ? 0 : 4 138 | const payloadLength = payload ? payload.length : 0 139 | 140 | const b = Buffer.alloc(7 + challengeLength + payloadLength) 141 | b.writeUInt8(0xFE, 0) 142 | b.writeUInt8(0xFD, 1) 143 | b.writeUInt8(type, 2) 144 | b.writeUInt32BE(this.sessionId, 3) 145 | if (challengeLength) b.writeInt32BE(challenge, 7) 146 | if (payloadLength) payload.copy(b, 7 + challengeLength) 147 | 148 | let numPackets = 0 149 | const packets = {} 150 | return await this.udpSend(b, (buffer) => { 151 | const reader = this.reader(buffer) 152 | const iType = reader.uint(1) 153 | if (iType !== type) { 154 | this.logger.debug('Skipping packet, type mismatch') 155 | return 156 | } 157 | const iSessionId = reader.uint(4) 158 | if (iSessionId !== this.sessionId) { 159 | this.logger.debug('Skipping packet, session id mismatch') 160 | return 161 | } 162 | 163 | if (!assemble) { 164 | return reader.rest() 165 | } 166 | if (this.useOnlySingleSplit) { 167 | // has split headers, but they are worthless and only one packet is used 168 | reader.skip(11) 169 | return [reader.rest()] 170 | } 171 | 172 | reader.skip(9) // filler data -- usually set to 'splitnum\0' 173 | let id = reader.uint(1) 174 | const last = (id & 0x80) 175 | id = id & 0x7f 176 | if (last) numPackets = id + 1 177 | 178 | reader.skip(1) // "another 'packet number' byte, but isn't understood." 179 | 180 | packets[id] = reader.rest() 181 | if (this.debug) { 182 | this.logger.debug('Received packet #' + id + (last ? ' (last)' : '')) 183 | } 184 | 185 | if (!numPackets || Object.keys(packets).length !== numPackets) return 186 | 187 | // assemble the parts 188 | const list = [] 189 | for (let i = 0; i < numPackets; i++) { 190 | if (!(i in packets)) { 191 | throw new Error('Missing packet #' + i) 192 | } 193 | list.push(packets[i]) 194 | } 195 | return list 196 | }) 197 | } 198 | } 199 | -------------------------------------------------------------------------------- /protocols/geneshift.js: -------------------------------------------------------------------------------- 1 | import Core from './core.js' 2 | 3 | export default class geneshift extends Core { 4 | async run (state) { 5 | await this.tcpPing() 6 | 7 | const body = await this.request({ 8 | url: 'http://geneshift.net/game/receiveLobby.php' 9 | }) 10 | 11 | const split = body.split('
') 12 | let found = null 13 | for (const line of split) { 14 | const fields = line.split('::') 15 | const ip = fields[2] 16 | const port = fields[3] 17 | if (ip === this.options.address && parseInt(port) === this.options.port) { 18 | found = fields 19 | break 20 | } 21 | } 22 | 23 | if (found === null) { 24 | throw new Error('Server not found in list') 25 | } 26 | 27 | state.raw.countrycode = found[0] 28 | state.raw.country = found[1] 29 | state.name = found[4] 30 | state.map = found[5] 31 | state.numplayers = parseInt(found[6]) 32 | state.maxplayers = parseInt(found[7]) 33 | // fields[8] is unknown? 34 | state.raw.rules = found[9] 35 | state.raw.gamemode = parseInt(found[10]) 36 | state.raw.gangsters = parseInt(found[11]) 37 | state.raw.cashrate = parseInt(found[12]) 38 | state.raw.missions = !!parseInt(found[13]) 39 | state.raw.vehicles = !!parseInt(found[14]) 40 | state.raw.customweapons = !!parseInt(found[15]) 41 | state.raw.friendlyfire = !!parseInt(found[16]) 42 | state.raw.mercs = !!parseInt(found[17]) 43 | // fields[18] is unknown? listen server? 44 | state.version = found[19] 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /protocols/goldsrc.js: -------------------------------------------------------------------------------- 1 | import valve from './valve.js' 2 | 3 | export default class goldsrc extends valve { 4 | constructor () { 5 | super() 6 | this.goldsrcInfo = true 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /protocols/gtasao.js: -------------------------------------------------------------------------------- 1 | import samp from './samp.js' 2 | 3 | export default class gtasao extends samp { 4 | constructor () { 5 | super() 6 | this.isOmp = true 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /protocols/hawakening.js: -------------------------------------------------------------------------------- 1 | import hawakeningmaster from './hawakeningmaster.js' 2 | 3 | /** 4 | * Implements the protocol for Hawakening, a fan project of the UnrealEngine3 based game HAWKEN 5 | * using a Meteor backend for the master server 6 | */ 7 | export default class hawakening extends hawakeningmaster { 8 | constructor () { 9 | super() 10 | this.doQuerySingle = true 11 | this.requireToken = true 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /protocols/hexen2.js: -------------------------------------------------------------------------------- 1 | import quake1 from './quake1.js' 2 | 3 | export default class hexen2 extends quake1 { 4 | constructor () { 5 | super() 6 | this.sendHeader = '\xFFstatus\x0a' 7 | this.responseHeader = '\xffn' 8 | } 9 | 10 | async run (state) { 11 | await super.run(state) 12 | state.gamePort = this.options.port - 50 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /protocols/index.js: -------------------------------------------------------------------------------- 1 | import armagetron from './armagetron.js' 2 | import ase from './ase.js' 3 | import asa from './asa.js' 4 | import assettocorsa from './assettocorsa.js' 5 | import battlefield from './battlefield.js' 6 | import brokeprotocol from './brokeprotocol.js' 7 | import brokeprotocolmaster from './brokeprotocolmaster.js' 8 | import buildandshoot from './buildandshoot.js' 9 | import cs2d from './cs2d.js' 10 | import discord from './discord.js' 11 | import doom3 from './doom3.js' 12 | import eco from './eco.js' 13 | import eldewrito from './eldewrito.js' 14 | import epic from './epic.js' 15 | import factorio from './factorio.js' 16 | import farmingsimulator from './farmingsimulator.js' 17 | import ffow from './ffow.js' 18 | import fivem from './fivem.js' 19 | import gamespy1 from './gamespy1.js' 20 | import gamespy2 from './gamespy2.js' 21 | import gamespy3 from './gamespy3.js' 22 | import geneshift from './geneshift.js' 23 | import goldsrc from './goldsrc.js' 24 | import gtasao from './gtasao.js' 25 | import hawakening from './hawakening.js' 26 | import hawakeningmaster from './hawakeningmaster.js' 27 | import hexen2 from './hexen2.js' 28 | import jc2mp from './jc2mp.js' 29 | import kspdmp from './kspdmp.js' 30 | import mafia2mp from './mafia2mp.js' 31 | import mafia2online from './mafia2online.js' 32 | import minecraft from './minecraft.js' 33 | import minecraftbedrock from './minecraftbedrock.js' 34 | import minecraftvanilla from './minecraftvanilla.js' 35 | import minetest from './minetest.js' 36 | import mumble from './mumble.js' 37 | import mumbleping from './mumbleping.js' 38 | import nadeo from './nadeo.js' 39 | import openttd from './openttd.js' 40 | import palworld from './palworld.js' 41 | import quake1 from './quake1.js' 42 | import quake2 from './quake2.js' 43 | import quake3 from './quake3.js' 44 | import renegadex from './renegadex.js' 45 | import renegadexmaster from './renegadexmaster.js' 46 | import renown from './renown.js' 47 | import rfactor from './rfactor.js' 48 | import samp from './samp.js' 49 | import satisfactory from './satisfactory.js' 50 | import savage2 from './savage2.js' 51 | import soldat from './soldat.js' 52 | import starmade from './starmade.js' 53 | import starsiege from './starsiege.js' 54 | import teamspeak2 from './teamspeak2.js' 55 | import teamspeak3 from './teamspeak3.js' 56 | import terraria from './terraria.js' 57 | import toxikk from './toxikk.js' 58 | import tribes1 from './tribes1.js' 59 | import tribes1master from './tribes1master.js' 60 | import unreal2 from './unreal2.js' 61 | import ut3 from './ut3.js' 62 | import valve from './valve.js' 63 | import vcmp from './vcmp.js' 64 | import ventrilo from './ventrilo.js' 65 | import warsow from './warsow.js' 66 | import beammpmaster from './beammpmaster.js' 67 | import beammp from './beammp.js' 68 | import dayz from './dayz.js' 69 | import theisleevrima from './theisleevrima.js' 70 | import ragemp from './ragemp.js' 71 | import xonotic from './xonotic.js' 72 | import altvmp from './altvmp.js' 73 | import vintagestorymaster from './vintagestorymaster.js' 74 | import vintagestory from './vintagestory.js' 75 | import sdtd from './sdtd.js' 76 | 77 | export { 78 | armagetron, ase, asa, assettocorsa, battlefield, brokeprotocol, brokeprotocolmaster, buildandshoot, cs2d, discord, doom3, eco, epic, factorio, farmingsimulator, ffow, 79 | fivem, gamespy1, gamespy2, gamespy3, geneshift, goldsrc, gtasao, hawakening, hawakeningmaster, hexen2, jc2mp, kspdmp, mafia2mp, mafia2online, minecraft, 80 | minecraftbedrock, minecraftvanilla, minetest, mumble, mumbleping, nadeo, openttd, palworld, quake1, quake2, quake3, renegadex, renegadexmaster, renown, rfactor, ragemp, samp, 81 | satisfactory, soldat, savage2, starmade, starsiege, teamspeak2, teamspeak3, terraria, toxikk, tribes1, tribes1master, unreal2, ut3, valve, 82 | vcmp, ventrilo, warsow, eldewrito, beammpmaster, beammp, dayz, theisleevrima, xonotic, altvmp, vintagestorymaster, vintagestory, sdtd 83 | } 84 | -------------------------------------------------------------------------------- /protocols/jc2mp.js: -------------------------------------------------------------------------------- 1 | import gamespy3 from './gamespy3.js' 2 | 3 | // supposedly, gamespy3 is the "official" query protocol for jcmp, 4 | // but it's broken (requires useOnlySingleSplit), and may not include some player names 5 | export default class jc2mp extends gamespy3 { 6 | constructor () { 7 | super() 8 | this.useOnlySingleSplit = true 9 | this.isJc2mp = true 10 | this.encoding = 'utf8' 11 | } 12 | 13 | async run (state) { 14 | await super.run(state) 15 | 16 | state.version = state.raw.version 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /protocols/kspdmp.js: -------------------------------------------------------------------------------- 1 | import Core from './core.js' 2 | 3 | export default class kspdmp extends Core { 4 | async run (state) { 5 | const json = await this.request({ 6 | url: 'http://' + this.options.address + ':' + this.options.port, 7 | responseType: 'json' 8 | }) 9 | 10 | for (const one of json.players) { 11 | state.players.push({ name: one.nickname, team: one.team }) 12 | } 13 | 14 | for (const key of Object.keys(json)) { 15 | state.raw[key] = json[key] 16 | } 17 | state.name = json.server_name 18 | state.maxplayers = json.max_players 19 | state.gamePort = json.port 20 | if (json.players) { 21 | const split = json.players.split(', ') 22 | for (const name of split) { 23 | state.players.push({ name }) 24 | } 25 | } 26 | state.numplayers = state.players.length 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /protocols/mafia2mp.js: -------------------------------------------------------------------------------- 1 | import Core from './core.js' 2 | 3 | export default class mafia2mp extends Core { 4 | constructor () { 5 | super() 6 | this.encoding = 'latin1' 7 | this.header = 'M2MP' 8 | this.isMafia2Online = false 9 | } 10 | 11 | async run (state) { 12 | const body = await this.udpSend(this.header, (buffer) => { 13 | const reader = this.reader(buffer) 14 | const header = reader.string(this.header.length) 15 | if (header !== this.header) return 16 | return reader.rest() 17 | }) 18 | 19 | const reader = this.reader(body) 20 | state.name = this.readString(reader) 21 | state.numplayers = parseInt(this.readString(reader)) 22 | state.maxplayers = parseInt(this.readString(reader)) 23 | state.raw.gamemode = this.readString(reader) 24 | state.version = state.raw.gamemode 25 | state.password = !!reader.uint(1) 26 | state.gamePort = this.options.port - 1 27 | 28 | while (!reader.done()) { 29 | const player = {} 30 | player.name = this.readString(reader) 31 | if (!player.name) break 32 | if (this.isMafia2Online) { 33 | player.ping = parseInt(this.readString(reader)) 34 | } 35 | state.players.push(player) 36 | } 37 | } 38 | 39 | readString (reader) { 40 | return reader.pascalString(1, -1) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /protocols/mafia2online.js: -------------------------------------------------------------------------------- 1 | import mafia2mp from './mafia2mp.js' 2 | 3 | export default class mafia2online extends mafia2mp { 4 | constructor () { 5 | super() 6 | this.header = 'M2Online' 7 | this.isMafia2Online = true 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /protocols/minecraft.js: -------------------------------------------------------------------------------- 1 | import Core from './core.js' 2 | import minecraftbedrock from './minecraftbedrock.js' 3 | import minecraftvanilla from './minecraftvanilla.js' 4 | import Gamespy3 from './gamespy3.js' 5 | 6 | /* 7 | Vanilla servers respond to minecraftvanilla only 8 | Some modded vanilla servers respond to minecraftvanilla and gamespy3, or gamespy3 only 9 | Some bedrock servers respond to gamespy3 only 10 | Some bedrock servers respond to minecraftbedrock only 11 | Unsure if any bedrock servers respond to gamespy3 and minecraftbedrock 12 | */ 13 | 14 | export default class minecraft extends Core { 15 | constructor () { 16 | super() 17 | this.srvRecord = '_minecraft._tcp' 18 | } 19 | 20 | async run (state) { 21 | /** @type {Promise[]} */ 22 | const promises = [] 23 | 24 | const vanillaResolver = new minecraftvanilla() 25 | vanillaResolver.options = this.options 26 | vanillaResolver.udpSocket = this.udpSocket 27 | promises.push(vanillaResolver) 28 | 29 | const gamespyResolver = new Gamespy3() 30 | gamespyResolver.options = { 31 | ...this.options, 32 | encoding: 'utf8' 33 | } 34 | gamespyResolver.udpSocket = this.udpSocket 35 | promises.push(gamespyResolver) 36 | 37 | const bedrockResolver = new minecraftbedrock() 38 | bedrockResolver.options = this.options 39 | bedrockResolver.udpSocket = this.udpSocket 40 | promises.push(bedrockResolver) 41 | 42 | const ranPromises = promises.map(p => p.runOnceSafe().catch(_ => undefined)) 43 | const [vanillaState, gamespyState, bedrockState] = await Promise.all(ranPromises) 44 | 45 | state.raw.vanilla = vanillaState 46 | state.raw.gamespy = gamespyState 47 | state.raw.bedrock = bedrockState 48 | 49 | if (!vanillaState && !gamespyState && !bedrockState) { 50 | throw new Error('No protocols succeeded') 51 | } 52 | 53 | // Ordered from least worth to most worth (player names / etc) 54 | if (bedrockState) { 55 | if (bedrockState.players.length) state.players = bedrockState.players 56 | } 57 | if (vanillaState) { 58 | try { 59 | let name = '' 60 | const description = vanillaState.raw.description 61 | if (typeof description === 'string') { 62 | name = description 63 | } else if (typeof description === 'object') { 64 | const stack = [description] 65 | 66 | while (stack.length) { 67 | const current = stack.pop() 68 | 69 | if (current.text) { 70 | name += current.text 71 | } 72 | 73 | if (Array.isArray(current.extra)) { 74 | stack.push(...current.extra.reverse()) 75 | } 76 | } 77 | } 78 | state.name = name 79 | } catch (e) {} 80 | if (vanillaState.numplayers) state.numplayers = vanillaState.numplayers 81 | if (vanillaState.maxplayers) state.maxplayers = vanillaState.maxplayers 82 | if (vanillaState.players.length) state.players = vanillaState.players 83 | if (vanillaState.ping) this.registerRtt(vanillaState.ping) 84 | if (vanillaState.raw.version) state.version = vanillaState.raw.version.name 85 | } 86 | if (gamespyState) { 87 | if (gamespyState.name) state.name = gamespyState.name 88 | if (gamespyState.numplayers) state.numplayers = gamespyState.numplayers 89 | if (gamespyState.maxplayers) state.maxplayers = gamespyState.maxplayers 90 | if (gamespyState.players.length) state.players = gamespyState.players 91 | else if (gamespyState.numplayers) state.numplayers = gamespyState.numplayers 92 | if (gamespyState.ping) this.registerRtt(gamespyState.ping) 93 | } 94 | if (bedrockState) { 95 | if (bedrockState.name) state.name = bedrockState.name 96 | if (bedrockState.numplayers) state.numplayers = bedrockState.numplayers 97 | if (bedrockState.maxplayers) state.maxplayers = bedrockState.maxplayers 98 | if (bedrockState.map) state.map = bedrockState.map 99 | if (bedrockState.ping) this.registerRtt(bedrockState.ping) 100 | if (bedrockState.raw.mcVersion) state.version = bedrockState.raw.mcVersion 101 | } 102 | // remove dupe spaces from name 103 | state.name = state.name.replace(/\s+/g, ' ') 104 | // remove color codes from name 105 | state.name = state.name.replace(/\u00A7./g, '') 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /protocols/minecraftbedrock.js: -------------------------------------------------------------------------------- 1 | import Core from './core.js' 2 | 3 | export default class minecraftbedrock extends Core { 4 | constructor () { 5 | super() 6 | this.byteorder = 'be' 7 | } 8 | 9 | async run (state) { 10 | const bufs = [ 11 | Buffer.from([0x01]), // Message ID, ID_UNCONNECTED_PING 12 | Buffer.from('1122334455667788', 'hex'), // Nonce / timestamp 13 | Buffer.from('00ffff00fefefefefdfdfdfd12345678', 'hex'), // Magic 14 | Buffer.from('0000000000000000', 'hex') // Cliend GUID 15 | ] 16 | 17 | return await this.udpSend(Buffer.concat(bufs), buffer => { 18 | const reader = this.reader(buffer) 19 | 20 | const messageId = reader.uint(1) 21 | if (messageId !== 0x1c) { 22 | this.logger.debug('Skipping packet, invalid message id') 23 | return 24 | } 25 | 26 | const nonce = reader.part(8).toString('hex') // should match the nonce we sent 27 | this.logger.debug('Nonce: ' + nonce) 28 | if (nonce !== '1122334455667788') { 29 | this.logger.debug('Skipping packet, invalid nonce') 30 | return 31 | } 32 | 33 | // These 8 bytes are identical to the serverId string we receive in decimal below 34 | reader.skip(8) 35 | 36 | const magic = reader.part(16).toString('hex') 37 | this.logger.debug('Magic value: ' + magic) 38 | if (magic !== '00ffff00fefefefefdfdfdfd12345678') { 39 | this.logger.debug('Skipping packet, invalid magic') 40 | return 41 | } 42 | 43 | const statusLen = reader.uint(2) 44 | if (reader.remaining() !== statusLen) { 45 | throw new Error('Invalid status length: ' + reader.remaining() + ' vs ' + statusLen) 46 | } 47 | 48 | const statusStr = reader.rest().toString('utf8') 49 | this.logger.debug('Raw status str: ' + statusStr) 50 | 51 | const split = statusStr.split(';') 52 | if (split.length < 6) { 53 | throw new Error('Missing enough chunks in status str') 54 | } 55 | 56 | state.raw.edition = split.shift() 57 | state.name = split.shift() 58 | state.raw.protocolVersion = split.shift() 59 | state.raw.mcVersion = split.shift() 60 | state.version = state.raw.mcVersion 61 | state.numplayers = parseInt(split.shift()) 62 | state.maxplayers = parseInt(split.shift()) 63 | if (split.length) state.raw.serverId = split.shift() 64 | if (split.length) state.map = split.shift() 65 | if (split.length) state.raw.gameMode = split.shift() 66 | if (split.length) state.raw.nintendoOnly = !!parseInt(split.shift()) 67 | if (split.length) state.raw.ipv4Port = split.shift() 68 | if (split.length) state.raw.ipv6Port = split.shift() 69 | 70 | return true 71 | }) 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /protocols/minecraftvanilla.js: -------------------------------------------------------------------------------- 1 | import Core from './core.js' 2 | import Varint from 'varint' 3 | 4 | export default class minecraftvanilla extends Core { 5 | async run (state) { 6 | const portBuf = Buffer.alloc(2) 7 | portBuf.writeUInt16BE(this.options.port, 0) 8 | 9 | const addressBuf = Buffer.from(this.options.host, 'utf8') 10 | 11 | const bufs = [ 12 | this.varIntBuffer(47), 13 | this.varIntBuffer(addressBuf.length), 14 | addressBuf, 15 | portBuf, 16 | this.varIntBuffer(1) 17 | ] 18 | 19 | const outBuffer = Buffer.concat([ 20 | this.buildPacket(0, Buffer.concat(bufs)), 21 | this.buildPacket(0) 22 | ]) 23 | 24 | const data = await this.withTcp(async socket => { 25 | return await this.tcpSend(socket, outBuffer, data => { 26 | if (data.length < 10) return 27 | const reader = this.reader(data) 28 | const length = reader.varint() 29 | if (data.length < length) return 30 | return reader.rest() 31 | }) 32 | }) 33 | 34 | const reader = this.reader(data) 35 | 36 | const packetId = reader.varint() 37 | this.logger.debug('Packet ID: ' + packetId) 38 | 39 | const strLen = reader.varint() 40 | this.logger.debug('String Length: ' + strLen) 41 | 42 | const rest = reader.rest() 43 | 44 | const str = rest.toString('utf8', 0, strLen) 45 | this.logger.debug(str) 46 | 47 | const json = JSON.parse(str.substring(0, strLen)) 48 | 49 | state.raw = json 50 | state.maxplayers = json.players.max 51 | state.numplayers = json.players.online 52 | 53 | if (json.players.sample) { 54 | for (const player of json.players.sample) { 55 | state.players.push({ 56 | id: player.id, 57 | name: player.name 58 | }) 59 | } 60 | } 61 | 62 | // Better Compatibility Checker mod support 63 | let bccJson = {} 64 | 65 | if (rest.length > strLen) { 66 | const bccStr = rest.toString('utf8', strLen + 1) 67 | bccJson = JSON.parse(bccStr) 68 | } 69 | 70 | state.raw.bcc = bccJson 71 | } 72 | 73 | varIntBuffer (num) { 74 | return Buffer.from(Varint.encode(num)) 75 | } 76 | 77 | buildPacket (id, data) { 78 | if (!data) data = Buffer.from([]) 79 | const idBuffer = this.varIntBuffer(id) 80 | return Buffer.concat([ 81 | this.varIntBuffer(data.length + idBuffer.length), 82 | idBuffer, 83 | data 84 | ]) 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /protocols/minetest.js: -------------------------------------------------------------------------------- 1 | import Core from './core.js' 2 | 3 | export default class minetest extends Core { 4 | constructor () { 5 | super() 6 | this.usedTcp = true 7 | } 8 | 9 | async run (state) { 10 | const servers = await this.request({ 11 | url: 'https://servers.minetest.net/list', 12 | responseType: 'json' 13 | }) 14 | 15 | if (servers == null) { 16 | throw new Error('Unable to retrieve master server list') 17 | } 18 | 19 | const serverInfo = servers.list.find( 20 | (server) => 21 | server.address === this.options.address && server.port === this.options.port 22 | ) 23 | 24 | if (serverInfo == null) { 25 | throw new Error('Server not found in master server list') 26 | } 27 | 28 | const players = serverInfo.clients_list || [] // the 'players' field is undefined if there are no players 29 | 30 | state.name = serverInfo.name 31 | state.password = serverInfo.password 32 | state.numplayers = serverInfo.clients || players.length 33 | state.maxplayers = serverInfo.clients_max 34 | state.players = players.map((player) => ({ name: player, raw: {} })) 35 | 36 | state.raw = serverInfo 37 | state.version = serverInfo.version 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /protocols/mumble.js: -------------------------------------------------------------------------------- 1 | import Core from './core.js' 2 | 3 | export default class mumble extends Core { 4 | async run (state) { 5 | const json = await this.withTcp(async socket => { 6 | return await this.tcpSend(socket, 'json', (buffer) => { 7 | if (buffer.length < 10) return 8 | const str = buffer.toString() 9 | let json 10 | try { 11 | json = JSON.parse(str) 12 | } catch (e) { 13 | // probably not all here yet 14 | return 15 | } 16 | return json 17 | }) 18 | }) 19 | 20 | state.raw = json 21 | state.name = json.name 22 | state.gamePort = json.x_gtmurmur_connectport || 64738 23 | 24 | let channelStack = [state.raw.root] 25 | while (channelStack.length) { 26 | const channel = channelStack.shift() 27 | channel.description = this.cleanComment(channel.description) 28 | channelStack = channelStack.concat(channel.channels) 29 | for (const user of channel.users) { 30 | user.comment = this.cleanComment(user.comment) 31 | state.players.push(user) 32 | } 33 | } 34 | } 35 | 36 | cleanComment (str) { 37 | return str.replace(/<.*>/g, '') 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /protocols/mumbleping.js: -------------------------------------------------------------------------------- 1 | import Core from './core.js' 2 | 3 | export default class mumbleping extends Core { 4 | constructor () { 5 | super() 6 | this.byteorder = 'be' 7 | } 8 | 9 | async run (state) { 10 | const data = await this.udpSend('\x00\x00\x00\x00\x01\x02\x03\x04\x05\x06\x07\x08', (buffer) => { 11 | if (buffer.length >= 24) return buffer 12 | }) 13 | 14 | const reader = this.reader(data) 15 | reader.skip(1) 16 | state.raw.versionMajor = reader.uint(1) 17 | state.raw.versionMinor = reader.uint(1) 18 | state.raw.versionPatch = reader.uint(1) 19 | state.version = state.raw.versionMajor + '.' + state.raw.versionMinor + '.' + state.raw.versionPatch 20 | reader.skip(8) 21 | state.numplayers = reader.uint(4) 22 | state.maxplayers = reader.uint(4) 23 | state.raw.allowedbandwidth = reader.uint(4) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /protocols/nadeo.js: -------------------------------------------------------------------------------- 1 | import Core from './core.js' 2 | import Promises from '../lib/Promises.js' 3 | import * as gbxremote from 'gbxremote' 4 | 5 | export default class nadeo extends Core { 6 | async run (state) { 7 | await this.withClient(async client => { 8 | const start = Date.now() 9 | await this.query(client, 'Authenticate', this.options.login, this.options.password) 10 | this.registerRtt(Date.now() - start) 11 | 12 | { 13 | const results = await this.query(client, 'GetServerOptions') 14 | state.name = this.stripColors(results.Name) 15 | state.password = (results.Password !== 'No password') 16 | state.maxplayers = results.CurrentMaxPlayers 17 | state.raw.maxspectators = results.CurrentMaxSpectators 18 | state.raw.GetServerOptions = results 19 | } 20 | 21 | { 22 | const results = await this.query(client, 'GetCurrentChallengeInfo') 23 | state.map = this.stripColors(results.Name) 24 | state.raw.GetCurrentChallengeInfo = results 25 | } 26 | 27 | { 28 | const results = await this.query(client, 'GetCurrentGameInfo') 29 | let gamemode = '' 30 | const igm = results.GameMode 31 | if (igm === 0) gamemode = 'Rounds' 32 | if (igm === 1) gamemode = 'Time Attack' 33 | if (igm === 2) gamemode = 'Team' 34 | if (igm === 3) gamemode = 'Laps' 35 | if (igm === 4) gamemode = 'Stunts' 36 | if (igm === 5) gamemode = 'Cup' 37 | state.raw.gametype = gamemode 38 | state.raw.mapcount = results.NbChallenge 39 | state.raw.GetCurrentGameInfo = results 40 | } 41 | 42 | { 43 | const results = await this.query(client, 'GetVersion') 44 | state.version = results.Version 45 | state.raw.GetVersion = results 46 | } 47 | 48 | if (this.options.port === 5000) { 49 | state.gamePort = 2350 50 | } 51 | 52 | state.raw.players = await this.query(client, 'GetPlayerList', 10000, 0) 53 | for (const player of state.raw.players) { 54 | state.players.push({ 55 | name: this.stripColors(player.Name || player.NickName) 56 | }) 57 | } 58 | state.numplayers = state.players.length 59 | }) 60 | } 61 | 62 | async withClient (fn) { 63 | const socket = new gbxremote.Client(this.options.port, this.options.host) 64 | try { 65 | const connectPromise = socket.connect() 66 | const timeoutPromise = Promises.createTimeout(this.options.socketTimeout, 'GBX Remote Opening') 67 | await Promise.race([connectPromise, timeoutPromise, this.abortedPromise]) 68 | return await fn(socket) 69 | } finally { 70 | socket.terminate() 71 | } 72 | } 73 | 74 | async query (client, command, ...args) { 75 | const params = args.slice() 76 | 77 | const sentPromise = client.query(command, params) 78 | const timeoutPromise = Promises.createTimeout(this.options.socketTimeout, 'GBX Method Call') 79 | return await Promise.race([sentPromise, timeoutPromise, this.abortedPromise]) 80 | } 81 | 82 | stripColors (str) { 83 | return this.options.stripColors ? str.replace(/\$([0-9a-f]{3}|[a-z])/gi, '') : str 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /protocols/openttd.js: -------------------------------------------------------------------------------- 1 | import Core from './core.js' 2 | 3 | export default class openttd extends Core { 4 | async run(state) { 5 | { 6 | let [reader, version] = await this.query(7, 6, 1, -1) 7 | 8 | if (version > 7) version = 7 //current version is 7, but this should work for heigher versions too 9 | 10 | switch (version) { 11 | case 7: 12 | state.raw.ticks_playing = reader.uint(8) 13 | case 6: 14 | state.raw.newgrf_serialisation = reader.uint(1) 15 | case 5: 16 | state.raw.gamescript_version = reader.uint(4) 17 | state.raw.gamescript_name = reader.string() // .replace(/\0/g, '') 18 | case 4: 19 | const numGrf = reader.uint(1) 20 | state.raw.grfs = [] 21 | for (let i = 0; i < numGrf; i++) { 22 | const grf = {} 23 | grf.id = reader.part(4).toString('hex') 24 | grf.md5 = reader.part(16).toString('hex') 25 | grf.name = reader.string() 26 | state.raw.grfs.push(grf) 27 | } 28 | case 3: 29 | state.raw.date_current = this.readDate(reader) 30 | state.raw.date_start = this.readDate(reader) 31 | case 2: 32 | state.raw.maxcompanies = reader.uint(1) 33 | state.raw.numcompanies = reader.uint(1) 34 | state.raw.maxspectators = reader.uint(1) //deprecated 35 | case 1: 36 | state.name = reader.string() 37 | state.version = reader.string() 38 | 39 | if (version < 6) { 40 | // reader.skip(1) 41 | state.raw.language = this.decode( 42 | reader.uint(1), 43 | ['any', 'en', 'de', 'fr'] 44 | ) 45 | } 46 | 47 | state.password = !!reader.uint(1) 48 | state.maxplayers = reader.uint(1) 49 | state.numplayers = reader.uint(1) 50 | state.raw.numspectators = reader.uint(1) 51 | 52 | if (version < 3) { 53 | state.raw.date_current = this.readOldDate(reader) 54 | state.raw.date_start = this.readOldDate(reader) 55 | } 56 | if (version < 6) { 57 | state.map = reader.string() 58 | } 59 | state.raw.map_width = reader.uint(2) 60 | state.raw.map_height = reader.uint(2) 61 | state.raw.landscape = this.decode( 62 | reader.uint(1), 63 | ['temperate', 'arctic', 'desert', 'toyland'] 64 | ) 65 | state.raw.dedicated = !!reader.uint(1) 66 | } 67 | 68 | if (version < 7) { 69 | const DAY_TICKS = 74; 70 | state.raw.ticks_playing = (new Date(state.raw.date_current).getTime() - new Date(state.raw.date_start).getTime()) / 1000 / 3600 / 24 * DAY_TICKS + 1280; //1280 looks like initial ticks after server start 71 | } 72 | } 73 | 74 | /** 75 | * this doesnt work with the current version of openttd and causes timeouts 76 | * leaving it here for the case that it will be fixed in the future 77 | */ 78 | // { 79 | // const [reader, version] = await this.query(2, 3, -1, -1) 80 | // // we don't know how to deal with companies outside version 6 81 | // if (version === 6) { 82 | // state.raw.companies = [] 83 | // const numCompanies = reader.uint(1) 84 | // for (let iCompany = 0; iCompany < numCompanies; iCompany++) { 85 | // const company = {} 86 | // company.id = reader.uint(1) 87 | // company.name = reader.string() 88 | // company.year_start = reader.uint(4) 89 | // company.value = reader.uint(8).toString() 90 | // company.money = reader.uint(8).toString() 91 | // company.income = reader.uint(8).toString() 92 | // company.performance = reader.uint(2) 93 | // company.password = !!reader.uint(1) 94 | 95 | // const vehicleTypes = ['train', 'truck', 'bus', 'aircraft', 'ship'] 96 | // const stationTypes = ['station', 'truckbay', 'busstation', 'airport', 'dock'] 97 | 98 | // company.vehicles = {} 99 | // for (const type of vehicleTypes) { 100 | // company.vehicles[type] = reader.uint(2) 101 | // } 102 | // company.stations = {} 103 | // for (const type of stationTypes) { 104 | // company.stations[type] = reader.uint(2) 105 | // } 106 | 107 | // company.clients = reader.string() 108 | // state.raw.companies.push(company) 109 | // } 110 | // } 111 | // } 112 | } 113 | 114 | async query (type, expected, minver, maxver) { 115 | const b = Buffer.from([0x03, 0x00, type]) 116 | return await this.withTcp(async socket => { 117 | return await this.tcpSend(socket, b, (buffer) => { 118 | const reader = this.reader(buffer) 119 | const packetLen = reader.uint(2) 120 | if (packetLen !== buffer.length) { 121 | this.logger.debug('Invalid reported packet length: ' + packetLen + ' ' + buffer.length) 122 | return 123 | } 124 | 125 | const packetType = reader.uint(1) 126 | if (packetType !== expected) { 127 | this.logger.debug('Unexpected response packet type: ' + packetType) 128 | return 129 | } 130 | 131 | const protocolVersion = reader.uint(1) 132 | if ((minver !== -1 && protocolVersion < minver) || (maxver !== -1 && protocolVersion > maxver)) { 133 | throw new Error('Unknown protocol version: ' + protocolVersion + ' Expected: ' + minver + '-' + maxver) 134 | } 135 | 136 | return [reader, protocolVersion] 137 | }) 138 | }) 139 | } 140 | 141 | readDate (reader) { 142 | const daysSinceZero = reader.uint(4) 143 | const temp = new Date(0, 0, 1) 144 | temp.setFullYear(0) 145 | temp.setDate(daysSinceZero + 2) // to show correct date here must be +2 146 | return temp.toISOString().split('T')[0] 147 | } 148 | 149 | readOldDate(reader) { 150 | const DAYS_TILL_ORIGIANAL_BASE_YEAR = 365 * 500 + 125 151 | const daysSinceZero = DAYS_TILL_ORIGIANAL_BASE_YEAR + reader.uint(2) 152 | const temp = new Date(0, 0, 1) 153 | temp.setFullYear(0) //not sure about this - no option to test it 154 | temp.setDate(daysSinceZero + 2) 155 | return temp.toISOString().split('T')[0] 156 | } 157 | 158 | decode (num, arr) { 159 | if (num < 0 || num >= arr.length) { 160 | return num 161 | } 162 | return arr[num] 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /protocols/palworld.js: -------------------------------------------------------------------------------- 1 | import Core from './core.js' 2 | 3 | export default class palworld extends Core { 4 | async makeCall (endpoint) { 5 | const url = `http://${this.options.host}:${this.options.port}/v1/api/${endpoint}` 6 | const headers = { 7 | Authorization: `Basic ${Buffer.from(`${this.options.username}:${this.options.password}`).toString('base64')}`, 8 | Accept: 'application/json' 9 | } 10 | 11 | return await this.request({ url, headers, method: 'GET', responseType: 'json' }) 12 | } 13 | 14 | async run (state) { 15 | const serverInfo = await this.makeCall('info') 16 | state.version = serverInfo.version 17 | state.name = serverInfo.servername 18 | state.raw.serverInfo = serverInfo 19 | 20 | const { players } = await this.makeCall('players') 21 | state.numplayers = players.length 22 | state.players = players.map((player) => ({ name: player.name, raw: player })) 23 | state.raw.players = players 24 | 25 | state.raw.settings = await this.makeCall('settings') 26 | 27 | const metrics = await this.makeCall('metrics') 28 | state.numplayers = metrics.currentplayernum 29 | state.maxplayers = metrics.maxplayernum 30 | state.raw.metrics = metrics 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /protocols/quake1.js: -------------------------------------------------------------------------------- 1 | import quake2 from './quake2.js' 2 | 3 | export default class quake1 extends quake2 { 4 | constructor () { 5 | super() 6 | this.responseHeader = 'n' 7 | this.isQuake1 = true 8 | } 9 | 10 | async run (state) { 11 | await super.run(state) 12 | if ('*version' in state.raw) state.version = state.raw['*version'] 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /protocols/quake2.js: -------------------------------------------------------------------------------- 1 | import Core from './core.js' 2 | 3 | export default class quake2 extends Core { 4 | constructor () { 5 | super() 6 | this.encoding = 'latin1' 7 | this.delimiter = '\n' 8 | this.sendHeader = 'status' 9 | this.responseHeader = 'print' 10 | this.isQuake1 = false 11 | } 12 | 13 | async run (state) { 14 | const body = await this.udpSend('\xff\xff\xff\xff' + this.sendHeader + '\x00', packet => { 15 | const reader = this.reader(packet) 16 | const header = reader.string({ length: 4, encoding: 'latin1' }) 17 | if (header !== '\xff\xff\xff\xff') return 18 | let type 19 | if (this.isQuake1) { 20 | type = reader.string(this.responseHeader.length) 21 | } else { 22 | type = reader.string({ encoding: 'latin1' }) 23 | } 24 | if (type !== this.responseHeader) return 25 | return reader.rest() 26 | }) 27 | 28 | const reader = this.reader(body) 29 | const info = reader.string().split('\\') 30 | if (info[0] === '') info.shift() 31 | 32 | while (true) { 33 | const key = info.shift() 34 | const value = info.shift() 35 | if (typeof value === 'undefined') break 36 | state.raw[key] = value 37 | } 38 | 39 | while (!reader.done()) { 40 | const line = reader.string() 41 | if (!line || line.charAt(0) === '\0') break 42 | 43 | const args = [] 44 | const split = line.split('"') 45 | split.forEach((part, i) => { 46 | const inQuote = (i % 2 === 1) 47 | if (inQuote) { 48 | args.push(part) 49 | } else { 50 | const splitSpace = part.split(' ') 51 | for (const subpart of splitSpace) { 52 | if (subpart) args.push(subpart) 53 | } 54 | } 55 | }) 56 | 57 | const player = {} 58 | if (this.isQuake1) { 59 | player.id = parseInt(args.shift()) 60 | player.score = parseInt(args.shift()) 61 | player.time = parseInt(args.shift()) 62 | player.ping = parseInt(args.shift()) 63 | player.name = args.shift() 64 | player.skin = args.shift() 65 | player.color1 = parseInt(args.shift()) 66 | player.color2 = parseInt(args.shift()) 67 | } else { 68 | player.frags = parseInt(args.shift()) 69 | player.ping = parseInt(args.shift()) 70 | player.name = args.shift() || '' 71 | if (!player.name) delete player.name 72 | player.address = args.shift() || '' 73 | if (!player.address) delete player.address 74 | } 75 | 76 | (player.ping ? state.players : state.bots).push(player) 77 | } 78 | 79 | if ('g_needpass' in state.raw) state.password = state.raw.g_needpass 80 | if ('mapname' in state.raw) state.map = state.raw.mapname 81 | if ('sv_maxclients' in state.raw) state.maxplayers = state.raw.sv_maxclients 82 | if ('maxclients' in state.raw) state.maxplayers = state.raw.maxclients 83 | if ('sv_hostname' in state.raw) state.name = state.raw.sv_hostname 84 | if ('hostname' in state.raw) state.name = state.raw.hostname 85 | if ('clients' in state.raw) state.numplayers = state.raw.clients 86 | if ('version' in state.raw) state.version = state.raw.version 87 | if ('iv' in state.raw) state.version = state.raw.iv 88 | else state.numplayers = state.players.length + state.bots.length 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /protocols/quake3.js: -------------------------------------------------------------------------------- 1 | import quake2 from './quake2.js' 2 | 3 | export default class quake3 extends quake2 { 4 | constructor () { 5 | super() 6 | this.sendHeader = 'getstatus' 7 | this.responseHeader = 'statusResponse' 8 | } 9 | 10 | async run (state) { 11 | await super.run(state) 12 | state.name = this.stripColors(state.name) 13 | for (const key of Object.keys(state.raw)) { 14 | state.raw[key] = this.stripColors(state.raw[key]) 15 | if ('version' in state.raw) state.version = state.raw.version 16 | } 17 | for (const player of state.players) { 18 | player.name = this.stripColors(player.name) 19 | } 20 | for (const bot of state.bots) { 21 | bot.name = this.stripColors(bot.name) 22 | } 23 | } 24 | 25 | stripColors (str) { 26 | return this.options.stripColors ? str.replace(/\^(X.{6}|.)/g, '') : str 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /protocols/ragemp.js: -------------------------------------------------------------------------------- 1 | import Core from './core.js' 2 | 3 | export default class ragemp extends Core { 4 | constructor () { 5 | super() 6 | this.usedTcp = true 7 | } 8 | 9 | async run (state) { 10 | const results = await this.request({ 11 | url: 'https://cdn.rage.mp/master/v2/', 12 | responseType: 'json' 13 | }) 14 | 15 | if (results == null) { 16 | throw new Error('Unable to retrieve master server list') 17 | } 18 | 19 | const targetID = `${this.options.host}:${this.options.port}` 20 | 21 | let serverResult = null 22 | let serverInfo = null 23 | 24 | for (const entry of results) { 25 | if (entry.id === targetID) { 26 | serverResult = entry 27 | serverInfo = entry.servers.at(0) 28 | break 29 | } 30 | 31 | for (const serverEntry of entry.servers) { 32 | if (serverEntry.id === targetID) { 33 | serverResult = entry 34 | serverInfo = serverEntry 35 | break 36 | } 37 | } 38 | } 39 | 40 | if (serverInfo == null) { 41 | throw new Error('Server not found in master server list.') 42 | } 43 | 44 | state.name = serverInfo.name 45 | state.numplayers = serverInfo.players.amount 46 | state.maxplayers = serverInfo.players.max 47 | state.raw = serverResult 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /protocols/renegadex.js: -------------------------------------------------------------------------------- 1 | import Core from './core.js' 2 | // import Ajv from 'ajv' 3 | // const ajv = new Ajv() 4 | 5 | export const MasterServerServerInfoSchema = { 6 | type: 'object', 7 | required: [ 8 | 'IP', 9 | 'Port', 10 | 'Name', 11 | 'Current Map', 12 | 'Bots', 13 | 'Players', 14 | 'Game Version', 15 | 'Variables' 16 | ], 17 | properties: { 18 | IP: { 19 | type: 'string', 20 | format: 'ipv4', 21 | description: 'IP of the server' 22 | }, 23 | Port: { 24 | type: 'integer', 25 | minimum: 0, 26 | maximum: 65535, 27 | description: 'The port of the server instance to connect to for joining' 28 | }, 29 | Name: { 30 | type: 'string', 31 | description: 'Name of the server, i.e.: Bob\'s Server.' 32 | }, 33 | NamePrefix: { 34 | type: 'string', 35 | description: 'A prefix of the server' 36 | }, 37 | 'Current Map': { 38 | type: 'string', 39 | description: 'The current map\'s name the server is running is running' 40 | }, 41 | Players: { 42 | type: 'integer', 43 | description: 'The number of players connected to the server', 44 | minimum: 0 45 | }, 46 | Bots: { 47 | type: 'integer', 48 | minimum: 0, 49 | description: 'The number of bots' 50 | }, 51 | 'Game Version': { 52 | type: 'string', 53 | pattern: '^Open Beta (.*?)?$', 54 | description: 'Version of the build of the server' 55 | }, 56 | Variables: { 57 | type: 'object', 58 | properties: { 59 | 'Player Limit': { 60 | type: 'integer', 61 | minimum: 0, 62 | description: 'Maximum number of players allowed by this server' 63 | }, 64 | 'Time Limit': { 65 | type: 'integer', 66 | minimum: 0, 67 | description: 'time limit in minutes' 68 | }, 69 | 'Team Mode': { 70 | type: 'integer', 71 | description: 'Determines how teams are organized between matches.', 72 | enum: [ 73 | 0, // static, 74 | 1, // swap 75 | 2, // random swap 76 | 3, // shuffle 77 | 4, // traditional (assign as players connect) 78 | 5, // traditional + free swap 79 | 6 // ladder rank 80 | ] 81 | }, 82 | 'Game Type': { 83 | type: 'integer', 84 | description: 'Type of the game the server is running', 85 | enum: [ 86 | 0, // Rx_Game_MainMenu 87 | 1, // Rx_Game 88 | 2, // TS_Game 89 | 3 // SP_Game 90 | // < 3 x < 1000 = RenX Unused/Reserved 91 | // < 1000 < x < 2^31 - 1 = Unassigned / Mod space 92 | ] 93 | }, 94 | 'Vehicle Limit': { 95 | type: 'integer', 96 | minimum: 0, 97 | description: 'Maximum number of vehicles allowed by this server' 98 | }, 99 | 'Mine Limit': { 100 | type: 'integer', 101 | minimum: 0, 102 | description: 'Maximum number of mines allowed by this server' 103 | }, 104 | bPassworded: { 105 | type: 'boolean', 106 | description: 'Whether a password is required to enter the game' 107 | }, 108 | bSteamRequired: { 109 | type: 'boolean', 110 | description: 'Whether clients required to be logged into Steam to play on this server' 111 | }, 112 | bRanked: { 113 | type: 'boolean', 114 | description: 'Whether the serer is ranked/official' 115 | }, 116 | bAllowPrivateMessaging: { 117 | type: 'boolean', 118 | description: 'Whether the server allows non-admin clients to PM each other' 119 | }, 120 | bPrivateMessageTeamOnly: { 121 | type: 'boolean', 122 | description: 'whether private messaging is restricted to just teammates' 123 | }, 124 | bAutoBalanceTeams: { // alias of 'bSpawnCrates' 125 | type: 'boolean', 126 | description: 'Whether the server will spawn crates in this game for balancing' 127 | }, 128 | bSpawnCrates: { 129 | type: 'boolean', 130 | description: 'Whether the server will spawn crates in this game for balancing' 131 | }, 132 | CrateRespawnAfterPickup: { 133 | type: 'integer', 134 | minimum: 0, 135 | description: 'interval for crate respawn (after pickup)' 136 | } 137 | }, 138 | required: [ 139 | 'Player Limit', 140 | 'Time Limit', 141 | 'Team Mode', 142 | 'Game Type', 143 | 'Vehicle Limit', 144 | 'Mine Limit' 145 | ] 146 | } 147 | } 148 | } 149 | export const MasterServerResponseSchema = { 150 | type: 'array', 151 | items: { $ref: '#/$defs/server' }, 152 | $defs: { 153 | server: MasterServerServerInfoSchema 154 | } 155 | } 156 | 157 | /** 158 | * Implements the protocol for Renegade X, an UnrealEngine3 based game, using a custom master server 159 | */ 160 | export default class renegadex extends Core { 161 | constructor () { 162 | super() 163 | this.usedTcp = true 164 | } 165 | 166 | async run (state) { 167 | // query master list and find specific server 168 | const servers = await this.getMasterServerList() 169 | const serverInfo = servers.find((server) => { 170 | return server.IP === this.options.address && server.Port === this.options.port 171 | }) 172 | 173 | if (serverInfo == null) { 174 | throw new Error('Server not found in master server list') 175 | } 176 | 177 | // set state properties based on received server info 178 | this.populateProperties(state, serverInfo) 179 | } 180 | 181 | /** 182 | * Retrieves server list from master server 183 | * @throws {Error} Will throw error when no master list was received 184 | * @returns a list of servers as raw data 185 | */ 186 | async getMasterServerList () { 187 | const servers = await this.request({ 188 | url: 'https://serverlist-rx.totemarts.services/servers.jsp', 189 | responseType: 'json' 190 | }) 191 | 192 | if (servers == null) { 193 | throw new Error('Unable to retrieve master server list') 194 | } 195 | if (!Array.isArray(servers)) { 196 | throw new Error('Invalid data received from master server. Expecting list of data') 197 | } 198 | if (servers.length === 0) { 199 | throw new Error('No data received from master server.') 200 | } 201 | 202 | // TODO: Ajv response validation 203 | // const isDataValid = ajv.validate(MasterServerResponseSchema, servers) 204 | // if (!isDataValid) { 205 | // throw new Error(`Received master server data is unknown/invalid: ${ajv.errorsText(ajv.errors)}`) 206 | // } 207 | 208 | return servers 209 | } 210 | 211 | /** 212 | * Translates raw properties into known properties 213 | * @param {Object} state Parsed data 214 | */ 215 | populateProperties (state, serverInfo) { 216 | let emptyPrefix = '' 217 | if (serverInfo.NamePrefix) emptyPrefix = serverInfo.NamePrefix + ' ' 218 | const servername = `${emptyPrefix}${serverInfo.Name || ''}` 219 | const numplayers = serverInfo.Players || 0 220 | const variables = serverInfo.Variables || {} 221 | 222 | state.name = servername 223 | state.map = serverInfo['Current Map'] || '' 224 | state.password = !!variables.bPassworded 225 | 226 | state.numplayers = numplayers 227 | state.maxplayers = variables['Player Limit'] || 0 228 | 229 | state.raw = serverInfo 230 | state.version = serverInfo['Game Version'] || '' 231 | } 232 | } 233 | -------------------------------------------------------------------------------- /protocols/renegadexmaster.js: -------------------------------------------------------------------------------- 1 | import renegadex from './renegadex.js' 2 | 3 | /** 4 | * Implements the protocol for retrieving a master list for Renegade X, an UnrealEngine3 based game 5 | */ 6 | export default class renegadexmaster extends renegadex { 7 | async run (state) { 8 | const servers = await this.getMasterServerList() 9 | 10 | // pass processed servers as raw list 11 | state.raw.servers = servers.map((serverInfo) => { 12 | // TODO: may use any other deep-copy method like structuredClone() (in Node.js 17+) 13 | // or use a method of Core to retrieve a clean state 14 | const serverState = JSON.parse(JSON.stringify(state)) 15 | 16 | // set state properties based on received server info 17 | this.populateProperties(serverState, serverInfo) 18 | return serverState 19 | }) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /protocols/renown.js: -------------------------------------------------------------------------------- 1 | import Epic from './epic.js' 2 | 3 | export default class renown extends Epic { 4 | constructor () { 5 | super() 6 | 7 | // OAuth2 credentials provided by the game developer. 8 | this.clientId = 'xyza7891XjE03sj3B404z0iQfQ4efjUj' 9 | this.clientSecret = '2YY7STKJ5wJuFIyR8TaXaBPfhHZiAY13YuNPSIn+0WY' 10 | this.deploymentId = '472a8286c61c408ca5ca1ec0401f07b7' 11 | this.authByExternalToken = true 12 | } 13 | 14 | async run (state) { 15 | await super.run(state) 16 | state.name = state.raw.attributes.SERVERNAME_s 17 | state.password = state.raw.attributes.PASSWORD_b 18 | state.version = state.raw.attributes.SERVERVERSION_s 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /protocols/rfactor.js: -------------------------------------------------------------------------------- 1 | import Core from './core.js' 2 | 3 | export default class rfactor extends Core { 4 | async run (state) { 5 | const buffer = await this.udpSend('rF_S', b => b) 6 | const reader = this.reader(buffer) 7 | 8 | state.raw.gamename = this.readString(reader, 8) 9 | state.raw.fullUpdate = reader.uint(1) 10 | state.raw.region = reader.uint(2) 11 | state.raw.ip = reader.part(4) 12 | state.raw.size = reader.uint(2) 13 | state.version = reader.uint(2) 14 | state.raw.versionRaceCast = reader.uint(2) 15 | state.gamePort = reader.uint(2) 16 | state.raw.queryPort = reader.uint(2) 17 | state.raw.game = this.readString(reader, 20) 18 | state.name = this.readString(reader, 28) 19 | state.map = this.readString(reader, 32) 20 | state.raw.motd = this.readString(reader, 96) 21 | state.raw.packedAids = reader.uint(2) 22 | state.raw.ping = reader.uint(2) 23 | state.raw.packedFlags = reader.uint(1) 24 | state.raw.rate = reader.uint(1) 25 | state.numplayers = reader.uint(1) 26 | state.maxplayers = reader.uint(1) 27 | state.raw.bots = reader.uint(1) 28 | state.raw.packedSpecial = reader.uint(1) 29 | state.raw.damage = reader.uint(1) 30 | state.raw.packedRules = reader.uint(2) 31 | state.raw.credits1 = reader.uint(1) 32 | state.raw.credits2 = reader.uint(2) 33 | this.logger.debug(reader.offset()) 34 | state.raw.time = reader.uint(2) 35 | state.raw.laps = reader.uint(2) / 16 36 | reader.skip(3) 37 | state.raw.vehicles = reader.string() 38 | 39 | state.password = !!(state.raw.packedSpecial & 2) 40 | state.raw.raceCast = !!(state.raw.packedSpecial & 4) 41 | state.raw.fixedSetups = !!(state.raw.packedSpecial & 16) 42 | 43 | const aids = [ 44 | 'TractionControl', 45 | 'AntiLockBraking', 46 | 'StabilityControl', 47 | 'AutoShifting', 48 | 'AutoClutch', 49 | 'Invulnerability', 50 | 'OppositeLock', 51 | 'SteeringHelp', 52 | 'BrakingHelp', 53 | 'SpinRecovery', 54 | 'AutoPitstop' 55 | ] 56 | state.raw.aids = [] 57 | for (let offset = 0; offset < aids.length; offset++) { 58 | if (state.packedAids && (1 << offset)) { 59 | state.raw.aids.push(aids[offset]) 60 | } 61 | } 62 | } 63 | 64 | // Consumes bytesToConsume, but only returns string up to the first null 65 | readString (reader, bytesToConsume) { 66 | const consumed = reader.part(bytesToConsume) 67 | return this.reader(consumed).string() 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /protocols/samp.js: -------------------------------------------------------------------------------- 1 | import Core from './core.js' 2 | 3 | export default class samp extends Core { 4 | constructor () { 5 | super() 6 | this.encoding = 'win1252' 7 | this.magicHeader = 'SAMP' 8 | this.responseMagicHeader = null 9 | this.isVcmp = false 10 | this.isOmp = false 11 | } 12 | 13 | async run (state) { 14 | // read info 15 | { 16 | const reader = await this.sendPacket('i') 17 | if (this.isVcmp) { 18 | const consumed = reader.part(12) 19 | state.version = this.reader(consumed).string() 20 | } 21 | state.password = !!reader.uint(1) 22 | state.numplayers = reader.uint(2) 23 | state.maxplayers = reader.uint(2) 24 | state.name = reader.pascalString(4) 25 | state.raw.gamemode = reader.pascalString(4) 26 | state.raw.map = reader.pascalString(4) 27 | } 28 | 29 | // read rules 30 | if (!this.isVcmp) { 31 | const reader = await this.sendPacket('r') 32 | const ruleCount = reader.uint(2) 33 | state.raw.rules = {} 34 | for (let i = 0; i < ruleCount; i++) { 35 | const key = reader.pascalString(1) 36 | const value = reader.pascalString(1) 37 | state.raw.rules[key] = value 38 | if ('version' in state.raw.rules) state.version = state.raw.rules.version 39 | } 40 | } 41 | 42 | // read players 43 | // don't even bother if > 100 players, because the server won't respond 44 | if (state.numplayers < 100) { 45 | if (this.isVcmp || this.isOmp) { 46 | const reader = await this.sendPacket('c', true) 47 | if (reader !== null) { 48 | const playerCount = reader.uint(2) 49 | for (let i = 0; i < playerCount; i++) { 50 | const player = {} 51 | player.name = reader.pascalString(1) 52 | player.score = reader.int(4) 53 | state.players.push(player) 54 | } 55 | } 56 | } else { 57 | const reader = await this.sendPacket('d', true) 58 | if (reader !== null) { 59 | const playerCount = reader.uint(2) 60 | for (let i = 0; i < playerCount; i++) { 61 | const player = {} 62 | player.id = reader.uint(1) 63 | player.name = reader.pascalString(1) 64 | player.score = reader.int(4) 65 | player.ping = reader.uint(4) 66 | state.players.push(player) 67 | } 68 | } 69 | } 70 | } 71 | } 72 | 73 | async sendPacket (type, allowTimeout) { 74 | const outBuffer = Buffer.alloc(11) 75 | outBuffer.write(this.magicHeader, 0, 4) 76 | const ipSplit = this.options.address.split('.') 77 | outBuffer.writeUInt8(parseInt(ipSplit[0]), 4) 78 | outBuffer.writeUInt8(parseInt(ipSplit[1]), 5) 79 | outBuffer.writeUInt8(parseInt(ipSplit[2]), 6) 80 | outBuffer.writeUInt8(parseInt(ipSplit[3]), 7) 81 | outBuffer.writeUInt16LE(this.options.port, 8) 82 | outBuffer.writeUInt8(type.charCodeAt(0), 10) 83 | 84 | const checkBuffer = Buffer.from(outBuffer) 85 | if (this.responseMagicHeader) { 86 | checkBuffer.write(this.responseMagicHeader, 0, 4) 87 | } 88 | 89 | return await this.udpSend( 90 | outBuffer, 91 | (buffer) => { 92 | const reader = this.reader(buffer) 93 | for (let i = 0; i < checkBuffer.length; i++) { 94 | if (checkBuffer.readUInt8(i) !== reader.uint(1)) return 95 | } 96 | return reader 97 | }, 98 | () => { 99 | if (allowTimeout) { 100 | return null 101 | } 102 | } 103 | ) 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /protocols/satisfactory.js: -------------------------------------------------------------------------------- 1 | import Core from './core.js' 2 | 3 | export default class satisfactory extends Core { 4 | constructor () { 5 | super() 6 | 7 | // Don't use the tcp ping probing 8 | this.usedTcp = true 9 | } 10 | 11 | async run (state) { 12 | const packet = Buffer.from([0xD5, 0xF6, 0, 1, 5, 5, 5, 5, 5, 5, 5, 5, 1]) 13 | const response = await this.udpSend(packet, packet => { 14 | const reader = this.reader(packet) 15 | const header = reader.part(4) 16 | if (header.equals(Buffer.from([0xD5, 0xF6, 1, 2]))) return 17 | reader.skip(8) // skip the cookie 18 | return reader 19 | }) 20 | 21 | state.raw.serverState = response.int(1) 22 | state.version = response.int(4).toString() 23 | state.raw.serverFlags = response.int(8) 24 | 25 | const subStatesCount = response.int(1) 26 | response.skip(subStatesCount * 3) 27 | 28 | const nameLength = response.int(2) 29 | state.name = response.part(nameLength).toString('utf-8') 30 | 31 | try { 32 | await this.doHttpApiQueries(state) 33 | } catch (e) { 34 | this.logger.debug('HTTP API query failed.') 35 | this.logger.debug(e) 36 | } 37 | } 38 | 39 | async doHttpApiQueries (state) { 40 | const headers = { 41 | 'Content-Type': 'application/json' 42 | } 43 | 44 | /** 45 | * Satisfactory servers unless specified use self-signed certificates for the HTTPS API. 46 | * Because of this we default the `rejectUnauthorized` flag to `false` unless set. 47 | * For more information see GAMES_LIST.md 48 | */ 49 | if (!this.options.rejectUnauthorized) this.options.rejectUnauthorized = false 50 | 51 | let token = this.options.token 52 | if (!token) { 53 | const tokenRequestJson = { 54 | function: 'PasswordlessLogin', 55 | data: { 56 | MinimumPrivilegeLevel: 'Client' 57 | } 58 | } 59 | 60 | const response = await this.queryInfo(tokenRequestJson, headers) 61 | token = response.authenticationToken 62 | } 63 | 64 | const queryJson = { 65 | function: 'QueryServerState' 66 | } 67 | 68 | const queryResponse = await this.queryInfo(queryJson, { 69 | ...headers, 70 | Authorization: `Bearer ${token}` 71 | }) 72 | 73 | /** 74 | * Satisfactory API cannot pull Server Name at the moment, see QA and vote for fix here 75 | * https://questions.satisfactorygame.com/post/66ebebad772a987f4a8b9ef8 76 | */ 77 | 78 | state.numplayers = queryResponse.serverGameState.numConnectedPlayers 79 | state.maxplayers = queryResponse.serverGameState.playerLimit 80 | state.raw.http = queryResponse 81 | } 82 | 83 | async queryInfo (json, headers) { 84 | const url = `https://${this.options.host}:${this.options.port}/api/v1/` 85 | 86 | this.logger.debug(`POST: ${url}`) 87 | 88 | const response = await this.request({ 89 | url, 90 | json, 91 | headers, 92 | method: 'POST', 93 | responseType: 'json', 94 | https: { 95 | minVersion: 'TLSv1.2', 96 | rejectUnauthorized: this.options.rejectUnauthorized 97 | } 98 | }) 99 | 100 | if (response.data == null) { 101 | throw new Error('Unable to retrieve data from server') 102 | } else { 103 | return response.data 104 | } 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /protocols/savage2.js: -------------------------------------------------------------------------------- 1 | import Core from './core.js' 2 | 3 | export default class savage2 extends Core { 4 | async run (state) { 5 | const buffer = await this.udpSend('\x01', b => b) 6 | const reader = this.reader(buffer) 7 | 8 | reader.skip(12) 9 | state.name = this.stripColorCodes(reader.string()) 10 | state.numplayers = reader.uint(1) 11 | state.maxplayers = reader.uint(1) 12 | state.raw.time = reader.string() 13 | state.map = reader.string() 14 | state.raw.nextmap = reader.string() 15 | state.raw.location = reader.string() 16 | state.raw.minplayers = reader.uint(1) 17 | state.raw.gametype = reader.string() 18 | state.version = reader.string() 19 | state.raw.minlevel = reader.uint(1) 20 | } 21 | 22 | stripColorCodes (str) { 23 | return this.options.stripColors ? str.replace(/\^./g, '') : str 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /protocols/sdtd.js: -------------------------------------------------------------------------------- 1 | import Valve from './valve.js' 2 | import { Players } from '../lib/Results.js' 3 | 4 | const playerRegex = /(?<=id=\d+,\s*)(?\S[^,]*)(?=,)/ 5 | const gameVersionsRegex = /V \d+\.\d+(?: \(b\d+\))?/g 6 | const modRegex = /^Mod\s+([^:]+):\s*([\d.]+)$/ 7 | const dateTimeRegex = /Day\s+(\d+),\s*(\d{2}:\d{2})/ 8 | 9 | const sanitizeTelnetResponse = response => { 10 | return response 11 | .split(/\r?\n/) 12 | .map(l => l.replace(/\r$/, '').trim()) 13 | .filter(l => l.length > 0) 14 | } 15 | 16 | export default class sdtd extends Valve { 17 | async run (state) { 18 | await super.run(state) 19 | await this.telnetCalls(state) 20 | } 21 | 22 | async telnetCalls (state) { 23 | const telnetPort = this.options.telnetPort 24 | const telnetPassword = this.options.telnetPassword 25 | 26 | if (!telnetPort || !telnetPassword) { 27 | this.logger.debug('No telnet args given, skipping.') 28 | return 29 | } 30 | 31 | if (!this.options.requestPlayers && !this.options.moreData) { 32 | return 33 | } 34 | 35 | await this.telnetConnect({ 36 | port: telnetPort, 37 | password: telnetPassword, 38 | passwordPrompt: /Please enter password:/i, 39 | shellPrompt: /\r\n$/, 40 | echoLines: 0 41 | }) 42 | 43 | if (this.options.requestPlayers) { 44 | await this.telnetCallPlayers(state) 45 | } 46 | 47 | if (this.options.moreData) { 48 | await this.telnetMoreData(state) 49 | } 50 | 51 | await this.telnetClose() 52 | } 53 | 54 | async telnetCallPlayers (state) { 55 | const playersResponse = await this.telnetExecute('listplayers') 56 | state.players = new Players() 57 | for (const possiblePlayerLine of sanitizeTelnetResponse(playersResponse)) { 58 | const match = possiblePlayerLine.match(playerRegex) 59 | 60 | const name = match?.groups?.name 61 | if (name) { 62 | state.players.push({ 63 | name, 64 | responseLine: possiblePlayerLine 65 | }) 66 | } 67 | } 68 | 69 | state.raw.telnetPlayersResponse = playersResponse 70 | } 71 | 72 | async telnetMoreData (state) { 73 | const gettimeResponse = await this.telnetExecute('gettime') 74 | const dateTime = sanitizeTelnetResponse(gettimeResponse)[0] || '' 75 | const match = dateTime.match(dateTimeRegex) 76 | if (match) { 77 | state.raw.day = Number(match[1]) 78 | state.raw.time = match[2] 79 | state.raw.hordeDay = state.raw.day % 7 === 0 80 | } else { 81 | state.raw.hordeDay = false 82 | } 83 | 84 | state.raw.telnetGettimeResponse = gettimeResponse 85 | 86 | const versionResponse = await this.telnetExecute('version') 87 | const versions = sanitizeTelnetResponse(versionResponse) 88 | const gameVersions = versions[0] || '' 89 | const gameVersionsMatch = gameVersions.match(gameVersionsRegex) 90 | if (gameVersionsMatch) { 91 | state.raw.gameVersion = gameVersionsMatch[0] 92 | state.raw.compatibilityVersion = gameVersionsMatch[1] 93 | } 94 | 95 | const mods = [] 96 | for (const possibleMod of versions.slice(1)) { 97 | const match = possibleMod.match(modRegex) 98 | if (match) { 99 | mods.push({ 100 | name: match[1], 101 | version: match[2] 102 | }) 103 | } 104 | } 105 | 106 | state.raw.mods = mods 107 | state.raw.telnetVersionResponse = versionResponse 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /protocols/soldat.js: -------------------------------------------------------------------------------- 1 | import Core from './core.js' 2 | 3 | const extractValue = (text, regex, defaultValue, parser = (val) => val) => { 4 | const match = text.match(regex) 5 | return match ? parser(match[1] || defaultValue) : defaultValue 6 | } 7 | 8 | export default class soldat extends Core { 9 | async run (state) { 10 | const data = await this.withTcp(async socket => { 11 | return await this.tcpSend(socket, 'STARTFILES\r\nlogs/gamestat.txt\r\nENDFILES\r\n', (data) => { 12 | const asString = data.toString() 13 | if (asString.endsWith('\r\n') && !asString.endsWith('ENDFILES\r\n')) { 14 | return undefined 15 | } 16 | return data 17 | }) 18 | }) 19 | 20 | const string = data.toString() 21 | 22 | state.numplayers = extractValue(string, /Players:\s*(\d+)/, 0, Number) 23 | state.map = extractValue(string, /Map:\s*(.+)/, '') 24 | 25 | const lines = string.trim().split('\n') 26 | const playersIndex = lines.findIndex(line => line.startsWith('Players list')) 27 | 28 | if (playersIndex > -1) { 29 | for (let i = playersIndex + 1; i < lines.length - 1; i += 5) { 30 | state.players.push({ 31 | name: lines[i].trim(), 32 | raw: { 33 | kills: parseInt(lines[i + 1].trim()), 34 | deaths: parseInt(lines[i + 2].trim()), 35 | team: parseInt(lines[i + 3].trim()), 36 | ping: parseInt(lines[i + 4].trim()) 37 | } 38 | }) 39 | } 40 | } 41 | 42 | state.raw.response = string 43 | state.raw.gamemode = extractValue(string, /Gamemode:\s*(.+)/, '') 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /protocols/starmade.js: -------------------------------------------------------------------------------- 1 | import Core from './core.js' 2 | 3 | export default class starmade extends Core { 4 | constructor () { 5 | super() 6 | this.encoding = 'latin1' 7 | this.byteorder = 'be' 8 | } 9 | 10 | async run (state) { 11 | const b = Buffer.from([0x00, 0x00, 0x00, 0x09, 0x2a, 0xff, 0xff, 0x01, 0x6f, 0x00, 0x00, 0x00, 0x00]) 12 | 13 | const payload = await this.withTcp(async socket => { 14 | return await this.tcpSend(socket, b, buffer => { 15 | if (buffer.length < 12) return 16 | const reader = this.reader(buffer) 17 | const packetLength = reader.uint(4) 18 | this.logger.debug('Received packet length: ' + packetLength) 19 | const timestamp = reader.uint(8).toString() 20 | this.logger.debug('Received timestamp: ' + timestamp) 21 | if (reader.remaining() < packetLength || reader.remaining() < 5) return 22 | 23 | const checkId = reader.uint(1) 24 | const packetId = reader.uint(2) 25 | const commandId = reader.uint(1) 26 | const type = reader.uint(1) 27 | 28 | this.logger.debug('checkId=' + checkId + ' packetId=' + packetId + ' commandId=' + commandId + ' type=' + type) 29 | if (checkId !== 0x2a) return 30 | 31 | return reader.rest() 32 | }) 33 | }) 34 | 35 | const reader = this.reader(payload) 36 | 37 | const data = [] 38 | state.raw.data = data 39 | 40 | while (!reader.done()) { 41 | const mark = reader.uint(1) 42 | if (mark === 1) { 43 | // signed int 44 | data.push(reader.int(4)) 45 | } else if (mark === 3) { 46 | // float 47 | data.push(reader.float()) 48 | } else if (mark === 4) { 49 | // string 50 | data.push(reader.pascalString(2)) 51 | } else if (mark === 6) { 52 | // byte 53 | data.push(reader.uint(1)) 54 | } 55 | } 56 | 57 | this.logger.debug('Received raw data array', data) 58 | 59 | if (typeof data[0] === 'number') state.raw.infoVersion = data[0] 60 | if (typeof data[1] === 'string') state.version = data[1] 61 | if (typeof data[2] === 'string') state.name = data[2] 62 | if (typeof data[3] === 'string') state.raw.description = data[3] 63 | if (typeof data[4] === 'number') state.raw.startTime = data[4] 64 | if (typeof data[5] === 'number') state.numplayers = data[5] 65 | if (typeof data[6] === 'number') state.maxplayers = data[6] 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /protocols/starsiege.js: -------------------------------------------------------------------------------- 1 | import tribes1 from './tribes1.js' 2 | 3 | export default class starsiege extends tribes1 { 4 | constructor () { 5 | super() 6 | this.encoding = 'latin1' 7 | this.requestByte = 0x72 8 | this.responseByte = 0x73 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /protocols/teamspeak2.js: -------------------------------------------------------------------------------- 1 | import Core from './core.js' 2 | 3 | export default class teamspeak2 extends Core { 4 | async run (state) { 5 | const queryPort = this.options.teamspeakQueryPort || 51234 6 | 7 | await this.withTcp(async socket => { 8 | { 9 | const data = await this.sendCommand(socket, 'sel ' + this.options.port) 10 | if (data !== '[TS]') throw new Error('Invalid header') 11 | } 12 | 13 | { 14 | const data = await this.sendCommand(socket, 'si') 15 | for (const line of data.split('\r\n')) { 16 | const equals = line.indexOf('=') 17 | const key = equals === -1 ? line : line.substring(0, equals) 18 | const value = equals === -1 ? '' : line.substring(equals + 1) 19 | state.raw[key] = value 20 | } 21 | if ('server_name' in state.raw) state.name = state.raw.server_name 22 | } 23 | 24 | { 25 | const data = await this.sendCommand(socket, 'pl') 26 | const split = data.split('\r\n') 27 | const fields = split.shift().split('\t') 28 | for (const line of split) { 29 | const split2 = line.split('\t') 30 | const player = {} 31 | split2.forEach((value, i) => { 32 | let key = fields[i] 33 | if (!key) return 34 | if (key === 'nick') key = 'name' 35 | const m = value.match(/^"(.*)"$/) 36 | if (m) value = m[1] 37 | player[key] = value 38 | }) 39 | state.players.push(player) 40 | } 41 | state.numplayers = state.players.length 42 | } 43 | 44 | { 45 | const data = await this.sendCommand(socket, 'cl') 46 | const split = data.split('\r\n') 47 | const fields = split.shift().split('\t') 48 | state.raw.channels = [] 49 | for (const line of split) { 50 | const split2 = line.split('\t') 51 | const channel = {} 52 | split2.forEach((value, i) => { 53 | const key = fields[i] 54 | if (!key) return 55 | const m = value.match(/^"(.*)"$/) 56 | if (m) value = m[1] 57 | channel[key] = value 58 | }) 59 | state.raw.channels.push(channel) 60 | } 61 | } 62 | }, queryPort) 63 | } 64 | 65 | async sendCommand (socket, cmd) { 66 | return await this.tcpSend(socket, cmd + '\x0A', buffer => { 67 | if (buffer.length < 6) return 68 | if (buffer.slice(-6).toString() !== '\r\nOK\r\n') return 69 | return buffer.slice(0, -6).toString() 70 | }) 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /protocols/teamspeak3.js: -------------------------------------------------------------------------------- 1 | import Core from './core.js' 2 | 3 | export default class teamspeak3 extends Core { 4 | async run (state) { 5 | const queryPort = this.options.teamspeakQueryPort || 10011 6 | 7 | await this.withTcp(async socket => { 8 | { 9 | const data = await this.sendCommand(socket, 'use port=' + this.options.port, true) 10 | const split = data.split('\n\r') 11 | if (split[0] !== 'TS3') throw new Error('Invalid header') 12 | } 13 | 14 | { 15 | const data = await this.sendCommand(socket, 'serverinfo') 16 | state.raw = data[0] 17 | if ('virtualserver_name' in state.raw) state.name = state.raw.virtualserver_name 18 | if ('virtualserver_maxclients' in state.raw) state.maxplayers = state.raw.virtualserver_maxclients 19 | if ('virtualserver_clientsonline' in state.raw) state.numplayers = state.raw.virtualserver_clientsonline 20 | if ('virtualserver_version' in state.raw) state.version = state.raw.virtualserver_version 21 | } 22 | 23 | { 24 | const list = await this.sendCommand(socket, 'clientlist') 25 | for (const client of list) { 26 | client.name = client.client_nickname 27 | delete client.client_nickname 28 | if (client.client_type === '0') { 29 | state.players.push(client) 30 | } 31 | } 32 | } 33 | 34 | { 35 | const data = await this.sendCommand(socket, 'channellist -topic') 36 | state.raw.channels = data 37 | } 38 | }, queryPort) 39 | } 40 | 41 | async sendCommand (socket, cmd, raw) { 42 | const body = await this.tcpSend(socket, cmd + '\x0A', (buffer) => { 43 | if (buffer.length < 21) return 44 | if (buffer.slice(-21).toString() !== '\n\rerror id=0 msg=ok\n\r') return 45 | return buffer.slice(0, -21).toString() 46 | }) 47 | 48 | if (raw) { 49 | return body 50 | } else { 51 | const segments = body.split('|') 52 | const out = [] 53 | for (const line of segments) { 54 | const split = line.split(' ') 55 | const unit = {} 56 | for (const field of split) { 57 | const equals = field.indexOf('=') 58 | const key = equals === -1 ? field : field.substring(0, equals) 59 | const value = equals === -1 60 | ? '' 61 | : field.substring(equals + 1) 62 | .replace(/\\s/g, ' ').replace(/\\\//g, '/') 63 | unit[key] = value 64 | } 65 | out.push(unit) 66 | } 67 | return out 68 | } 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /protocols/terraria.js: -------------------------------------------------------------------------------- 1 | import Core from './core.js' 2 | 3 | export default class terraria extends Core { 4 | async run (state) { 5 | const json = await this.request({ 6 | url: 'http://' + this.options.address + ':' + this.options.port + '/v2/server/status', 7 | searchParams: { 8 | players: 'true', 9 | token: this.options.token 10 | }, 11 | responseType: 'json' 12 | }) 13 | 14 | if (json.status !== '200') throw new Error('Invalid status') 15 | 16 | state.raw = json 17 | 18 | for (const one of json.players) { 19 | state.players.push({ name: one.nickname, team: one.team }) 20 | } 21 | 22 | state.name = json.name 23 | state.gamePort = json.port 24 | state.numplayers = json.playercount 25 | state.maxplayers = json.maxplayers 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /protocols/theisleevrima.js: -------------------------------------------------------------------------------- 1 | import Epic from './epic.js' 2 | 3 | export default class theisleevrima extends Epic { 4 | constructor () { 5 | super() 6 | 7 | // OAuth2 credentials extracted from The Isle Evrima files. 8 | this.clientId = 'xyza7891gk5PRo3J7G9puCJGFJjmEguW' 9 | this.clientSecret = 'pKWl6t5i9NJK8gTpVlAxzENZ65P8hYzodV8Dqe5Rlc8' 10 | this.deploymentId = '6db6bea492f94b1bbdfcdfe3e4f898dc' 11 | } 12 | 13 | async run (state) { 14 | await super.run(state) 15 | state.name = state.raw.attributes.SERVERNAME_s 16 | state.map = state.raw.attributes.MAP_NAME_s 17 | state.version = state.raw.attributes.SERVER_VERSION_s 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /protocols/toxikk.js: -------------------------------------------------------------------------------- 1 | import valve from './valve.js' 2 | // import { TranslateMapUT3 } from './ut3.js' 3 | 4 | export const TranslateMapToxikk = Object.freeze({ 5 | // add UT3 values 6 | // Toxikk is using UDK which includes basic implementation of UT3 7 | // ...TranslateMapUT3, 8 | 9 | // Old UT3/UDK properties 10 | p1073741825: 'map', // UC Source='CRZMapName' 11 | p1073741826: 'game', // UC Source='CustomGameMode' 12 | p268435704: 'frag_limit', // UC Source='TimeLimit' 13 | p268435705: 'time_limit', // UC Source='TimeLimit' 14 | p268435703: 'numbots', 15 | p1073741827: 'servername', // UC Source='ServerDescription' 16 | p268435717: false, // 'stock_mutators' // UC Source='OfficialMutators' // Note: "EpicMutators" are bit-masked and require Full UE3 license (C++) to flag mutators, stock mutators could be always 0, ignore for now 17 | p1073741828: 'custom_mutators', // UC Source='CustomMutators' 18 | 19 | // Toxikk specific localized settings (commented name is based on source code) 20 | s32779: 'GameMode', // 8=Custom, anything else is UT3/UDK 21 | s0: 'bot_skill', // UC Source='BotSkill', // 0=Ridiculous 1=Novice 2=Average 3=Experienced 4=Adept 5=Masterful 6=Inhuman 7=Godlike 22 | s1: false, // UC Source='Map' // Note: set as '0' mostly, generally the index will state the official map, ignore for now 23 | s6: 'pure_server', // UC Source='PureServer', // bool 24 | s7: 'password', // UC Source='LockedServer', // bool 25 | s8: 'vs_bots', // UC Source='VsBots', // 0=Disabled 1="2:1" 2="1:1" 3="2:3" 4="1:2" 5="1:3" 6="1:4" 26 | s9: 'Campaign', // bool 27 | s10: 'force_respawn', // UC Source='ForceRespawn', // bool 28 | s11: 'AllowKeyboard', // bool 29 | s12: 'IsFullServer', // bool 30 | s13: 'IsEmptyServer', // bool 31 | s14: 'IsDedicated', // bool 32 | s15: 'RankedServer', // 0=UnRanked 1=Ranked 33 | s16: 'OnlyFullGamePlayers', // bool 34 | s17: 'IgnoredByMatchmaking', // bool 35 | s18: 'OfficialServer', // 0=Community 1=Official 36 | s19: 'ModdingLevel', // 0=Unmodded 1=Server Modded 2=Client Modded 37 | 38 | // Toxikk properties 39 | p268435706: 'MaxPlayers', 40 | p268435707: 'MinNetPlayers', 41 | p268435708: 'MinSkillClass', 42 | p268435709: 'MaxSkillClass', 43 | p1073741829: 'PLAYERIDS1', 44 | p1073741830: 'PLAYERIDS2', 45 | p1073741831: 'PLAYERIDS3', 46 | p1073741832: 'PLAYERNAMES1', 47 | p1073741833: 'PLAYERNAMES2', 48 | p1073741834: 'PLAYERNAMES3', 49 | p1073741837: 'PLAYERSCS', 50 | p1073741838: 'PLAYERBadgeRanks', 51 | p1073741839: 'GameVersion', 52 | p1073741840: 'GameVoteList' 53 | }) 54 | 55 | /** 56 | * Implements the protocol for Toxikk, an UnrealEngine3 based game, 57 | * using Valve protocol for query with additional UE3 properties/settings parsing 58 | */ 59 | export default class toxikk extends valve { 60 | async run (state) { 61 | if (!this.options.port) this.options.port = 27015 62 | await this.queryInfo(state) 63 | await this.queryChallenge() 64 | await this.queryPlayers(state) 65 | await this.queryRules(state) 66 | 67 | this.processQueryInfo(state) 68 | await this.cleanup(state) 69 | } 70 | 71 | /** @override */ 72 | async cleanup (state) { 73 | // valve protocol attempts to put "hidden players" into player/bot array 74 | // the bot data is not properly queried, therefore prevent push players into bots-array 75 | const originalNumBots = state.raw.numbots 76 | state.raw.numbots = null 77 | super.cleanup(state) 78 | state.raw.numbots = originalNumBots 79 | } 80 | 81 | async queryRules (state) { 82 | if (!this.options.requestRules) { 83 | return 84 | } 85 | 86 | const rules = {} 87 | this.logger.debug('Requesting rules ...') 88 | 89 | const b = await this.sendPacket(0x56, null, 0x45, true) 90 | if (b === null && !this.options.requestRulesRequired) return // timed out - the server probably has rules disabled 91 | 92 | const reader = this.reader(b) 93 | const num = reader.uint(2) 94 | for (let i = num - 1; i > 0 && !reader.done(); i--) { 95 | const key = reader.string() 96 | const value = reader.string() 97 | if (reader.remaining() <= 0) { 98 | // data might be corrupt in this case, keep existing rules 99 | break 100 | } 101 | 102 | rules[key] = value 103 | } 104 | 105 | state.raw.rules = rules 106 | } 107 | 108 | processQueryInfo (state) { 109 | // move raw rules into root-raw object and attempt to translate properties 110 | Object.assign(state.raw, state.raw.rules) 111 | this.translate(state.raw, TranslateMapToxikk) 112 | 113 | const split = (a) => { 114 | let s = a.split('\x1c') 115 | s = s.filter((e) => { return e }) 116 | return s 117 | } 118 | if ('custom_mutators' in state.raw) state.raw.custom_mutators = split(state.raw.custom_mutators) 119 | if ('stock_mutators' in state.raw) state.raw.stock_mutators = split(state.raw.stock_mutators) 120 | if ('map' in state.raw) state.map ??= state.raw.map 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /protocols/tribes1.js: -------------------------------------------------------------------------------- 1 | import Core from './core.js' 2 | 3 | export default class tribes1 extends Core { 4 | constructor () { 5 | super() 6 | this.encoding = 'latin1' 7 | this.requestByte = 0x62 8 | this.responseByte = 0x63 9 | this.challenge = 0x01 10 | } 11 | 12 | async run (state) { 13 | const query = Buffer.alloc(3) 14 | query.writeUInt8(this.requestByte, 0) 15 | query.writeUInt16LE(this.challenge, 1) 16 | const reader = await this.udpSend(query, (buffer) => { 17 | const reader = this.reader(buffer) 18 | const responseByte = reader.uint(1) 19 | if (responseByte !== this.responseByte) { 20 | this.logger.debug('Unexpected response byte') 21 | return 22 | } 23 | const challenge = reader.uint(2) 24 | if (challenge !== this.challenge) { 25 | this.logger.debug('Unexpected challenge') 26 | return 27 | } 28 | const requestByte = reader.uint(1) 29 | if (requestByte !== this.requestByte) { 30 | this.logger.debug('Unexpected request byte') 31 | return 32 | } 33 | return reader 34 | }) 35 | 36 | state.raw.gametype = this.readString(reader) 37 | const isStarsiege2009 = state.raw.gametype === 'Starsiege' 38 | state.version = this.readString(reader) 39 | state.name = this.readString(reader) 40 | 41 | if (isStarsiege2009) { 42 | state.password = !!reader.uint(1) 43 | state.raw.dedicated = !!reader.uint(1) 44 | state.raw.dropInProgress = !!reader.uint(1) 45 | state.raw.gameInProgress = !!reader.uint(1) 46 | state.numplayers = reader.uint(4) 47 | state.maxplayers = reader.uint(4) 48 | state.raw.teamPlay = reader.uint(1) 49 | state.map = this.readString(reader) 50 | state.raw.cpuSpeed = reader.uint(2) 51 | state.raw.factoryVeh = reader.uint(1) 52 | state.raw.allowTecmix = reader.uint(1) 53 | state.raw.spawnLimit = reader.uint(4) 54 | state.raw.fragLimit = reader.uint(4) 55 | state.raw.timeLimit = reader.uint(4) 56 | state.raw.techLimit = reader.uint(4) 57 | state.raw.combatLimit = reader.uint(4) 58 | state.raw.massLimit = reader.uint(4) 59 | state.raw.playersSent = reader.uint(4) 60 | const teams = { 1: 'yellow', 2: 'blue', 4: 'red', 8: 'purple' } 61 | while (!reader.done()) { 62 | const player = {} 63 | player.name = this.readString(reader) 64 | const teamId = reader.uint(1) 65 | const team = teams[teamId] 66 | if (team) player.team = teams[teamId] 67 | } 68 | return 69 | } 70 | 71 | state.raw.dedicated = !!reader.uint(1) 72 | state.password = !!reader.uint(1) 73 | state.raw.playerCount = reader.uint(1) 74 | state.maxplayers = reader.uint(1) 75 | state.raw.cpuSpeed = reader.uint(2) 76 | state.raw.mod = this.readString(reader) 77 | state.raw.type = this.readString(reader) 78 | state.map = this.readString(reader) 79 | state.raw.motd = this.readString(reader) 80 | state.raw.teamCount = reader.uint(1) 81 | 82 | const teamFields = this.readFieldList(reader) 83 | const playerFields = this.readFieldList(reader) 84 | 85 | state.raw.teams = [] 86 | for (let i = 0; i < state.raw.teamCount; i++) { 87 | const teamName = this.readString(reader) 88 | const teamValues = this.readValues(reader) 89 | 90 | const teamInfo = {} 91 | for (let i = 0; i < teamValues.length && i < teamFields.length; i++) { 92 | let key = teamFields[i] 93 | let value = teamValues[i] 94 | if (key === 'ultra_base') key = 'name' 95 | if (value === '%t') value = teamName 96 | if (['score', 'players'].includes(key)) value = parseInt(value) 97 | teamInfo[key] = value 98 | } 99 | state.raw.teams.push(teamInfo) 100 | } 101 | 102 | for (let i = 0; i < state.raw.playerCount; i++) { 103 | const ping = reader.uint(1) * 4 104 | const packetLoss = reader.uint(1) 105 | const teamNum = reader.uint(1) 106 | const name = this.readString(reader) 107 | const playerValues = this.readValues(reader) 108 | 109 | const playerInfo = {} 110 | for (let i = 0; i < playerValues.length && i < playerFields.length; i++) { 111 | const key = playerFields[i] 112 | let value = playerValues[i] 113 | if (value === '%p') value = ping 114 | if (value === '%l') value = packetLoss 115 | if (value === '%t') value = teamNum 116 | if (value === '%n') value = name 117 | if (['score', 'ping', 'pl', 'kills', 'lvl'].includes(key)) value = parseInt(value) 118 | if (key === 'team') { 119 | const teamId = parseInt(value) 120 | if (teamId >= 0 && teamId < state.raw.teams.length && state.raw.teams[teamId].name) { 121 | value = state.raw.teams[teamId].name 122 | } else { 123 | continue 124 | } 125 | } 126 | playerInfo[key] = value 127 | } 128 | state.players.push(playerInfo) 129 | } 130 | } 131 | 132 | readFieldList (reader) { 133 | const str = this.readString(reader) 134 | if (!str) return [] 135 | return ('?' + str) 136 | .split('\t') 137 | .map((a) => a.substring(1).trim().toLowerCase()) 138 | .map((a) => a === 'team name' ? 'name' : a) 139 | .map((a) => a === 'player name' ? 'name' : a) 140 | } 141 | 142 | readValues (reader) { 143 | const str = this.readString(reader) 144 | if (!str) return [] 145 | return str 146 | .split('\t') 147 | .map((a) => a.trim()) 148 | } 149 | 150 | readString (reader) { 151 | return reader.pascalString(1) 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /protocols/tribes1master.js: -------------------------------------------------------------------------------- 1 | import Core from './core.js' 2 | 3 | /** Unsupported -- use at your own risk!! */ 4 | 5 | export default class tribes1master extends Core { 6 | constructor () { 7 | super() 8 | this.encoding = 'latin1' 9 | } 10 | 11 | async run (state) { 12 | const queryBuffer = Buffer.from([ 13 | 0x10, // standard header 14 | 0x03, // dump servers 15 | 0xff, // ask for all packets 16 | 0x00, // junk 17 | 0x01, 0x02 // challenge 18 | ]) 19 | 20 | const parts = new Map() 21 | let total = 0 22 | const full = await this.udpSend(queryBuffer, (buffer) => { 23 | const reader = this.reader(buffer) 24 | const header = reader.uint(2) 25 | if (header !== 0x0610) { 26 | this.logger.debug('Header response does not match: ' + header.toString(16)) 27 | return 28 | } 29 | const num = reader.uint(1) 30 | const t = reader.uint(1) 31 | if (t <= 0 || (total > 0 && t !== total)) { 32 | throw new Error('Conflicting packet total: ' + t) 33 | } 34 | total = t 35 | 36 | if (num < 1 || num > total) { 37 | this.logger.debug('Invalid packet number: ' + num + ' ' + total) 38 | return 39 | } 40 | if (parts.has(num)) { 41 | this.logger.debug('Duplicate part: ' + num) 42 | return 43 | } 44 | 45 | reader.skip(2) // challenge (0x0201) 46 | reader.skip(2) // always 0x6600 47 | parts.set(num, reader.rest()) 48 | 49 | if (parts.size === total) { 50 | const ordered = [] 51 | for (let i = 1; i <= total; i++) ordered.push(parts.get(i)) 52 | return Buffer.concat(ordered) 53 | } 54 | }) 55 | 56 | const fullReader = this.reader(full) 57 | state.raw.name = this.readString(fullReader) 58 | state.raw.motd = this.readString(fullReader) 59 | 60 | state.raw.servers = [] 61 | while (!fullReader.done()) { 62 | fullReader.skip(1) // junk ? 63 | const count = fullReader.uint(1) 64 | for (let i = 0; i < count; i++) { 65 | const six = fullReader.uint(1) 66 | if (six !== 6) { 67 | throw new Error('Expecting 6') 68 | } 69 | const ip = fullReader.uint(4) 70 | const port = fullReader.uint(2) 71 | const ipStr = (ip & 255) + '.' + (ip >> 8 & 255) + '.' + (ip >> 16 & 255) + '.' + (ip >>> 24) 72 | state.raw.servers.push(ipStr + ':' + port) 73 | } 74 | } 75 | } 76 | 77 | readString (reader) { 78 | return reader.pascalString(1) 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /protocols/unreal2.js: -------------------------------------------------------------------------------- 1 | import Core from './core.js' 2 | 3 | export default class unreal2 extends Core { 4 | constructor () { 5 | super() 6 | this.encoding = 'latin1' 7 | } 8 | 9 | async run (state) { 10 | let extraInfoReader 11 | { 12 | const b = await this.sendPacket(0, true) 13 | const reader = this.reader(b) 14 | state.raw.serverid = reader.uint(4) 15 | state.raw.ip = this.readUnrealString(reader) 16 | state.gamePort = reader.uint(4) 17 | state.raw.queryport = reader.uint(4) 18 | state.name = this.readUnrealString(reader, true) 19 | state.map = this.readUnrealString(reader, true) 20 | state.raw.gametype = this.readUnrealString(reader, true) 21 | state.numplayers = reader.uint(4) 22 | state.maxplayers = reader.uint(4) 23 | this.logger.debug(log => { 24 | log('UNREAL2 EXTRA INFO', reader.buffer.slice(reader.i)) 25 | }) 26 | extraInfoReader = reader 27 | } 28 | 29 | { 30 | const b = await this.sendPacket(1, true) 31 | const reader = this.reader(b) 32 | state.raw.mutators = [] 33 | state.raw.rules = {} 34 | while (!reader.done()) { 35 | const key = this.readUnrealString(reader, true) 36 | const value = this.readUnrealString(reader, true) 37 | this.logger.debug(key + '=' + value) 38 | if (key === 'Mutator' || key === 'mutator') { 39 | state.raw.mutators.push(value) 40 | } else if (key || value) { 41 | if (Object.prototype.hasOwnProperty.call(state.raw.rules, key)) { 42 | state.raw.rules[key] += ',' + value 43 | } else { 44 | state.raw.rules[key] = value 45 | } 46 | } 47 | } 48 | if ('GamePassword' in state.raw.rules) { state.password = state.raw.rules.GamePassword !== 'True' } 49 | if ('UTComp_Version' in state.raw.rules) { state.version = state.raw.rules.UTComp_Version } 50 | } 51 | 52 | if (state.raw.mutators.includes('KillingFloorMut') || 53 | state.raw.rules['Num trader weapons'] || 54 | state.raw.rules['Server Version'] === '1065' 55 | ) { 56 | // Killing Floor 57 | state.raw.wavecurrent = extraInfoReader.uint(4) 58 | state.raw.wavetotal = extraInfoReader.uint(4) 59 | state.raw.ping = extraInfoReader.uint(4) 60 | state.raw.flags = extraInfoReader.uint(4) 61 | state.raw.skillLevel = this.readUnrealString(extraInfoReader, true) 62 | } else { 63 | state.raw.ping = extraInfoReader.uint(4) 64 | // These fields were added in later revisions of unreal engine 65 | if (extraInfoReader.remaining() >= 8) { 66 | state.raw.flags = extraInfoReader.uint(4) 67 | state.raw.skill = this.readUnrealString(extraInfoReader, true) 68 | } 69 | } 70 | 71 | { 72 | const b = await this.sendPacket(2, false) 73 | const reader = this.reader(b) 74 | 75 | state.raw.scoreboard = {} 76 | while (!reader.done()) { 77 | const player = {} 78 | player.id = reader.uint(4) 79 | player.name = this.readUnrealString(reader, true) 80 | player.ping = reader.uint(4) 81 | player.score = reader.int(4) 82 | player.statsId = reader.uint(4) 83 | this.logger.debug(player) 84 | 85 | if (!player.id) { 86 | state.raw.scoreboard[player.name] = player.score 87 | } else if (!player.ping) { 88 | state.bots.push(player) 89 | } else { 90 | state.players.push(player) 91 | } 92 | } 93 | } 94 | } 95 | 96 | readUnrealString (reader, stripColor) { 97 | let length = reader.uint(1); let ucs2 = false 98 | if (length >= 0x80) { 99 | // This is flagged as a UCS-2 String 100 | length = (length & 0x7f) * 2 101 | ucs2 = true 102 | 103 | // For UCS-2 strings, some unreal 2 games randomly insert an extra 0x01 here, 104 | // not included in the length. Skip it if present (hopefully this never happens legitimately) 105 | const peek = reader.uint(1) 106 | if (peek !== 1) reader.skip(-1) 107 | 108 | this.logger.debug(log => { 109 | log('UCS2 STRING') 110 | log('UCS2 Length: ' + length) 111 | log(reader.buffer.slice(reader.i, reader.i + length)) 112 | }) 113 | } 114 | 115 | let out = '' 116 | if (ucs2) { 117 | out = reader.string({ encoding: 'ucs2', length }) 118 | this.logger.debug('UCS2 String decoded: ' + out) 119 | } else if (length > 0) { 120 | out = reader.string() 121 | } 122 | 123 | // Sometimes the string has a null at the end (included with the length) 124 | // Strip it if present 125 | if (out.charCodeAt(out.length - 1) === 0) { 126 | out = out.substring(0, out.length - 1) 127 | } 128 | 129 | if (stripColor && this.options.stripColors) { 130 | out = out.replace(/\x1b...|[\x00-\x1a]/gus, '') 131 | } 132 | 133 | return out 134 | } 135 | 136 | async sendPacket (type, required) { 137 | const outbuffer = Buffer.from([0x79, 0, 0, 0, type]) 138 | 139 | const packets = [] 140 | return await this.udpSend(outbuffer, (buffer) => { 141 | const reader = this.reader(buffer) 142 | reader.uint(4) // header 143 | const iType = reader.uint(1) 144 | if (iType !== type) return 145 | packets.push(reader.rest()) 146 | }, () => { 147 | if (!packets.length && required) return 148 | return Buffer.concat(packets) 149 | }) 150 | } 151 | } 152 | -------------------------------------------------------------------------------- /protocols/ut3.js: -------------------------------------------------------------------------------- 1 | import gamespy3 from './gamespy3.js' 2 | 3 | export default class ut3 extends gamespy3 { 4 | async run (state) { 5 | await super.run(state) 6 | 7 | this.translate(state.raw, { 8 | mapname: false, 9 | p1073741825: 'map', 10 | p1073741826: 'gametype', 11 | p1073741827: 'servername', 12 | p1073741828: 'custom_mutators', 13 | gamemode: 'joininprogress', 14 | s32779: 'gamemode', 15 | s0: 'bot_skill', 16 | s6: 'pure_server', 17 | s7: 'password', 18 | s8: 'vs_bots', 19 | s10: 'force_respawn', 20 | p268435704: 'frag_limit', 21 | p268435705: 'time_limit', 22 | p268435703: 'numbots', 23 | p268435717: 'stock_mutators', 24 | p1073741829: 'stock_mutators', 25 | s1: false, 26 | s9: false, 27 | s11: false, 28 | s12: false, 29 | s13: false, 30 | s14: false, 31 | p268435706: false, 32 | p268435968: false, 33 | p268435969: false 34 | }) 35 | 36 | const split = (a) => { 37 | let s = a.split('\x1c') 38 | s = s.filter((e) => { return e }) 39 | return s 40 | } 41 | if ('custom_mutators' in state.raw) state.raw.custom_mutators = split(state.raw.custom_mutators) 42 | if ('stock_mutators' in state.raw) state.raw.stock_mutators = split(state.raw.stock_mutators) 43 | if ('map' in state.raw) state.map = state.raw.map 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /protocols/vcmp.js: -------------------------------------------------------------------------------- 1 | import samp from './samp.js' 2 | 3 | export default class vcmp extends samp { 4 | constructor () { 5 | super() 6 | this.magicHeader = 'VCMP' 7 | this.responseMagicHeader = 'MP04' 8 | this.isVcmp = true 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /protocols/vintagestory.js: -------------------------------------------------------------------------------- 1 | import Core from './core.js' 2 | import vintagestorymaster from './vintagestorymaster.js' 3 | 4 | export default class vintagestory extends Core { 5 | async run (state) { 6 | const master = new vintagestorymaster() 7 | master.options = this.options 8 | const masterState = await master.runOnceSafe() 9 | const servers = masterState.raw.servers 10 | const server = servers.find(s => s.serverIP === `${this.options.address}:${this.options.port}`) 11 | 12 | if (!server) { 13 | throw new Error('Server not found in the master list') 14 | } 15 | 16 | state.name = server.serverName 17 | state.password = server.hasPassword 18 | state.numplayers = parseInt(server.players) 19 | state.maxplayers = parseInt(server.maxPlayers) 20 | state.version = server.gameVersion 21 | 22 | state.raw = server 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /protocols/vintagestorymaster.js: -------------------------------------------------------------------------------- 1 | import Core from './core.js' 2 | 3 | export default class vintagestorymaster extends Core { 4 | constructor () { 5 | super() 6 | this.usedTcp = true 7 | } 8 | 9 | async run (state) { 10 | const response = await this.request({ 11 | url: 'https://masterserver.vintagestory.at/api/v1/servers/list', 12 | responseType: 'json' 13 | }) 14 | 15 | state.raw.servers = response?.data || [] 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /protocols/warsow.js: -------------------------------------------------------------------------------- 1 | import quake3 from './quake3.js' 2 | 3 | export default class warsow extends quake3 { 4 | async run (state) { 5 | await super.run(state) 6 | if (state.players) { 7 | for (const player of state.players) { 8 | player.team = player.address 9 | delete player.address 10 | } 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /protocols/xonotic.js: -------------------------------------------------------------------------------- 1 | import quake3 from './quake3.js' 2 | 3 | export default class xonotic extends quake3 { 4 | async run (state) { 5 | await super.run(state) 6 | 7 | // Sometimes, the server returns a player's name as a number (which seems to be the team?) and the name in 8 | // an extra field called "address", we are not sure of this behaviour nor if this is a good enough solution 9 | for (const player of state.players) { 10 | if (!isNaN(player.name) && player.raw.address) { 11 | player.raw.team = player.name 12 | player.name = player.raw.address 13 | } 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /tools/README.md: -------------------------------------------------------------------------------- 1 | This folder is not really intended to be used by users. 2 | 3 | Contains helpers for certain stuff (such as generating the games list Markdown table). 4 | -------------------------------------------------------------------------------- /tools/attempt_protocols.js: -------------------------------------------------------------------------------- 1 | import Minimist from 'minimist' 2 | import { GameDig } from './../lib/index.js' 3 | import * as protocols from './../protocols/index.js' 4 | 5 | const argv = Minimist(process.argv.slice(2), {}) 6 | 7 | const options = {} 8 | if (argv._.length >= 1) { 9 | const target = argv._[0] 10 | const split = target.split(':') 11 | options.host = split[0] 12 | if (split.length >= 2) { 13 | options.port = split[1] 14 | } 15 | options.debug = argv._[1] === 'debug' 16 | } 17 | 18 | const gamedig = new GameDig(options) 19 | 20 | const protocolList = [] 21 | Object.keys(protocols).forEach((key) => protocolList.push(key)) 22 | 23 | const ignoredProtocols = ['discord', 'beammpmaster', 'beammp', 'teamspeak2', 'teamspeak3', 'vintagestorymaster', 'renegadexmaster', 'hawakeningmaster', 'brokeprotocolmaster'] 24 | const protocolListFiltered = protocolList.filter((protocol) => !ignoredProtocols.includes(protocol)) 25 | 26 | const run = async () => { 27 | for (const protocol of protocolListFiltered) { 28 | try { 29 | const response = await gamedig.query({ 30 | ...options, 31 | type: `protocol-${protocol}` 32 | }) 33 | console.log(`Success on '${protocol}':`, response) 34 | process.exit() 35 | } catch (e) { 36 | console.log(`Error on '${protocol}': ${e}`) 37 | } 38 | } 39 | } 40 | 41 | run().then(() => {}) 42 | -------------------------------------------------------------------------------- /tools/esbuild.js: -------------------------------------------------------------------------------- 1 | import { build } from 'esbuild' 2 | import { nodeExternalsPlugin } from 'esbuild-node-externals' 3 | 4 | const buildConfig = { 5 | entryPoints: ['lib/index.js'], 6 | platform: 'node', 7 | target: 'node16', 8 | bundle: true, 9 | outfile: 'dist/index.cjs', 10 | format: 'cjs', 11 | // got is a esm module, so we need to add it to the allowList, to include it in the bundle. 12 | plugins: [nodeExternalsPlugin({ allowList: ['got'] })] 13 | } 14 | 15 | build(buildConfig).then(() => { }).catch(() => { process.exit(1) }) 16 | -------------------------------------------------------------------------------- /tools/find-id-changes.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import { spawnSync } from 'node:child_process' 4 | import assert from 'node:assert' 5 | import { mkdirSync, copyFileSync } from 'node:fs' 6 | import path from 'node:path' 7 | import { fileURLToPath } from 'node:url' 8 | import process from 'node:process' 9 | 10 | // Generate a list of changes to "lib/games.js" where game IDs have been changed via the git history. 11 | // Requires git to be installed. 12 | // Make sure you don't have any local un-committed changes to lib/games.js 13 | 14 | // Usage: node tools/find-id-changes.js > id-changes.json 15 | // Output is an array of 16 | // { 17 | // "hash": "git commit hash", 18 | // "changes": [ ["oldid", "newid"], ... ], 19 | // "removed": [ "removedid", ... ], 20 | // "added": [ "addedid", ... ] 21 | // } 22 | 23 | // The output can be converted to a map of { "oldid": "newid" } using a jq command: 24 | // cat id-changes.json | jq ".[].changes | map({ (.[0]): .[1] } ) | add" | jq -s "add" 25 | 26 | const main = async (rootDir) => { 27 | // Make sure CWD is the root of the repo 28 | process.chdir(rootDir) 29 | 30 | // Get list of commits that have modified lib/games.js 31 | const gitLog = spawnSync( 32 | 'git', 33 | [ 34 | 'log', 35 | '--follow', 36 | '--format=%H', 37 | '--diff-filter=M', 38 | '--reverse', 39 | '--', 40 | 'lib/games.js' 41 | ], 42 | { encoding: 'utf-8' } 43 | ) 44 | 45 | // Make a directory to store files in 46 | mkdirSync('game_changes', { recursive: true }) 47 | 48 | const output = [] 49 | 50 | for (const commitHash of gitLog.stdout.split('\n')) { 51 | if (commitHash.length === 0) continue 52 | 53 | // Checkout lib/games.js before the commit that changed it 54 | assert( 55 | spawnSync('git', ['checkout', `${commitHash}^1`, '--', 'lib/games.js']) 56 | .status === 0 57 | ) 58 | 59 | // We have to copy each state of the file to its own file because node caches imports 60 | const beforeName = `game_changes/${commitHash}-before.js` 61 | copyFileSync('lib/games.js', beforeName) 62 | 63 | const before = await import(path.join('../', beforeName)) 64 | 65 | // Checkout lib/games.js after the commit that changed it 66 | assert( 67 | spawnSync('git', ['checkout', `${commitHash}`, '--', 'lib/games.js']) 68 | .status === 0 69 | ) 70 | 71 | const afterName = `game_changes/${commitHash}-after.js` 72 | copyFileSync('lib/games.js', afterName) 73 | 74 | const after = await import(path.join('../', afterName)) 75 | 76 | // Find game IDs that were removed and added 77 | let removed = Object.keys(before.games).filter( 78 | (key) => !(key in after.games) 79 | ) 80 | let added = Object.keys(after.games).filter( 81 | (key) => !(key in before.games) 82 | ) 83 | 84 | const changes = [] 85 | 86 | for (const rm of removed) { 87 | for (const add of added) { 88 | const beforeGame = before.games[rm] 89 | const afterGame = after.games[add] 90 | 91 | // Modify game names to ignore case, spaces, and punctuation 92 | const beforeName = beforeGame.name.toLowerCase().replace(/[^a-z]/g, '') 93 | const afterName = afterGame.name.toLowerCase().replace(/[^a-z]/g, '') 94 | 95 | if ( 96 | beforeGame.options.protocol === afterGame.options.protocol && 97 | (beforeName.includes(afterName) || afterName.includes(beforeName)) 98 | ) { 99 | changes.push([rm, add]) 100 | removed = removed.filter((r) => r !== rm) 101 | added = added.filter((a) => a !== add) 102 | break 103 | } 104 | } 105 | } 106 | 107 | output.push({ 108 | hash: commitHash, 109 | changes, 110 | removed, 111 | added 112 | }) 113 | } 114 | 115 | // Reset the contents of lib/games.js 116 | spawnSync('git', ['checkout', '--', 'lib/games.js']) 117 | 118 | return output 119 | } 120 | 121 | main( 122 | // Get the root of the repo: 123 | // dir of bin/find-id-changes.js -> /../ 124 | path.join(path.dirname(fileURLToPath(import.meta.url)), '..') 125 | ).then((o) => console.log(JSON.stringify(o)), console.error) 126 | -------------------------------------------------------------------------------- /tools/find_id_duplicates.js: -------------------------------------------------------------------------------- 1 | import { games } from '../lib/games.js' 2 | 3 | const ids = Object.keys(games) 4 | 5 | Object.keys(games).forEach((key) => { 6 | if (games[key].extra && games[key].extra.old_id) { 7 | const idOld = games[key].extra.old_id 8 | ids.push(idOld) 9 | } 10 | }) 11 | 12 | function hasDuplicates (obj) { 13 | const uniqueSet = new Set() 14 | 15 | for (const item of obj) { 16 | if (uniqueSet.has(item)) { 17 | console.log('Duplicate:', item) 18 | return true 19 | } 20 | uniqueSet.add(item) 21 | } 22 | 23 | return false 24 | } 25 | 26 | if (hasDuplicates(ids)) { 27 | console.log('Duplicates found.') 28 | } else { 29 | console.log('No duplicates found.') 30 | } 31 | -------------------------------------------------------------------------------- /tools/generate_games_list.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import * as fs from 'node:fs' 4 | import { games } from '../lib/games.js' 5 | import { fileURLToPath } from 'node:url' 6 | import { dirname } from 'node:path' 7 | 8 | const __filename = fileURLToPath(import.meta.url) 9 | const __dirname = dirname(__filename) 10 | 11 | const readmeFilename = __dirname + '/../GAMES_LIST.md' 12 | const readme = fs.readFileSync(readmeFilename, { encoding: 'utf8' }) 13 | 14 | const markerTop = '' 15 | const markerBottom = '' 16 | 17 | const sortedGamesIds = Object.keys(games).sort() 18 | const sortedGames = {} 19 | sortedGamesIds.forEach(key => { 20 | sortedGames[key] = games[key] 21 | }) 22 | 23 | const columnDelimiter = '|' 24 | const columnPadLeft = 1 25 | const columnPadRight = 1 26 | 27 | const HeaderType = { 28 | ID: 0, 29 | GameName: 1, 30 | Notes: 2 31 | } 32 | const HeaderNames = { 33 | [HeaderType.ID]: { Name: 'GameDig Type ID' }, 34 | [HeaderType.GameName]: { Name: 'Name' }, 35 | [HeaderType.Notes]: { Name: 'See Also' } 36 | } 37 | // defines the order of columns 38 | const HeaderDefinition = [ 39 | HeaderType.ID, 40 | HeaderType.GameName, 41 | HeaderType.Notes 42 | ] 43 | 44 | const headerMap = HeaderDefinition.map(idx => Object.values(HeaderType)[idx]) 45 | const headers = Object.keys(HeaderType).map((x, idx) => HeaderNames[idx].Name) 46 | 47 | const matrix = [] 48 | const maxLength = headers.map(x => x?.length ?? 0) 49 | Object.entries(sortedGames).forEach(([id, game]) => { 50 | const lineArray = Array(headerMap.length).fill('') 51 | lineArray[HeaderType.ID] = id 52 | lineArray[HeaderType.GameName] = game.name 53 | 54 | const notes = [] 55 | if (game?.extra?.doc_notes) { 56 | notes.push('[Notes](#' + game.extra.doc_notes + ')') 57 | } 58 | if (['valve', 'dayz', 'sdtd'].includes(game.options.protocol)) { 59 | notes.push('[Valve Protocol](#valve)') 60 | } 61 | if (['epic', 'asa', 'theisleevrima', 'renown'].includes(game.options.protocol)) { 62 | notes.push('[EOS Protocol](#epic)') 63 | } 64 | lineArray[HeaderType.Notes] = notes.join(', ') 65 | 66 | lineArray.forEach((x, index) => { 67 | maxLength[index] = Math.max(maxLength[index], x?.length ?? 0) 68 | }) 69 | matrix.push(lineArray) 70 | }) 71 | 72 | matrix.splice(0, 0, headers) 73 | const padLeft = ' '.repeat(columnPadLeft) 74 | const padRight = ' '.repeat(columnPadRight) 75 | const lines = matrix.map(row => { 76 | const values = headerMap.map((x, idx) => { 77 | return padLeft + row[x].padEnd(maxLength[x], ' ') + padRight 78 | }) 79 | return `${columnDelimiter}${''}${values.join(columnDelimiter)}${columnDelimiter}` 80 | }) 81 | const headerSeps = ['', ...headerMap.map(x => '-'.repeat(maxLength[x] + columnPadLeft + columnPadRight)), ''] 82 | const headerSep = `${headerSeps.join(columnDelimiter)}` 83 | lines.splice(1, 0, headerSep) 84 | const generated = lines.join('\n') 85 | 86 | let start = readme.indexOf(markerTop) 87 | const end = start >= 0 ? readme.indexOf(markerBottom) : 0 88 | start = Math.max(0, start) + (start >= 0 ? markerTop.length : 0) 89 | 90 | const updated = readme.substring(0, start) + '\n' + generated + '\n' + readme.substring(end) 91 | fs.writeFileSync(readmeFilename, updated) 92 | -------------------------------------------------------------------------------- /tools/run-id-tests.js: -------------------------------------------------------------------------------- 1 | import { spawnSync } from 'node:child_process' 2 | import process from 'node:process' 3 | 4 | // Import directly from file so that this script works without dependencies installed. 5 | import { games } from './../lib/games.js' 6 | 7 | const ID_TEST_BIN = process.env.GAMEDIG_ID_TESTER || 'gamedig-id-tests' 8 | 9 | const result = spawnSync(ID_TEST_BIN, { 10 | input: JSON.stringify(games), 11 | stdio: ['pipe', 'inherit', 'inherit'] 12 | }) 13 | 14 | if (result.error) { 15 | throw result.error 16 | } 17 | 18 | process.exit(result.status) 19 | --------------------------------------------------------------------------------