Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 10 additions & 17 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -8,27 +8,29 @@ RUN apt-get update \
&& \
apt-get install -y --no-install-recommends --no-install-suggests \
python3 \
python3-pip \
lib32stdc++6 \
lib32gcc-s1 \
libcurl4 \
wget \
ca-certificates \
curl \
libstdc++6 \
git \
&& \
apt-get remove --purge -y \
&& \
apt-get clean autoclean \
&& \
apt-get autoremove -y \
&& \
rm -rf /var/lib/apt/lists/* \
&& \
mkdir -p /steamcmd \
&& \
wget -qO- 'https://steamcdn-a.akamaihd.net/client/installer/steamcmd_linux.tar.gz' | tar zxf - -C /steamcmd
rm -rf /var/lib/apt/lists/*

RUN pip3 install -U zstandard "git+https://github.com/brettmayson/valvepythonsteam#egg=steam[client]"

ENV PYTHONUNBUFFERED=1

ENV ARMA_BINARY=./arma3server
ENV ARMA_BINARY=./arma3server_x64
ENV ARMA_CONFIG=main.cfg
ENV ARMA_PARAMS=
ENV ARMA_PROFILE=main
Expand All @@ -38,10 +40,8 @@ ENV ARMA_CDLC=
ENV HEADLESS_CLIENTS=0
ENV HEADLESS_CLIENTS_PROFILE="\$profile-hc-\$i"
ENV PORT=2302
ENV STEAM_BRANCH=public
ENV STEAM_BRANCH_PASSWORD=
ENV STEAM_ADDITIONAL_DEPOT=
ENV MODS_LOCAL=true
ENV CLEAR_KEYS=true
ENV MODS_PRESET=
ENV SKIP_INSTALL=false

Expand All @@ -53,14 +53,7 @@ EXPOSE 2306/udp

WORKDIR /arma3

VOLUME /steamcmd
VOLUME /arma3/addons
VOLUME /arma3/enoch
VOLUME /arma3/expansion
VOLUME /arma3/jets
VOLUME /arma3/heli
VOLUME /arma3/orange
VOLUME /arma3/argo
VOLUME /arma3/server

STOPSIGNAL SIGINT

Expand Down
39 changes: 19 additions & 20 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,10 @@ An Arma 3 Dedicated Server. Updates to the latest version every time it is resta
-p 2304:2304/udp \
-p 2305:2305/udp \
-p 2306:2306/udp \
-v path/to/missions:/arma3/mpmissions \
-v path/to/configs:/arma3/configs \
-v path/to/mods:/arma3/mods \
-v path/to/servermods:/arma3/servermods \
-v path/to/missions:/arma3/server/mpmissions \
-v path/to/configs:/arma3/server/configs \
-v path/to/mods:/arma3/server/mods \
-v path/to/servermods:/arma3/server/servermods \
-e ARMA_CONFIG=main.cfg \
-e STEAM_USER=myusername \
-e STEAM_PASSWORD=mypassword \
Expand All @@ -42,35 +42,34 @@ Use `docker-compose up -d` to start the server, detached.

See [Docker-compose](https://docs.docker.com/compose/install/#install-compose) for an installation guide.

Profiles are saved in `/arma3/configs/profiles`
Profiles are saved in `/arma3/server/configs/profiles`

## Parameters

| Parameter | Function | Default |
| ------------- |-------------- | - |
| `-p 2302-2306` | Ports required by Arma 3 |
| `-v /arma3/mpmission` | Folder with MP Missions |
| `-v /arma3/configs` | Folder containing config files |
| `-v /arma3/mods` | Mods that will be loaded by clients |
| `-v /arma3/servermods` | Mods that will only be loaded by the server |
| `-v /arma3/server/mpmission` | Folder with MP Missions |
| `-v /arma3/server/configs` | Folder containing config files |
| `-v /arma3/server/mods` | Mods that will be loaded by clients |
| `-v /arma3/server/servermods` | Mods that will only be loaded by the server |
| `-v /arma3/server` | Folder containing the server files |
| `-e PORT` | Port used by the server, (uses PORT to PORT+3) | 2302 |
| `-e ARMA_BINARY` | Arma 3 server binary to use, `./arma3server_x64` for x64 | `./arma3server` |
| `-e ARMA_CONFIG` | Config file to load from `/arma3/configs` | `main.cfg` |
| `-e ARMA_BINARY` | Arma 3 server binary to use | `./arma3server` |
| `-e ARMA_CONFIG` | Config file to load from `/arma3/server/configs` | `main.cfg` |
| `-e ARMA_PARAMS` | Additional Arma CLI parameters |
| `-e ARMA_PROFILE` | Profile name, stored in `/arma3/configs/profiles` | `main` |
| `-e ARMA_PROFILE` | Profile name, stored in `/arma3/server/configs/profiles` | `main` |
| `-e ARMA_WORLD` | World to load on startup | `empty` |
| `-e ARMA_LIMITFPS` | Maximum FPS | `1000` |
| `-e ARMA_CDLC` | cDLCs to load |
| `-e STEAM_BRANCH` | Steam branch used by steamcmd | `public` |
| `-e STEAM_BRANCH_PASSWORD` | Steam branch password used by steamcmd |
| `-e ARMA_CDLC` | cDLCs to load, separated by semicolons | - |
| `-e STEAM_USER` | Steam username used to login to steamcmd |
| `-e STEAM_PASSWORD` | Steam password |
| `-e HEADLESS_CLIENTS` | Launch n number of headless clients | `0` |
| `-e HEADLESS_CLIENTS_PROFILE` | Headless client profile name (supports placeholders) | `$profile-hc-$i` |
| `-e MODS_LOCAL` | Should the mods folder be loaded | `true` |
| `-e MODS_PRESET` | An Arma 3 Launcher preset to load |
| `-e SKIP_INSTALL` | Skip Arma 3 installation | `false` |
| `-e CLEAR_KEYS` | Clear the keys directory every launch (keys will still be copied from mods) | `false` |
| `-e CLEAR_KEYS` | Clear the keys directory every launch (keys will still be copied from mods) | `true` |

The Steam account does not need to own Arma 3, but must have Steam Guard disabled.

Expand All @@ -82,10 +81,10 @@ To use a Creator DLC the `STEAM_BRANCH` must be set to `creatordlc`

| Name | Flag |
| ---- | ---- |
| [CSLA Iron Curtain](https://store.steampowered.com/app/1294440/Arma_3_Creator_DLC_CSLA_Iron_Curtain/) | CSLA |
| [Global Mobilization - Cold War Germany](https://store.steampowered.com/app/1042220/Arma_3_Creator_DLC_Global_Mobilization__Cold_War_Germany/) | GM |
| [CSLA Iron Curtain](https://store.steampowered.com/app/1294440/Arma_3_Creator_DLC_CSLA_Iron_Curtain/) | csla |
| [Global Mobilization - Cold War Germany](https://store.steampowered.com/app/1042220/Arma_3_Creator_DLC_Global_Mobilization__Cold_War_Germany/) | gm |
| [S.O.G. Prairie Fire](https://store.steampowered.com/app/1227700/Arma_3_Creator_DLC_SOG_Prairie_Fire) | vn |
| [Western Sahara](https://store.steampowered.com/app/1681170/Arma_3_Creator_DLC_Western_Sahara/) | WS |
| [Western Sahara](https://store.steampowered.com/app/1681170/Arma_3_Creator_DLC_Western_Sahara/) | ws |
| [Spearhead 1944](https://store.steampowered.com/app/1175380/Arma_3_Creator_DLC_Spearhead_1944/) | spe |
| [Reaction Forces](https://store.steampowered.com/app/2647760/Arma_3_Creator_DLC_Reaction_Forces/) | rf |
| [Expeditionary Forces](https://store.steampowered.com/app/2647830/Arma_3_Creator_DLC_Expeditionary_Forces/) | ef |
Expand All @@ -110,7 +109,7 @@ Bohemia-updated list of codes here: <https://community.bistudio.com/wiki/Categor

### Workshop

Set the environment variable `MODS_PRESET` to the HTML preset file exported from the Arma 3 Launcher. The path can be local file or a URL. A volume can be created at `/arma3/steamapps/workshop/content/107410` to preserve the mods between containers.
Set the environment variable `MODS_PRESET` to the HTML preset file exported from the Arma 3 Launcher. The path can be local file or a URL. A volume can be created at `/arma3/server/workshop/` to preserve the mods between containers separately from the main `/arma3/server` volume.

`-e MODS_PRESET="my_mods.html"`

Expand Down
184 changes: 184 additions & 0 deletions api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
from steam.client import SteamClient
from steam.client.cdn import CDNClient, CDNDepotFile
import os
import hashlib
import json
import time
import sys

ARMA3_SERVER_APP_ID = 233780
CACHE_DIR = "cache"
MANIFEST_CACHE_FILE = os.path.join(CACHE_DIR, "manifests.json")
CACHE_EXPIRY_SECONDS = 5 * 60

CDLC_IDS = {
"csla": 233793,
"gm": 233792,
"vn": 233794,
"ws": 233795,
"spe": 233788,
"rf": 233799,
"ef": 233798
}

def login(username, password):
client = SteamClient()
client.login(username, password)
print("Logged in to Steam as", client.user.name)
return client

def load_cached_manifests():
"""Load cached manifest data if it exists and is not expired"""
if not os.path.exists(MANIFEST_CACHE_FILE):
return None

try:
with open(MANIFEST_CACHE_FILE, 'r') as f:
cache_data = json.load(f)

if time.time() - cache_data.get('timestamp', 0) > CACHE_EXPIRY_SECONDS:
print("Manifest cache expired, will refetch...")
return None

return cache_data.get('manifests', [])
except (json.JSONDecodeError, FileNotFoundError):
print("Cache file corrupted or missing, will refetch...")
return None

def save_manifests_to_cache(manifests):
"""Save manifest data to cache with timestamp"""
os.makedirs(CACHE_DIR, exist_ok=True)

manifest_data = []
for manifest in manifests:
manifest_data.append({
'name': manifest.name,
'gid': manifest.gid,
'depot_id': manifest.depot_id
})

cache_data = {
'timestamp': time.time(),
'manifests': manifest_data
}

with open(MANIFEST_CACHE_FILE, 'w') as f:
json.dump(cache_data, f, indent=2)

print(f"Manifest data cached to {MANIFEST_CACHE_FILE}")

def download_depot(client, depot_id):
cdn_client = CDNClient(client)

cached_manifests = load_cached_manifests()

if cached_manifests:
manifests = cached_manifests
print("Got manifests from cache for ARMA3 server app ID:", ARMA3_SERVER_APP_ID)
else:
print("Fetching fresh manifests from Steam...")
manifests_obj = cdn_client.get_manifests(ARMA3_SERVER_APP_ID, branch="creatordlc")

save_manifests_to_cache(manifests_obj)

manifests = []
for manifest in manifests_obj:
manifests.append({
'name': manifest.name,
'gid': manifest.gid,
'depot_id': manifest.depot_id
})

target_manifest = next((m for m in manifests if m['depot_id'] == depot_id), None)
if not target_manifest:
print(f"No manifest found for depot ID {depot_id}")
return

print(f"Downloading Manifest ID: {target_manifest['gid']}, Depot ID: {target_manifest['depot_id']}")

files_generator = cdn_client.iter_files(ARMA3_SERVER_APP_ID, branch="creatordlc", filter_func=lambda d_id, depot_info: d_id == target_manifest['depot_id'])
files = list(files_generator)
files = [f for f in files if f.is_file]
print(f"Found {len(files)} files to download")
download_files(client, cdn_client, files, destination="server/")

def download_workshop(client, workshop_id):
cdn_client = CDNClient(client)
workshop_manifest = cdn_client.get_manifest_for_workshop_item(workshop_id)
files_generator = workshop_manifest.iter_files()
files = list(files_generator)
files = [f for f in files if f.is_file]
print(f"Found {len(files)} files to download")
download_files(client, cdn_client, files, destination=f"server/workshop/{workshop_id}/")

def download_files(client, cdn_client, files, destination):
print(f"Verifying {len(files)} files...")
files_to_download = []

for i, file in enumerate(files):
file.local = os.path.join(destination, file.filename).lower()

if os.path.exists(file.local):
with open(file.local, 'rb') as f:
existing_hash = hashlib.sha1(f.read()).hexdigest()
expected_hash = file.sha_content.hex() if isinstance(file.sha_content, bytes) else file.sha_content
if existing_hash == expected_hash:
if file.is_executable:
os.chmod(file.local, 0o755)
continue
files_to_download.append(file)

print(f"Need to download {len(files_to_download)} files...")

for i, file in enumerate(files_to_download):
print(f"Downloading {i+1}/{len(files_to_download)}: {file.filename} ({file.size} bytes)")
_download_single_file(file)

print("All files downloaded successfully.")

def _download_single_file(file):
if file.local and os.path.dirname(file.local) != "":
os.makedirs(os.path.dirname(file.local), exist_ok=True)

chunk_size = 1024 * 1024 # 1MB chunks
downloaded = 0
failed = False

with open(file.local, 'wb') as f:
while downloaded < file.size:
remaining = file.size - downloaded
read_size = min(chunk_size, remaining)

chunk = file.read(read_size)
if not chunk:
break

f.write(chunk)
downloaded += len(chunk)

if downloaded % (10 * 1024 * 1024) == 0:
percent = (downloaded / file.size) * 100
print(f" Progress: {percent:.1f}% ({downloaded}/{file.size} bytes)")

if failed:
print(f"Failed to download {file.filename}")
else:
if file.is_executable:
os.chmod(file.local, 0o755)
print(f"✓ Downloaded {file.filename}")

if __name__ == "__main__":
import os
import sys

if len(sys.argv) != 3:
print("Usage: python api.py <username> <password>")
sys.exit(1)

username = sys.argv[1]
password = sys.argv[2]

client = login(username, password)
if client:
download_depot(client, 233785) # Western Sahara
download_workshop(client, 463939057) # Example workshop ID for ACE3 mod
16 changes: 4 additions & 12 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,10 @@ services:
container_name: arma3
network_mode: host
volumes:
- './missions:/arma3/mpmissions'
- './configs:/arma3/configs'
- './mods:/arma3/mods'
- './servermods:/arma3/servermods'
- 'addons:/arma3/addons'
- 'argo:/arma3/argo'
- 'enoch:/arma3/enoch'
- 'expansion:/arma3/expansion'
- 'heli:/arma3/heli'
- 'jets:/arma3/jets'
- 'orange:/arma3/orange'
- 'steamcmd:/steamcmd'
- './configs:/arma3/server/configs'
- './mods:/arma3/server/mods'
- './servermods:/arma3/server/servermods'
- './server:/arma3/server/'
env_file: .env
restart: unless-stopped
volumes:
Expand Down
2 changes: 1 addition & 1 deletion keys.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ def copy(moddir):
if len(keys) > 0:
for key in keys:
if not os.path.isdir(key):
shutil.copy2(key, "/arma3/keys")
shutil.copy2(key, "/arma3/server/keys")
else:
print("Missing keys:", moddir)

Expand Down
Loading
Loading