Files
Tran G. (Revernomad) Khoa 6e9a37ef00 fix: use absolute paths in Arma 3 launch args to survive cwd change
Arma 3 changes its own working directory to the exe folder on startup,
so relative paths like -config=server.cfg resolved against A3Master/
instead of the server data dir. Configs were never found, and profile/
battleye dirs pointed at the wrong location (confirmed via RPT location
in A3Master/server/ instead of the data dir).

build_launch_args() now accepts an optional server_dir: Path argument.
When provided, all four path args (-config, -cfg, -profiles, -bepath)
use absolute paths. Service passes server_dir at the call site.
2026-04-20 11:13:18 +07:00

616 lines
35 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Arma 3 config generator.
Merged protocol: Pydantic models (schema) + file generation + launch args.
"""
from __future__ import annotations
import os
from pathlib import Path
from typing import Any, Union
from pydantic import BaseModel, Field
MissionParamValue = Union[int, float, str, bool]
# ─── Pydantic Models (config schema) ─────────────────────────────────────────
class MissionRotationItem(BaseModel):
name: str
difficulty: str = ""
params: dict[str, MissionParamValue] = Field(default_factory=dict)
class ServerConfig(BaseModel):
hostname: str = "My Arma 3 Server"
password: str | None = None
password_admin: str = ""
server_command_password: str | None = None
max_players: int = Field(default=40, gt=0, le=1000)
kick_duplicate: int = Field(default=1, ge=0, le=1)
persistent: int = Field(default=1, ge=0, le=1)
vote_threshold: float = Field(default=0.33, ge=0.0, le=1.0)
vote_mission_players: int = Field(default=1, ge=0)
vote_timeout: int = Field(default=60, ge=0)
role_timeout: int = Field(default=90, ge=0)
briefing_timeout: int = Field(default=60, ge=0)
debriefing_timeout: int = Field(default=45, ge=0)
lobby_idle_timeout: int = Field(default=300, ge=0)
disable_von: int = Field(default=0, ge=0, le=1)
von_codec: int = Field(default=1, ge=0, le=1)
von_codec_quality: int = Field(default=20, ge=0, le=30)
max_ping: int = Field(default=250, gt=0)
max_packet_loss: int = Field(default=50, ge=0, le=100)
max_desync: int = Field(default=200, ge=0)
disconnect_timeout: int = Field(default=15, ge=0)
kick_on_ping: int = Field(default=1, ge=0, le=1)
kick_on_packet_loss: int = Field(default=1, ge=0, le=1)
kick_on_desync: int = Field(default=1, ge=0, le=1)
kick_on_timeout: int = Field(default=1, ge=0, le=1)
battleye: int = Field(default=1, ge=0, le=1)
verify_signatures: int = Field(default=2, ge=0, le=2)
allowed_file_patching: int = Field(default=0, ge=0, le=2)
forced_difficulty: str = "Regular"
timestamp_format: str = "short"
auto_select_mission: int = Field(default=0, ge=0, le=1)
random_mission_order: int = Field(default=0, ge=0, le=1)
log_file: str = "server_console.log"
skip_lobby: int = Field(default=0, ge=0, le=1)
drawing_in_map: int = Field(default=1, ge=0, le=1)
upnp: int = Field(default=0, ge=0, le=1)
loopback: int = Field(default=0, ge=0, le=1)
statistics_enabled: int = Field(default=1, ge=0, le=1)
motd_lines: list[str] = Field(default_factory=lambda: ["Welcome!", "Have fun"])
motd_interval: float = Field(default=5.0, gt=0)
headless_clients: list[str] = Field(default_factory=list)
local_clients: list[str] = Field(default_factory=list)
admin_uids: list[str] = Field(default_factory=list)
missions: list[MissionRotationItem] = Field(default_factory=list)
default_mission_params: dict[str, MissionParamValue] = Field(default_factory=dict)
class BasicConfig(BaseModel):
min_bandwidth: int = Field(default=131072, gt=0)
max_bandwidth: int = Field(default=10000000000, gt=0)
max_msg_send: int = Field(default=128, gt=0)
max_size_guaranteed: int = Field(default=512, gt=0)
max_size_non_guaranteed: int = Field(default=256, gt=0)
min_error_to_send: float = Field(default=0.001, gt=0)
max_custom_file_size: int = Field(default=0, ge=0)
class ProfileConfig(BaseModel):
reduced_damage: int = Field(default=0, ge=0, le=1)
group_indicators: int = Field(default=0, ge=0, le=3)
friendly_tags: int = Field(default=0, ge=0, le=3)
enemy_tags: int = Field(default=0, ge=0, le=3)
detected_mines: int = Field(default=0, ge=0, le=3)
commands: int = Field(default=1, ge=0, le=3)
waypoints: int = Field(default=0, ge=0, le=3)
tactical_ping: int = Field(default=0, ge=0, le=1)
weapon_info: int = Field(default=2, ge=0, le=3)
stance_indicator: int = Field(default=2, ge=0, le=3)
stamina_bar: int = Field(default=2, ge=0, le=2)
weapon_crosshair: int = Field(default=0, ge=0, le=1)
vision_aid: int = Field(default=0, ge=0, le=1)
third_person_view: int = Field(default=0, ge=0, le=1)
camera_shake: int = Field(default=1, ge=0, le=1)
score_table: int = Field(default=0, ge=0, le=1)
death_messages: int = Field(default=1, ge=0, le=1)
von_id: int = Field(default=1, ge=0, le=1)
map_content_friendly: int = Field(default=0, ge=0, le=3)
map_content_enemy: int = Field(default=0, ge=0, le=3)
map_content_mines: int = Field(default=0, ge=0, le=3)
auto_report: int = Field(default=0, ge=0, le=1)
multiple_saves: int = Field(default=0, ge=0, le=1)
ai_level_preset: int = Field(default=3, ge=0, le=4)
skill_ai: float = Field(default=1.0, ge=0.0, le=1.0)
precision_ai: float = Field(default=0.2, ge=0.0, le=1.0)
class LaunchConfig(BaseModel):
world: str = "empty"
extra_params: str = ""
limit_fps: int = Field(default=50, gt=0, le=1000)
auto_init: int = Field(default=0, ge=0, le=1)
load_mission_to_memory: int = Field(default=0, ge=0, le=1)
enable_ht: int = Field(default=0, ge=0, le=1)
huge_pages: int = Field(default=0, ge=0, le=1)
cpu_count: int | None = None
ex_threads: int = Field(default=7, ge=0)
max_mem: int | None = None
no_logs: int = Field(default=0, ge=0, le=1)
netlog: int = Field(default=0, ge=0, le=1)
class RConConfig(BaseModel):
rcon_password: str = ""
max_ping: int = Field(default=200, gt=0)
enabled: int = Field(default=1, ge=0, le=1)
# ─── Config Generator ─────────────────────────────────────────────────────────
class Arma3ConfigGenerator:
game_type = "arma3"
SECTIONS: dict[str, type[BaseModel]] = {
"server": ServerConfig,
"basic": BasicConfig,
"profile": ProfileConfig,
"launch": LaunchConfig,
"rcon": RConConfig,
}
SENSITIVE_FIELDS: dict[str, list[str]] = {
"server": ["password", "password_admin", "server_command_password"],
"rcon": ["rcon_password"],
}
def get_sections(self) -> dict[str, type[BaseModel]]:
return self.SECTIONS
def get_defaults(self, section: str) -> dict[str, Any]:
model_cls = self.SECTIONS.get(section)
if model_cls is None:
return {}
return model_cls().model_dump()
def get_sensitive_fields(self, section: str) -> list[str]:
return self.SENSITIVE_FIELDS.get(section, [])
def get_config_version(self) -> str:
return "1.1.0"
def migrate_config(self, old_version: str, config_json: dict) -> dict:
from adapters.exceptions import ConfigMigrationError
if old_version == "1.0.0":
server = config_json.get("server", {})
for m in server.get("missions", []):
if isinstance(m, dict):
m.setdefault("params", {})
server.setdefault("default_mission_params", {})
return config_json
raise ConfigMigrationError(
old_version, f"No migration path from {old_version} to {self.get_config_version()}"
)
def normalize_section(self, section: str, data: dict) -> dict:
"""Backfill new optional fields on server section for pre-1.1.0 stored data."""
if section == "server":
for m in data.get("missions", []):
if isinstance(m, dict):
m.setdefault("params", {})
data.setdefault("default_mission_params", {})
return data
# ── Config file writers ───────────────────────────────────────────────────
def _render_param_value(self, val: MissionParamValue) -> str:
if isinstance(val, bool):
return "1" if val else "0"
if isinstance(val, (int, float)):
return str(val)
return f'"{self._escape(str(val))}"'
def _render_missions_block(self, cfg: ServerConfig) -> str:
"""Render the class Missions { ... } block for server.cfg.
Per-mission params take priority; falls back to default_mission_params;
if both are empty the class Params block is omitted entirely.
"""
if not cfg.missions:
return ""
lines = ["class Missions {"]
for idx, entry in enumerate(cfg.missions):
effective = entry.params if entry.params else cfg.default_mission_params
lines.append(f" class Mission_{idx} {{")
lines.append(f' template = "{self._escape(entry.name)}";')
if entry.difficulty:
lines.append(f' difficulty = "{self._escape(entry.difficulty)}";')
if effective:
lines.append(" class Params {")
for key, val in effective.items():
lines.append(f" {key} = {self._render_param_value(val)};")
lines.append(" };")
lines.append(" };")
lines.append("};")
return "\n".join(lines) + "\n"
@staticmethod
def _escape(value: str) -> str:
"""
Escape a string for use inside Arma 3 double-quoted config values.
Order matters: escape backslashes FIRST.
"""
value = value.replace("\\", "\\\\")
value = value.replace('"', '\\"')
value = value.replace('\n', '\\n')
return value
@staticmethod
def _atomic_write(path: Path, content: str) -> None:
"""Write content to path atomically via tmp file + os.replace()."""
from adapters.exceptions import ConfigWriteError
tmp_path = path.with_suffix(path.suffix + ".tmp")
try:
path.parent.mkdir(parents=True, exist_ok=True)
tmp_path.write_text(content, encoding="utf-8")
os.replace(str(tmp_path), str(path))
except OSError as e:
# Clean up tmp file if it exists
try:
tmp_path.unlink(missing_ok=True)
except OSError as exc:
logger.debug("Could not clean up temp file %s: %s", tmp_path, exc)
raise ConfigWriteError(str(path), str(e)) from e
def _render_server_cfg(self, cfg: ServerConfig) -> str:
"""Render server.cfg content string."""
motd_items = ", ".join(f'"{self._escape(l)}"' for l in cfg.motd_lines)
headless = ", ".join(f'"{h}"' for h in cfg.headless_clients)
local = ", ".join(f'"{l}"' for l in cfg.local_clients)
admin_uids = ", ".join(f'"{u}"' for u in cfg.admin_uids)
lines = [
f'hostname = "{self._escape(cfg.hostname)}";',
]
if cfg.password:
lines.append(f'password = "{self._escape(cfg.password)}";')
if cfg.password_admin:
lines.append(f'passwordAdmin = "{self._escape(cfg.password_admin)}";')
if cfg.server_command_password:
lines.append(f'serverCommandPassword = "{self._escape(cfg.server_command_password)}";')
lines += [
f"maxPlayers = {cfg.max_players};",
f"kickDuplicate = {cfg.kick_duplicate};",
f"persistent = {cfg.persistent};",
f"voteThreshold = {cfg.vote_threshold};",
f"voteMissionPlayers = {cfg.vote_mission_players};",
f"voteTimeout = {cfg.vote_timeout};",
f"roleTimeout = {cfg.role_timeout};",
f"briefingTimeOut = {cfg.briefing_timeout};",
f"debriefingTimeOut = {cfg.debriefing_timeout};",
f"lobbyIdleTimeout = {cfg.lobby_idle_timeout};",
f"disableVoN = {cfg.disable_von};",
f"vonCodec = {cfg.von_codec};",
f"vonCodecQuality = {cfg.von_codec_quality};",
f"maxPing = {cfg.max_ping};",
f"maxPacketLoss = {cfg.max_packet_loss};",
f"maxDesync = {cfg.max_desync};",
f"disconnectTimeout = {cfg.disconnect_timeout};",
f"kickOnPing = {cfg.kick_on_ping};",
f"kickOnPacketLoss = {cfg.kick_on_packet_loss};",
f"kickOnDesync = {cfg.kick_on_desync};",
f"kickOnTimeout = {cfg.kick_on_timeout};",
f"BattlEye = {cfg.battleye};",
f"verifySignatures = {cfg.verify_signatures};",
f"allowedFilePatching = {cfg.allowed_file_patching};",
f'forcedDifficulty = "{cfg.forced_difficulty}";',
f'timeStampFormat = "{cfg.timestamp_format}";',
f"autoSelectMission = {cfg.auto_select_mission};",
f"randomMissionOrder = {cfg.random_mission_order};",
f'logFile = "{cfg.log_file}";',
f"skipLobby = {cfg.skip_lobby};",
f"drawingInMap = {cfg.drawing_in_map};",
f"upnp = {cfg.upnp};",
f"loopback = {cfg.loopback};",
f"statisticsEnabled = {cfg.statistics_enabled};",
f"motd[] = {{{motd_items}}};",
f"motdInterval = {cfg.motd_interval};",
]
if cfg.headless_clients:
lines.append(f"headlessClients[] = {{{headless}}};")
if cfg.local_clients:
lines.append(f"localClient[] = {{{local}}};")
if cfg.admin_uids:
lines.append(f"admins[] = {{{admin_uids}}};")
return "\n".join(lines) + "\n" + self._render_missions_block(cfg)
def _render_basic_cfg(self, cfg: BasicConfig) -> str:
return (
f"MinBandwidth = {cfg.min_bandwidth};\n"
f"MaxBandwidth = {cfg.max_bandwidth};\n"
f"MaxMsgSend = {cfg.max_msg_send};\n"
f"MaxSizeGuaranteed = {cfg.max_size_guaranteed};\n"
f"MaxSizeNonguaranteed = {cfg.max_size_non_guaranteed};\n"
f"MinErrorToSend = {cfg.min_error_to_send};\n"
f"MaxCustomFileSize = {cfg.max_custom_file_size};\n"
)
def _render_arma3profile(self, cfg: ProfileConfig) -> str:
return (
"class DifficultyPresets {\n"
" class CustomDifficulty {\n"
" class Options {\n"
f" reducedDamage = {cfg.reduced_damage};\n"
f" groupIndicators = {cfg.group_indicators};\n"
f" friendlyTags = {cfg.friendly_tags};\n"
f" enemyTags = {cfg.enemy_tags};\n"
f" detectedMines = {cfg.detected_mines};\n"
f" commands = {cfg.commands};\n"
f" waypoints = {cfg.waypoints};\n"
f" tacticalPing = {cfg.tactical_ping};\n"
f" weaponInfo = {cfg.weapon_info};\n"
f" stanceIndicator = {cfg.stance_indicator};\n"
f" staminaBar = {cfg.stamina_bar};\n"
f" weaponCrosshair = {cfg.weapon_crosshair};\n"
f" visionAid = {cfg.vision_aid};\n"
f" thirdPersonView = {cfg.third_person_view};\n"
f" cameraShake = {cfg.camera_shake};\n"
f" scoreTable = {cfg.score_table};\n"
f" deathMessages = {cfg.death_messages};\n"
f" vonID = {cfg.von_id};\n"
f" mapContentFriendly = {cfg.map_content_friendly};\n"
f" mapContentEnemy = {cfg.map_content_enemy};\n"
f" mapContentMines = {cfg.map_content_mines};\n"
f" autoReport = {cfg.auto_report};\n"
f" multipleSaves = {cfg.multiple_saves};\n"
" };\n"
f" aiLevelPreset = {cfg.ai_level_preset};\n"
" };\n"
" class CustomAILevel {\n"
f" skillAI = {cfg.skill_ai};\n"
f" precisionAI = {cfg.precision_ai};\n"
" };\n"
"};\n"
)
def _render_beserver_cfg(self, cfg: RConConfig) -> str:
return (
f"RConPassword {cfg.rcon_password}\n"
f"MaxPing {cfg.max_ping}\n"
)
# ── Public interface ──────────────────────────────────────────────────────
def write_configs(
self,
server_id: int,
server_dir: Path,
config_sections: dict[str, dict],
) -> list[Path]:
server_cfg = ServerConfig(**config_sections.get("server", {}))
basic_cfg = BasicConfig(**config_sections.get("basic", {}))
profile_cfg = ProfileConfig(**config_sections.get("profile", {}))
rcon_cfg = RConConfig(**config_sections.get("rcon", {}))
written = []
pairs = [
(server_dir / "server.cfg", self._render_server_cfg(server_cfg)),
(server_dir / "basic.cfg", self._render_basic_cfg(basic_cfg)),
(server_dir / "server" / "server.Arma3Profile", self._render_arma3profile(profile_cfg)),
(server_dir / "battleye" / "beserver.cfg", self._render_beserver_cfg(rcon_cfg)),
]
for path, content in pairs:
self._atomic_write(path, content)
written.append(path)
# Restrict permissions on files containing passwords (Unix only)
if os.name != "nt":
for path in [server_dir / "server.cfg", server_dir / "battleye" / "beserver.cfg"]:
if path.exists():
os.chmod(path, 0o600)
return written
def build_launch_args(
self,
config_sections: dict[str, dict],
mod_args: list[str] | None = None,
server_dir: Path | None = None,
) -> list[str]:
from adapters.exceptions import LaunchArgsError
launch = LaunchConfig(**config_sections.get("launch", {}))
server = ServerConfig(**config_sections.get("server", {}))
# Arma 3 changes its own cwd to the exe directory at startup, so relative
# paths in launch args resolve against the exe dir, not server_dir.
# Use absolute paths when server_dir is provided so configs are always found.
if server_dir is not None:
d = Path(server_dir)
config_arg = f"-config={d / 'server.cfg'}"
cfg_arg = f"-cfg={d / 'basic.cfg'}"
profiles_arg = f"-profiles={d / 'server'}"
bepath_arg = f"-bepath={d / 'battleye'}"
else:
config_arg = "-config=server.cfg"
cfg_arg = "-cfg=basic.cfg"
profiles_arg = "-profiles=./server"
bepath_arg = "-bepath=./battleye"
args = [
f"-port={config_sections.get('_port', 2302)}",
config_arg,
cfg_arg,
profiles_arg,
"-name=server",
f"-world={launch.world}",
f"-limitFPS={launch.limit_fps}",
bepath_arg,
]
if launch.auto_init:
args.append("-autoInit")
if launch.enable_ht:
args.append("-enableHT")
if launch.huge_pages:
args.append("-hugePages")
if launch.cpu_count is not None:
args.append(f"-cpuCount={launch.cpu_count}")
if launch.max_mem is not None:
args.append(f"-maxMem={launch.max_mem}")
if launch.no_logs:
args.append("-noLogs")
if launch.netlog:
args.append("-netlog")
if launch.extra_params:
args.extend(launch.extra_params.split())
if mod_args:
args.extend(mod_args)
return args
def get_ui_schema(self) -> dict:
B, A = False, True # basic / advanced shorthand
return {
"server": {
# Identity — basic
"hostname": {"widget": "text", "label": "Server Name", "advanced": B},
"max_players": {"widget": "number", "label": "Max Players", "min": 1, "max": 1000, "advanced": B},
"password": {"widget": "password", "label": "Join Password", "advanced": B},
"password_admin": {"widget": "password", "label": "Admin Password", "advanced": B},
"server_command_password": {"widget": "password", "label": "Server Command Password", "advanced": A},
# Message of the Day — basic
"motd_lines": {"widget": "textarea", "label": "Message of the Day (one line per row)", "advanced": B},
"motd_interval": {"widget": "number", "label": "MOTD Interval (sec)", "min": 1, "advanced": B},
# Mission / Rotation — basic
"forced_difficulty": {"widget": "select", "label": "Forced Difficulty",
"options": ["Recruit", "Regular", "Veteran", "Custom"], "advanced": B},
"auto_select_mission": {"widget": "toggle", "label": "Auto-Select Mission", "advanced": B},
"random_mission_order": {"widget": "toggle", "label": "Random Mission Order", "advanced": B},
# Behaviour — mixed
"persistent": {"widget": "toggle", "label": "Persistent (keep running when empty)", "advanced": B},
"kick_duplicate": {"widget": "toggle", "label": "Kick Duplicate Connections", "advanced": A},
"skip_lobby": {"widget": "toggle", "label": "Skip Lobby (go straight to briefing)", "advanced": B},
"drawing_in_map": {"widget": "toggle", "label": "Allow Drawing in Map", "advanced": B},
# Security — basic
"battleye": {"widget": "toggle", "label": "BattlEye Anti-Cheat", "advanced": B},
"verify_signatures": {"widget": "select", "label": "Verify Addon Signatures",
"options": ["0 - Off", "1 - Kick unsigned", "2 - Strict (kick mismatched)"], "advanced": B},
"allowed_file_patching": {"widget": "select", "label": "Allow File Patching",
"options": ["0 - Nobody", "1 - Lobby only", "2 - Everyone"], "advanced": B},
# Voice — basic
"disable_von": {"widget": "toggle", "label": "Disable Voice-over-Network (VoN)", "advanced": B},
"von_codec": {"widget": "toggle", "label": "Use Opus VoN Codec", "advanced": B},
"von_codec_quality": {"widget": "number", "label": "VoN Codec Quality (030)", "min": 0, "max": 30, "advanced": A},
# Network / Kick thresholds — advanced
"kick_on_ping": {"widget": "toggle", "label": "Kick on High Ping", "advanced": A},
"kick_on_packet_loss": {"widget": "toggle", "label": "Kick on High Packet Loss", "advanced": A},
"kick_on_desync": {"widget": "toggle", "label": "Kick on High Desync", "advanced": A},
"kick_on_timeout": {"widget": "toggle", "label": "Kick on Timeout", "advanced": A},
"max_ping": {"widget": "number", "label": "Max Ping (ms)", "min": 1, "advanced": A},
"max_packet_loss": {"widget": "number", "label": "Max Packet Loss (%)", "min": 0, "max": 100, "advanced": A},
"max_desync": {"widget": "number", "label": "Max Desync", "min": 0, "advanced": A},
"disconnect_timeout": {"widget": "number", "label": "Disconnect Timeout (sec)", "min": 0, "advanced": A},
# Voting — advanced
"vote_threshold": {"widget": "number", "label": "Vote Threshold (0.01.0)", "min": 0, "max": 1, "advanced": A},
"vote_mission_players": {"widget": "number", "label": "Min Players to Start Vote", "min": 0, "advanced": A},
"vote_timeout": {"widget": "number", "label": "Vote Timeout (sec)", "min": 0, "advanced": A},
# Timeouts — advanced
"role_timeout": {"widget": "number", "label": "Role Selection Timeout (sec)", "min": 0, "advanced": A},
"briefing_timeout": {"widget": "number", "label": "Briefing Timeout (sec)", "min": 0, "advanced": A},
"debriefing_timeout": {"widget": "number", "label": "Debriefing Timeout (sec)", "min": 0, "advanced": A},
"lobby_idle_timeout": {"widget": "number", "label": "Lobby Idle Timeout (sec)", "min": 0, "advanced": A},
# Misc — advanced
"statistics_enabled": {"widget": "toggle", "label": "Enable Steam Statistics", "advanced": A},
"upnp": {"widget": "toggle", "label": "Enable UPnP", "advanced": A},
"loopback": {"widget": "toggle", "label": "Loopback Mode (LAN only)", "advanced": A},
"timestamp_format": {"widget": "select", "label": "Log Timestamp Format",
"options": ["none", "short", "full"], "advanced": A},
"log_file": {"widget": "text", "label": "Log File Name", "advanced": A},
# Admin / Headless — advanced
"admin_uids": {"widget": "tag-list", "label": "Admin Steam UIDs",
"placeholder": "76561198000000000", "advanced": A},
"headless_clients": {"widget": "tag-list", "label": "Headless Client IPs",
"placeholder": "127.0.0.1", "advanced": A},
"local_clients": {"widget": "tag-list", "label": "Local Client IPs",
"placeholder": "127.0.0.1", "advanced": A},
# missions managed by the Missions tab — hidden here
"missions": {"widget": "hidden"},
# default params — advanced
"default_mission_params": {"widget": "key-value", "label": "Default Mission Parameters",
"help": "Applied to all missions without custom params.", "advanced": A},
},
"basic": {
# All network tuning fields are advanced
"min_bandwidth": {"widget": "number", "label": "Min Bandwidth (bps)", "min": 1, "advanced": A},
"max_bandwidth": {"widget": "number", "label": "Max Bandwidth (bps)", "min": 1, "advanced": A},
"max_msg_send": {"widget": "number", "label": "Max Messages Sent per Frame", "min": 1, "advanced": A},
"max_size_guaranteed": {"widget": "number", "label": "Max Guaranteed Packet Size (bytes)", "min": 1, "advanced": A},
"max_size_non_guaranteed": {"widget": "number", "label": "Max Non-Guaranteed Packet Size (bytes)", "min": 1, "advanced": A},
"min_error_to_send": {"widget": "number", "label": "Min Error to Send", "advanced": A},
"max_custom_file_size": {"widget": "number", "label": "Max Custom File Size (bytes)", "min": 0, "advanced": A},
},
"profile": {
# Basic difficulty options
"reduced_damage": {"widget": "toggle", "label": "Reduced Damage", "advanced": A},
"group_indicators": {"widget": "select", "label": "Group Indicators",
"options": ["0 - Never", "1 - Limited distance", "2 - Fade out", "3 - Always"], "advanced": B},
"friendly_tags": {"widget": "select", "label": "Friendly Name Tags",
"options": ["0 - Never", "1 - Limited distance", "2 - Fade out", "3 - Always"], "advanced": B},
"enemy_tags": {"widget": "select", "label": "Enemy Name Tags",
"options": ["0 - Never", "1 - Limited distance", "2 - Fade out", "3 - Always"], "advanced": B},
"detected_mines": {"widget": "select", "label": "Detected Mines",
"options": ["0 - Never", "1 - Limited distance", "2 - Fade out", "3 - Always"], "advanced": A},
"commands": {"widget": "select", "label": "Map Commands",
"options": ["0 - Never", "1 - High command", "2 - Fade out", "3 - Always"], "advanced": B},
"waypoints": {"widget": "select", "label": "Waypoints",
"options": ["0 - Never", "1 - Known positions", "2 - Fade out", "3 - Always"], "advanced": B},
"tactical_ping": {"widget": "toggle", "label": "Tactical Ping", "advanced": A},
"weapon_info": {"widget": "select", "label": "Weapon Info",
"options": ["0 - Never", "1 - Limited distance", "2 - Fade out", "3 - Always"], "advanced": B},
"stance_indicator": {"widget": "select", "label": "Stance Indicator",
"options": ["0 - Never", "1 - Experimental", "2 - Always", "3 - Always (soldier)"], "advanced": B},
"stamina_bar": {"widget": "select", "label": "Stamina Bar",
"options": ["0 - Never", "1 - Low stamina only", "2 - Always"], "advanced": A},
"weapon_crosshair": {"widget": "toggle", "label": "Weapon Crosshair", "advanced": A},
"vision_aid": {"widget": "toggle", "label": "Vision Aid", "advanced": A},
"third_person_view": {"widget": "toggle", "label": "Third Person View", "advanced": A},
"camera_shake": {"widget": "toggle", "label": "Camera Shake", "advanced": A},
"score_table": {"widget": "toggle", "label": "Show Score Table", "advanced": A},
"death_messages": {"widget": "toggle", "label": "Death Messages", "advanced": A},
"von_id": {"widget": "toggle", "label": "Show VoN Speaker ID", "advanced": A},
"map_content_friendly": {"widget": "select", "label": "Map — Friendly Units",
"options": ["0 - Never", "1 - Limited distance", "2 - Fade out", "3 - Always"], "advanced": A},
"map_content_enemy": {"widget": "select", "label": "Map — Enemy Units",
"options": ["0 - Never", "1 - Limited distance", "2 - Fade out", "3 - Always"], "advanced": A},
"map_content_mines": {"widget": "select", "label": "Map — Mines",
"options": ["0 - Never", "1 - Limited distance", "2 - Fade out", "3 - Always"], "advanced": A},
"auto_report": {"widget": "toggle", "label": "Auto Report (automatic radio reports)", "advanced": A},
"multiple_saves": {"widget": "toggle", "label": "Multiple Saves", "advanced": A},
"ai_level_preset": {"widget": "select", "label": "AI Level Preset",
"options": ["0 - Low", "1 - Normal", "2 - High", "3 - Custom", "4 - Ultra"], "advanced": B},
"skill_ai": {"widget": "number", "label": "AI Skill (0.01.0)", "min": 0, "max": 1, "advanced": B},
"precision_ai": {"widget": "number", "label": "AI Precision / Accuracy (0.01.0)", "min": 0, "max": 1, "advanced": B},
},
"launch": {
# All launch/startup fields are advanced
"world": {"widget": "text", "label": "Default World (map name)", "advanced": A},
"limit_fps": {"widget": "number", "label": "FPS Limit", "min": 1, "max": 1000, "advanced": A},
"cpu_count": {"widget": "number", "label": "CPU Core Count (0 = auto)", "min": 0, "advanced": A},
"ex_threads": {"widget": "number", "label": "Extra Thread Count", "min": 0, "advanced": A},
"max_mem": {"widget": "number", "label": "Max RAM (MB, 0 = auto)", "min": 0, "advanced": A},
"auto_init": {"widget": "toggle", "label": "Auto-Init (skip mission select)", "advanced": A},
"load_mission_to_memory": {"widget": "toggle", "label": "Load Mission to Memory", "advanced": A},
"enable_ht": {"widget": "toggle", "label": "Enable HyperThreading", "advanced": A},
"huge_pages": {"widget": "toggle", "label": "Enable Huge Pages (performance)", "advanced": A},
"no_logs": {"widget": "toggle", "label": "Disable Server Logging", "advanced": A},
"netlog": {"widget": "toggle", "label": "Enable Network Log", "advanced": A},
"extra_params": {"widget": "tag-list", "label": "Additional Startup Parameters",
"placeholder": "-filePatching", "advanced": A},
},
"rcon": {
"rcon_password": {"widget": "password", "label": "RCon Password", "advanced": B},
"max_ping": {"widget": "number", "label": "Max Ping for RCon (ms)", "min": 1, "advanced": A},
"enabled": {"widget": "toggle", "label": "Enable RCon", "advanced": B},
},
}
def preview_config(
self,
server_id: int,
server_dir: Path,
config_sections: dict[str, dict],
) -> dict[str, str]:
server_cfg = ServerConfig(**config_sections.get("server", {}))
basic_cfg = BasicConfig(**config_sections.get("basic", {}))
profile_cfg = ProfileConfig(**config_sections.get("profile", {}))
rcon_cfg = RConConfig(**config_sections.get("rcon", {}))
return {
"server.cfg": self._render_server_cfg(server_cfg),
"basic.cfg": self._render_basic_cfg(basic_cfg),
"server/server.Arma3Profile": self._render_arma3profile(profile_cfg),
"battleye/beserver.cfg": self._render_beserver_cfg(rcon_cfg),
}