2026-01-08 15:41:23 -05:00
|
|
|
from pathlib import Path
|
2026-01-21 22:06:07 -05:00
|
|
|
from textmenu import TextMenu
|
2026-01-02 18:12:41 -05:00
|
|
|
import threading
|
|
|
|
|
import time
|
|
|
|
|
import os
|
|
|
|
|
import sys
|
|
|
|
|
import random
|
|
|
|
|
import re
|
|
|
|
|
import queue
|
2026-01-20 15:57:22 -05:00
|
|
|
import tempfile
|
|
|
|
|
import psutil
|
2026-01-10 16:43:48 -05:00
|
|
|
import subprocess
|
2026-01-20 15:57:22 -05:00
|
|
|
import getpass
|
2026-01-22 01:49:32 -05:00
|
|
|
import shutil
|
2026-01-04 23:59:47 -05:00
|
|
|
|
2026-01-20 21:46:56 -05:00
|
|
|
if (os.name != "nt"):
|
|
|
|
|
import pty
|
|
|
|
|
|
2026-01-08 15:41:23 -05:00
|
|
|
os.environ["PYGAME_HIDE_SUPPORT_PROMPT"] = "1"
|
|
|
|
|
import pygame
|
2026-01-04 23:59:47 -05:00
|
|
|
|
|
|
|
|
# Event to signal shutdown
|
|
|
|
|
shutdown_event = threading.Event()
|
|
|
|
|
|
2026-01-02 18:12:41 -05:00
|
|
|
# ========================= CONFIG =========================
|
2026-01-22 22:13:18 -05:00
|
|
|
DEBUG = False # Set True to enable debug overrides
|
2026-01-02 18:12:41 -05:00
|
|
|
|
|
|
|
|
# Default paths (used if not in debug mode)
|
2026-01-20 15:57:22 -05:00
|
|
|
DEFAULT_WIN_GAME_EXE = r"./qgame.dll"
|
|
|
|
|
DEFAULT_LIN_GAME_EXE = r"./qgame.so"
|
|
|
|
|
DEFAULT_PLAYLIST = r"./arena/music/playlist.txt"
|
|
|
|
|
DEFAULT_RA3_MAPS = r"./arena/music/ra3_maps.txt"
|
|
|
|
|
DEFAULT_ARENA0_BL = r"./arena/music/arena0_bl.txt"
|
2026-01-20 20:14:29 -05:00
|
|
|
DEFAULT_GAME_EXE = ""
|
2026-01-02 18:12:41 -05:00
|
|
|
|
|
|
|
|
# Debug override paths
|
2026-01-20 15:57:22 -05:00
|
|
|
DEBUG_WIN_GAME_EXE = r"D:/GOG Games/Quake III/qgame.dll"
|
|
|
|
|
DEBUG_LIN_GAME_EXE = r"D:/GOG Games/Quake III/qgame.so"
|
|
|
|
|
DEBUG_PLAYLIST = r"D:/GOG Games/Quake III/arena/music/playlist.txt"
|
|
|
|
|
DEBUG_RA3_MAPS = r"D:/GOG Games/Quake III/arena/music/ra3_maps.txt"
|
|
|
|
|
DEBUG_ARENA0_BL = r"D:/GOG Games/Quake III/arena/music/arena0_bl.txt"
|
2026-01-20 20:14:29 -05:00
|
|
|
DEBUG_GAME_EXE = ""
|
2026-01-20 15:57:22 -05:00
|
|
|
|
|
|
|
|
if (os.name == "nt"):
|
|
|
|
|
DEFAULT_GAME_EXE = DEFAULT_WIN_GAME_EXE
|
|
|
|
|
DEBUG_GAME_EXE = DEBUG_WIN_GAME_EXE
|
|
|
|
|
else:
|
|
|
|
|
DEFAULT_GAME_EXE = DEFAULT_LIN_GAME_EXE
|
|
|
|
|
DEBUG_GAME_EXE = DEBUG_LIN_GAME_EXE
|
|
|
|
|
|
2026-01-02 18:12:41 -05:00
|
|
|
|
|
|
|
|
# Initial volume
|
|
|
|
|
VOLUME_STEP = 0.1 # step for W/S volume control
|
|
|
|
|
# ==========================================================
|
|
|
|
|
|
|
|
|
|
# ====================== GLOBAL STATE =====================
|
2026-01-06 21:39:09 -05:00
|
|
|
volumecheck = False
|
2026-01-20 20:37:48 -05:00
|
|
|
master_fd = None
|
2026-01-08 15:41:23 -05:00
|
|
|
gametypecheck = False
|
|
|
|
|
arenacheck = False
|
|
|
|
|
last_arena0 = "undefined"
|
|
|
|
|
is_ra3 = False
|
|
|
|
|
is_ra3_map = False
|
|
|
|
|
is_in_map = False
|
2026-01-04 23:59:47 -05:00
|
|
|
ra3_maps_path = "."
|
2026-01-10 16:43:48 -05:00
|
|
|
arena0_bl_path = "."
|
2026-01-02 18:12:41 -05:00
|
|
|
playlist = []
|
2026-01-10 16:43:48 -05:00
|
|
|
arena0_bl = []
|
2026-01-02 18:12:41 -05:00
|
|
|
playlist_index = 0
|
|
|
|
|
current_mode = "shuffle" # sequential, shuffle, loop
|
|
|
|
|
volume = 0.5
|
|
|
|
|
is_playing = False
|
|
|
|
|
stop_flag = threading.Event()
|
|
|
|
|
serverstatus_sent = False
|
|
|
|
|
current_map = "nomap"
|
|
|
|
|
current_song = "nosong"
|
|
|
|
|
playlist_path = "undefined"
|
|
|
|
|
ra3_maps = set()
|
|
|
|
|
song_queue = queue.Queue()
|
|
|
|
|
ANSI_ESCAPE_RE = re.compile(
|
|
|
|
|
r'''
|
|
|
|
|
\x1B(?:
|
2026-01-04 23:59:47 -05:00
|
|
|
[78] # ESC 7, ESC 8 (DEC save/restore)
|
2026-01-02 18:12:41 -05:00
|
|
|
| \[ [0-?]* [ -/]* [@-~] # CSI sequences
|
|
|
|
|
| \] .*? (?:\x07|\x1B\\) # OSC sequences
|
|
|
|
|
| [@-Z\\-_] # Other 7-bit C1 controls
|
|
|
|
|
)
|
2026-01-20 21:04:38 -05:00
|
|
|
| \x08\ \x08 # literal "\x08 \x08" sequence
|
2026-01-02 18:12:41 -05:00
|
|
|
''',
|
|
|
|
|
re.VERBOSE
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ==========================================================
|
|
|
|
|
|
|
|
|
|
# ===================== UTILITY FUNCTIONS =================
|
2026-01-22 01:05:38 -05:00
|
|
|
# Strip Quake-style color codes (^ followed by any character)
|
|
|
|
|
_QUAKE_COLOR_RE = re.compile(r"\^.")
|
|
|
|
|
|
|
|
|
|
def _strip_quake_colors(text):
|
|
|
|
|
return _QUAKE_COLOR_RE.sub("", text)
|
|
|
|
|
|
2026-01-21 22:06:07 -05:00
|
|
|
def find_pk3_subfolders(base_path):
|
|
|
|
|
"""
|
2026-01-22 01:05:38 -05:00
|
|
|
Returns a list of strings for each subfolder under base_path that contains
|
2026-01-21 22:06:07 -05:00
|
|
|
at least one .pk3 file.
|
2026-01-22 01:05:38 -05:00
|
|
|
|
|
|
|
|
The list always begins with:
|
|
|
|
|
baseq3\nQuake III Arena
|
|
|
|
|
|
|
|
|
|
Each subsequent entry is formatted as:
|
|
|
|
|
folder_name + "\n" + description
|
2026-01-21 22:06:07 -05:00
|
|
|
"""
|
|
|
|
|
base = Path(base_path)
|
2026-01-22 01:05:38 -05:00
|
|
|
|
|
|
|
|
# Always start with baseq3
|
|
|
|
|
results = ["baseq3\nQuake III Arena"]
|
2026-01-21 22:06:07 -05:00
|
|
|
|
|
|
|
|
if not base.is_dir():
|
2026-01-22 01:05:38 -05:00
|
|
|
return results
|
2026-01-21 22:06:07 -05:00
|
|
|
|
|
|
|
|
for entry in base.iterdir():
|
2026-01-22 01:05:38 -05:00
|
|
|
if not entry.is_dir():
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
folder_name = entry.name
|
|
|
|
|
folder_name_lower = folder_name.lower()
|
|
|
|
|
|
|
|
|
|
# Skip baseq3 during scan (already added)
|
|
|
|
|
if folder_name_lower == "baseq3":
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
# Check for at least one .pk3 file
|
|
|
|
|
has_pk3 = any(
|
|
|
|
|
f.is_file() and f.suffix.lower() == ".pk3"
|
|
|
|
|
for f in entry.iterdir()
|
|
|
|
|
)
|
2026-01-21 22:06:07 -05:00
|
|
|
|
2026-01-22 01:05:38 -05:00
|
|
|
if not has_pk3:
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
# Special-case missionpack
|
|
|
|
|
if folder_name_lower == "missionpack":
|
|
|
|
|
results.append(f"{folder_name}\nQuake III Team Arena")
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
# Description.txt handling
|
|
|
|
|
description_file = entry / "description.txt"
|
|
|
|
|
|
|
|
|
|
if description_file.is_file():
|
|
|
|
|
try:
|
|
|
|
|
with description_file.open("r", encoding="utf-8", errors="ignore") as f:
|
|
|
|
|
first_line = _strip_quake_colors(f.readline().strip())
|
|
|
|
|
if first_line:
|
|
|
|
|
results.append(f"{folder_name}\n{first_line}")
|
|
|
|
|
else:
|
|
|
|
|
results.append(f"{folder_name}\nNo description available")
|
|
|
|
|
except OSError:
|
|
|
|
|
results.append(f"{folder_name}\nNo description available")
|
|
|
|
|
else:
|
|
|
|
|
results.append(f"{folder_name}\nNo description available")
|
|
|
|
|
|
|
|
|
|
return results
|
2026-01-20 20:46:12 -05:00
|
|
|
|
2026-01-20 15:57:22 -05:00
|
|
|
def acquire_single_instance(lockfile_base: str):
|
|
|
|
|
"""
|
|
|
|
|
Ensures only one instance of the program is running per user.
|
|
|
|
|
Uses a lock file in the temp directory and psutil to check for stale locks.
|
|
|
|
|
Returns (file descriptor, lockfile path) if acquired, None if another instance is running.
|
|
|
|
|
"""
|
|
|
|
|
username = getpass.getuser()
|
|
|
|
|
lockfile_name = f"{lockfile_base}_{username}.lock"
|
|
|
|
|
lockfile_path = os.path.join(tempfile.gettempdir(), lockfile_name)
|
|
|
|
|
|
|
|
|
|
# If lock file exists, check if PID inside is running
|
|
|
|
|
if os.path.exists(lockfile_path):
|
|
|
|
|
try:
|
|
|
|
|
with open(lockfile_path, "r") as f:
|
|
|
|
|
pid = int(f.read())
|
|
|
|
|
if psutil.pid_exists(pid):
|
|
|
|
|
# Another instance is running
|
|
|
|
|
return None, lockfile_path
|
|
|
|
|
else:
|
|
|
|
|
# Stale lock file; remove it
|
|
|
|
|
os.unlink(lockfile_path)
|
|
|
|
|
except Exception:
|
|
|
|
|
# Could not read PID; remove stale lock
|
|
|
|
|
try:
|
|
|
|
|
os.unlink(lockfile_path)
|
|
|
|
|
except:
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
# Create lock file exclusively
|
|
|
|
|
try:
|
|
|
|
|
fd = os.open(lockfile_path, os.O_CREAT | os.O_EXCL | os.O_RDWR)
|
|
|
|
|
os.write(fd, str(os.getpid()).encode())
|
|
|
|
|
return fd, lockfile_path
|
|
|
|
|
except FileExistsError:
|
|
|
|
|
# Race condition: another process created it just now
|
|
|
|
|
return None, lockfile_path
|
|
|
|
|
|
|
|
|
|
def release_single_instance(fd, lockfile_path):
|
|
|
|
|
"""Closes the lock file and removes it."""
|
|
|
|
|
try:
|
|
|
|
|
os.close(fd)
|
|
|
|
|
except:
|
|
|
|
|
pass
|
|
|
|
|
try:
|
|
|
|
|
os.unlink(lockfile_path)
|
|
|
|
|
except:
|
|
|
|
|
pass
|
|
|
|
|
|
2026-01-10 16:43:48 -05:00
|
|
|
def Check_Arena_Blacklist(arenaline: str) -> bool:
|
|
|
|
|
global arena0_bl_path
|
|
|
|
|
arena_bl_local = load_arena0_blacklist(arena0_bl_path)
|
|
|
|
|
for item in arena_bl_local:
|
|
|
|
|
if arenaline.lower().startswith(f"\"arena0\" is:\"{item.lower()}"):
|
|
|
|
|
return True
|
|
|
|
|
return False
|
|
|
|
|
|
2026-01-02 18:12:41 -05:00
|
|
|
def strip_ansi(text: str) -> str:
|
|
|
|
|
return ANSI_ESCAPE_RE.sub("", text)
|
|
|
|
|
|
|
|
|
|
def playlist_watcher(path, poll_interval=1.0):
|
|
|
|
|
global playlist
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
last_mtime = os.path.getmtime(path)
|
|
|
|
|
except FileNotFoundError:
|
|
|
|
|
last_mtime = 0
|
|
|
|
|
|
|
|
|
|
while not stop_flag.is_set():
|
|
|
|
|
try:
|
|
|
|
|
current_mtime = os.path.getmtime(path)
|
|
|
|
|
if current_mtime != last_mtime:
|
|
|
|
|
last_mtime = current_mtime
|
|
|
|
|
print("[DEBUG] Playlist file changed. Reloading playlist.")
|
|
|
|
|
playlist = load_playlist(path)
|
|
|
|
|
except FileNotFoundError:
|
|
|
|
|
pass # Playlist temporarily missing; ignore
|
|
|
|
|
|
|
|
|
|
time.sleep(poll_interval)
|
|
|
|
|
|
|
|
|
|
def track_watcher():
|
|
|
|
|
global is_playing
|
|
|
|
|
last_busy = False
|
|
|
|
|
while not stop_flag.is_set():
|
|
|
|
|
busy = pygame.mixer.music.get_busy()
|
|
|
|
|
if last_busy and not busy and is_playing:
|
|
|
|
|
# Track just finished
|
|
|
|
|
print("[DEBUG] Track finished. Advancing to next track.")
|
|
|
|
|
next_track()
|
|
|
|
|
last_busy = busy
|
|
|
|
|
time.sleep(0.1)
|
|
|
|
|
|
|
|
|
|
def parse_music_volume(line: str) -> float:
|
|
|
|
|
# Remove all ^<digit> color codes
|
|
|
|
|
clean_line = re.sub(r'\^\d', '', line)
|
|
|
|
|
|
|
|
|
|
# Extract the number inside quotes after "is:"
|
|
|
|
|
match = re.search(r'is\s*:\s*"(.*?)"', clean_line, re.IGNORECASE)
|
|
|
|
|
if not match:
|
|
|
|
|
#raise ValueError(f"Could not parse volume from line: {line}")
|
|
|
|
|
value_str = 0
|
|
|
|
|
else:
|
|
|
|
|
value_str = match.group(1)
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
return float(value_str)
|
|
|
|
|
except ValueError:
|
|
|
|
|
raise ValueError(f"Invalid float value for volume: {value_str}")
|
|
|
|
|
|
|
|
|
|
def load_playlist(playlist_path):
|
|
|
|
|
global playlist_index
|
|
|
|
|
playlist_path = Path(playlist_path).resolve()
|
|
|
|
|
base_dir = playlist_path.parent
|
|
|
|
|
|
|
|
|
|
songs = []
|
2026-01-08 17:22:47 -05:00
|
|
|
try:
|
|
|
|
|
with open(playlist_path, "r", encoding="utf-8") as f:
|
|
|
|
|
for line in f:
|
|
|
|
|
line = line.strip()
|
|
|
|
|
if not line:
|
|
|
|
|
continue
|
|
|
|
|
if line.startswith("#"):
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
song_path = Path(line)
|
|
|
|
|
|
|
|
|
|
# If the playlist entry is relative, resolve it relative to playlist.txt
|
|
|
|
|
if not song_path.is_absolute():
|
|
|
|
|
song_path = base_dir / song_path
|
|
|
|
|
|
|
|
|
|
songs.append(str(song_path))
|
|
|
|
|
|
|
|
|
|
if current_mode == "shuffle":
|
|
|
|
|
random.shuffle(songs)
|
|
|
|
|
|
|
|
|
|
# Re-align song that's playing to new index
|
|
|
|
|
if current_song != "nosong":
|
|
|
|
|
_song_index = 0
|
|
|
|
|
_found_song = False
|
|
|
|
|
for s in songs:
|
|
|
|
|
_song_eval = Path(s).stem
|
|
|
|
|
if current_song == _song_eval:
|
|
|
|
|
_found_song = True
|
|
|
|
|
playlist_index = _song_index
|
|
|
|
|
break
|
|
|
|
|
_song_index += 1
|
|
|
|
|
if not _found_song:
|
|
|
|
|
playlist_index = 0
|
|
|
|
|
play_current()
|
|
|
|
|
except:
|
|
|
|
|
print(f"[DEBUG] Failed to open playlist.txt")
|
|
|
|
|
pass
|
2026-01-02 18:12:41 -05:00
|
|
|
|
|
|
|
|
return songs
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def load_ra3_maps(path):
|
|
|
|
|
maps = set()
|
2026-01-04 23:59:47 -05:00
|
|
|
try:
|
|
|
|
|
with open(path, "r", encoding="utf-8") as f:
|
|
|
|
|
for line in f:
|
|
|
|
|
line = line.strip()
|
|
|
|
|
if line:
|
|
|
|
|
maps.add(line.lower())
|
2026-01-08 17:22:47 -05:00
|
|
|
except:
|
|
|
|
|
print(f"[DEBUG] Failed to open ra3_maps.txt")
|
2026-01-04 23:59:47 -05:00
|
|
|
return set()
|
2026-01-02 18:12:41 -05:00
|
|
|
return maps
|
|
|
|
|
|
2026-01-10 16:43:48 -05:00
|
|
|
def load_arena0_blacklist(path):
|
|
|
|
|
maps = set()
|
|
|
|
|
try:
|
|
|
|
|
with open(path, "r", encoding="utf-8") as f:
|
|
|
|
|
for line in f:
|
|
|
|
|
line = line.strip()
|
|
|
|
|
if line:
|
|
|
|
|
maps.add(line.lower())
|
|
|
|
|
except:
|
|
|
|
|
print(f"[DEBUG] Failed to open ra3_maps.txt")
|
|
|
|
|
return set()
|
|
|
|
|
return maps
|
|
|
|
|
|
2026-01-02 18:12:41 -05:00
|
|
|
def next_track():
|
2026-01-08 17:22:47 -05:00
|
|
|
global playlist
|
|
|
|
|
if not playlist:
|
|
|
|
|
return
|
|
|
|
|
|
2026-01-06 21:39:09 -05:00
|
|
|
global volumecheck
|
|
|
|
|
volumecheck = True
|
2026-01-02 18:12:41 -05:00
|
|
|
send_command(pty_proc,"s_musicvolume")
|
|
|
|
|
global playlist_index
|
|
|
|
|
if current_mode == "loop":
|
|
|
|
|
pass # keep same index
|
|
|
|
|
else:
|
|
|
|
|
playlist_index = (playlist_index + 1) % len(playlist)
|
|
|
|
|
play_current()
|
|
|
|
|
|
|
|
|
|
def previous_track():
|
2026-01-08 17:22:47 -05:00
|
|
|
global playlist
|
|
|
|
|
if not playlist:
|
|
|
|
|
return
|
|
|
|
|
|
2026-01-06 21:39:09 -05:00
|
|
|
global volumecheck
|
|
|
|
|
volumecheck = True
|
2026-01-02 18:12:41 -05:00
|
|
|
send_command(pty_proc,"s_musicVolume")
|
|
|
|
|
global playlist_index
|
|
|
|
|
if current_mode == "loop":
|
|
|
|
|
pass # keep same index
|
|
|
|
|
else:
|
|
|
|
|
playlist_index = (playlist_index - 1) % len(playlist)
|
|
|
|
|
play_current()
|
|
|
|
|
|
|
|
|
|
def play_current():
|
2026-01-08 17:22:47 -05:00
|
|
|
global is_playing, current_song, playlist
|
2026-01-02 18:12:41 -05:00
|
|
|
if not playlist:
|
|
|
|
|
return
|
|
|
|
|
track = playlist[playlist_index]
|
|
|
|
|
if not os.path.isfile(track):
|
|
|
|
|
print(f"[DEBUG] Track not found: {track}")
|
|
|
|
|
return
|
|
|
|
|
try:
|
|
|
|
|
pygame.mixer.music.load(track)
|
|
|
|
|
pygame.mixer.music.set_volume(volume)
|
|
|
|
|
pygame.mixer.music.play()
|
|
|
|
|
is_playing = True
|
|
|
|
|
current_song = Path(track).stem
|
|
|
|
|
print(f"[DEBUG] Playing: {current_song} at volume {volume}")
|
2026-01-06 21:39:09 -05:00
|
|
|
threading.Timer(.1, lambda: send_command(pty_proc,f"echo\r\necho\r\necho\r\necho Playing: {current_song}")).start()
|
2026-01-02 18:12:41 -05:00
|
|
|
except Exception as e:
|
|
|
|
|
print(f"[DEBUG] Couldn't start MP3 player': {e}")
|
|
|
|
|
|
|
|
|
|
def stop_playback():
|
|
|
|
|
global is_playing
|
|
|
|
|
pygame.mixer.music.stop()
|
|
|
|
|
is_playing = False
|
|
|
|
|
|
|
|
|
|
def toggle_pause():
|
2026-01-08 17:22:47 -05:00
|
|
|
global playlist
|
|
|
|
|
if not playlist:
|
|
|
|
|
return
|
|
|
|
|
|
2026-01-06 21:39:09 -05:00
|
|
|
global volumecheck
|
|
|
|
|
volumecheck = True
|
2026-01-02 18:12:41 -05:00
|
|
|
send_command(pty_proc,"s_musicVolume")
|
|
|
|
|
global is_playing
|
|
|
|
|
if pygame.mixer.music.get_busy():
|
|
|
|
|
pygame.mixer.music.pause()
|
|
|
|
|
is_playing = False
|
|
|
|
|
else:
|
|
|
|
|
pygame.mixer.music.unpause()
|
|
|
|
|
is_playing = True
|
|
|
|
|
|
|
|
|
|
def change_mode():
|
|
|
|
|
global current_mode, playlist_path, playlist
|
|
|
|
|
|
|
|
|
|
modes = ["sequential", "shuffle", "loop"]
|
|
|
|
|
idx = modes.index(current_mode)
|
|
|
|
|
current_mode = modes[(idx + 1) % len(modes)]
|
|
|
|
|
print(f"[DEBUG] Mode changed to: {current_mode}")
|
|
|
|
|
send_command(pty_proc,f"echo _musicmode {current_mode}")
|
|
|
|
|
playlist = load_playlist(playlist_path)
|
|
|
|
|
|
2026-01-20 20:31:57 -05:00
|
|
|
def monitor_game_pty(master_fd):
|
|
|
|
|
buffer = b""
|
|
|
|
|
|
|
|
|
|
while not stop_flag.is_set():
|
|
|
|
|
try:
|
|
|
|
|
data = os.read(master_fd, 1024)
|
|
|
|
|
except OSError:
|
|
|
|
|
break
|
|
|
|
|
|
|
|
|
|
if not data:
|
|
|
|
|
break
|
|
|
|
|
|
|
|
|
|
buffer += data
|
|
|
|
|
|
|
|
|
|
while b"\n" in buffer:
|
|
|
|
|
line, buffer = buffer.split(b"\n", 1)
|
2026-01-20 20:59:52 -05:00
|
|
|
#if os.name != "nt":
|
|
|
|
|
# line = apply_backspaces(line)
|
2026-01-20 20:31:57 -05:00
|
|
|
line = strip_ansi(line.decode(errors="ignore")).strip()
|
|
|
|
|
if line:
|
2026-01-20 21:10:54 -05:00
|
|
|
if line.startswith("]"):
|
|
|
|
|
line = line[1:]
|
2026-01-20 20:31:57 -05:00
|
|
|
if "--debug" in sys.argv:
|
|
|
|
|
print(f"[GAME] {line}", flush=True)
|
|
|
|
|
if "--rawdebug" in sys.argv:
|
|
|
|
|
print(f"[GAME RAW] {repr(line)}", flush=True)
|
|
|
|
|
handle_game_line(line, pty_proc)
|
|
|
|
|
|
2026-01-10 16:43:48 -05:00
|
|
|
def monitor_game(proc):
|
|
|
|
|
#subprocess version
|
|
|
|
|
global serverstatus_sent
|
|
|
|
|
buffer = b""
|
|
|
|
|
|
|
|
|
|
while not stop_flag.is_set():
|
|
|
|
|
data = proc.stdout.read(1024)
|
|
|
|
|
if not data:
|
2026-01-02 18:12:41 -05:00
|
|
|
break
|
|
|
|
|
|
2026-01-10 16:43:48 -05:00
|
|
|
buffer += data
|
|
|
|
|
|
|
|
|
|
while b"\n" in buffer:
|
|
|
|
|
line, buffer = buffer.split(b"\n", 1)
|
|
|
|
|
try:
|
|
|
|
|
line = line.decode(errors="ignore")
|
|
|
|
|
except:
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
line = strip_ansi(line).strip()
|
|
|
|
|
if line:
|
2026-01-20 20:14:29 -05:00
|
|
|
if any(arg.lower() == "--debug" for arg in sys.argv):
|
|
|
|
|
print(f"[GAME] {line}")
|
|
|
|
|
if any(arg.lower() == "--rawdebug" for arg in sys.argv):
|
|
|
|
|
print(f"[GAME RAW] {repr(line)}")
|
2026-01-10 16:43:48 -05:00
|
|
|
handle_game_line(line, proc)
|
|
|
|
|
|
|
|
|
|
|
2026-01-02 18:12:41 -05:00
|
|
|
def handle_game_line(line, pty_proc):
|
2026-01-08 15:41:23 -05:00
|
|
|
global volume, current_map, ra3_maps, ra3_maps_path, volumecheck, is_ra3, gametypecheck, is_playing, arenacheck, is_in_map, is_ra3_map, last_arena0
|
|
|
|
|
global serverstatus_sent
|
|
|
|
|
|
|
|
|
|
if is_ra3 == False or is_in_map == False: is_ra3_map = False
|
|
|
|
|
|
2026-01-20 20:14:29 -05:00
|
|
|
|
|
|
|
|
|
2026-01-08 15:41:23 -05:00
|
|
|
#Loading vm file vm/ui.qvm...
|
|
|
|
|
#if "Sound initialization successful." in line:
|
|
|
|
|
if "Loading vm file vm/ui.qvm..." in line:
|
|
|
|
|
gametypecheck = True
|
|
|
|
|
is_in_map = False
|
|
|
|
|
threading.Timer(.3, lambda: send_command(pty_proc,"fs_game")).start()
|
|
|
|
|
elif "\"fs_game\" is:\"" in line and gametypecheck:
|
|
|
|
|
gametypecheck = False
|
|
|
|
|
if "\"fs_game\" is:\"arena" in line:
|
|
|
|
|
if is_ra3 == False:
|
|
|
|
|
print(f"[DEBUG] Active mod is Rocket Arena 3 - enabling music playback")
|
|
|
|
|
is_ra3 = True
|
|
|
|
|
volumecheck = True
|
2026-01-08 17:22:47 -05:00
|
|
|
threading.Timer(.5, lambda: send_command(pty_proc,"s_musicVolume")).start()
|
|
|
|
|
if playlist:
|
|
|
|
|
if current_mode == "shuffle":
|
|
|
|
|
global playlist_index
|
|
|
|
|
playlist_index = random.randint(0, len(playlist) - 1)
|
|
|
|
|
threading.Timer(1.0, play_current).start()
|
2026-01-08 15:41:23 -05:00
|
|
|
else:
|
|
|
|
|
if not is_playing:
|
|
|
|
|
next_track()
|
2026-01-08 17:22:47 -05:00
|
|
|
threading.Timer(.2, lambda: send_command(pty_proc,"bind [ echo ]\\prevtrack\r\nbind ] echo ]\\nexttrack\r\nbind \\ echo ]\\musicmode\r\nbind ' echo ]\\pausetrack")).start()
|
2026-01-02 18:12:41 -05:00
|
|
|
else:
|
2026-01-08 17:22:47 -05:00
|
|
|
#print(f"[DEBUG] Active mod is NOT Rocket Arena 3 - disabling music playback")
|
2026-01-08 15:41:23 -05:00
|
|
|
is_ra3 = False
|
|
|
|
|
if is_playing:
|
|
|
|
|
stop_playback()
|
|
|
|
|
elif line.startswith("]\\showvars"):
|
|
|
|
|
threading.Timer(.1, lambda: send_command(pty_proc,f"echo is_ra3 = {is_ra3}\r\necho is_in_map = {is_in_map}\r\necho is_ra3_map = {is_ra3_map}")).start()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if is_ra3:
|
|
|
|
|
if line.startswith("\"s_musicVolume\"") and volumecheck == True:
|
|
|
|
|
volumecheck = False
|
|
|
|
|
svolume = parse_music_volume(line)
|
|
|
|
|
if svolume > 0:
|
|
|
|
|
if volume != svolume:
|
|
|
|
|
volume = svolume
|
|
|
|
|
pygame.mixer.music.set_volume(volume)
|
|
|
|
|
print(f"[DEBUG] Set music volume to {volume} by game client.")
|
|
|
|
|
|
|
|
|
|
elif line.startswith("]\\nexttrack"):
|
|
|
|
|
if (is_in_map and is_ra3_map) or (is_in_map == False):
|
|
|
|
|
next_track()
|
|
|
|
|
else:
|
|
|
|
|
threading.Timer(.1, lambda: send_command(pty_proc,f"echo Music controls only available for RA3 maps.")).start()
|
|
|
|
|
elif line.startswith("]\\prevtrack"):
|
|
|
|
|
if (is_in_map and is_ra3_map) or (is_in_map == False):
|
|
|
|
|
previous_track()
|
|
|
|
|
else:
|
|
|
|
|
threading.Timer(.1, lambda: send_command(pty_proc,f"echo Music controls only available for RA3 maps.")).start()
|
|
|
|
|
elif line.startswith("]\\pausetrack"):
|
|
|
|
|
if (is_in_map and is_ra3_map) or (is_in_map == False):
|
|
|
|
|
toggle_pause()
|
|
|
|
|
else:
|
|
|
|
|
threading.Timer(.1, lambda: send_command(pty_proc,f"echo Music controls only available for RA3 maps.")).start()
|
|
|
|
|
elif line.startswith("]\\musicmode"):
|
|
|
|
|
if (is_in_map and is_ra3_map) or (is_in_map == False):
|
|
|
|
|
change_mode()
|
|
|
|
|
else:
|
|
|
|
|
threading.Timer(.1, lambda: send_command(pty_proc,f"echo Music controls only available for RA3 maps.")).start()
|
|
|
|
|
elif line.startswith("Com_TouchMemory:"):
|
|
|
|
|
is_in_map = True
|
|
|
|
|
arenacheck = True
|
|
|
|
|
threading.Timer(.5, lambda: send_command(pty_proc,f"arena0")).start()
|
|
|
|
|
elif "\"arena0\" is:" in line and arenacheck:
|
|
|
|
|
if line == last_arena0:
|
|
|
|
|
same_map = True
|
|
|
|
|
else:
|
|
|
|
|
same_map = False
|
|
|
|
|
last_arena0 = line
|
|
|
|
|
arenacheck = False
|
|
|
|
|
if line.startswith("\"arena0\" is:\"Arena Number 1"):
|
|
|
|
|
print(f"[DEBUG] Not an RA3 map. Checking for override.")
|
|
|
|
|
serverstatus_sent = True
|
|
|
|
|
threading.Timer(.1, lambda: send_command(pty_proc,f"serverstatus")).start()
|
|
|
|
|
else:
|
|
|
|
|
if not same_map:
|
2026-01-10 16:43:48 -05:00
|
|
|
if not Check_Arena_Blacklist(line):
|
|
|
|
|
print(f"[DEBUG] RA3 map detected. Advancing track.")
|
|
|
|
|
is_ra3_map = True
|
|
|
|
|
next_track()
|
|
|
|
|
else:
|
|
|
|
|
print(f"[DEBUG] RA3 Arena found on blacklist - disabling music.")
|
|
|
|
|
is_ra3_map = False
|
|
|
|
|
stop_playback()
|
|
|
|
|
|
2026-01-08 15:41:23 -05:00
|
|
|
elif "mapname" in line.lower() and serverstatus_sent:
|
|
|
|
|
serverstatus_sent = False
|
|
|
|
|
current_map = line.split()[-1].lower()
|
|
|
|
|
ra3_maps = load_ra3_maps(ra3_maps_path)
|
|
|
|
|
if current_map in ra3_maps:
|
|
|
|
|
print(f"[DEBUG] Found override for: {current_map}. Advancing track.")
|
|
|
|
|
is_ra3_map = True
|
|
|
|
|
next_track()
|
|
|
|
|
else:
|
|
|
|
|
print(f"[DEBUG] Unknown map: {current_map}. Stopping playback.")
|
|
|
|
|
is_ra3_map = False
|
2026-01-10 16:43:48 -05:00
|
|
|
threading.Timer(.3, lambda: stop_playback()).start()
|
|
|
|
|
#stop_playback()
|
|
|
|
|
|
|
|
|
|
def send_command(proc, cmd):
|
2026-01-20 20:37:48 -05:00
|
|
|
global master_fd
|
2026-01-10 16:43:48 -05:00
|
|
|
#subprocess version
|
2026-01-02 18:12:41 -05:00
|
|
|
try:
|
2026-01-20 20:31:57 -05:00
|
|
|
if os.name == "nt":
|
|
|
|
|
proc.stdin.write((cmd + "\r\n").encode())
|
|
|
|
|
proc.stdin.flush()
|
|
|
|
|
else:
|
|
|
|
|
os.write(master_fd, (cmd + "\n").encode())
|
2026-01-02 18:12:41 -05:00
|
|
|
except Exception as e:
|
|
|
|
|
print(f"[DEBUG] Failed to send command: {e}")
|
|
|
|
|
# ==========================================================
|
|
|
|
|
|
|
|
|
|
# ======================== MAIN ===========================
|
2026-01-22 15:06:25 -05:00
|
|
|
def game_launch(gameargs):
|
2026-01-20 20:37:48 -05:00
|
|
|
global playlist, ra3_maps, pty_proc, playlist_path, ra3_maps_path, arena0_blacklist, arena0_bl_path, master_fd
|
2026-01-02 18:12:41 -05:00
|
|
|
|
|
|
|
|
# Use debug paths if enabled
|
|
|
|
|
game_exe = DEBUG_GAME_EXE if DEBUG else DEFAULT_GAME_EXE
|
|
|
|
|
playlist_path = DEBUG_PLAYLIST if DEBUG else DEFAULT_PLAYLIST
|
|
|
|
|
ra3_maps_path = DEBUG_RA3_MAPS if DEBUG else DEFAULT_RA3_MAPS
|
2026-01-10 16:43:48 -05:00
|
|
|
arena0_bl_path = DEBUG_ARENA0_BL if DEBUG else DEFAULT_ARENA0_BL
|
|
|
|
|
|
2026-01-02 18:12:41 -05:00
|
|
|
# Load playlist and map list
|
|
|
|
|
playlist = load_playlist(playlist_path)
|
|
|
|
|
ra3_maps = load_ra3_maps(ra3_maps_path)
|
2026-01-10 16:43:48 -05:00
|
|
|
arena0_bl = load_arena0_blacklist(arena0_bl_path)
|
2026-01-02 18:12:41 -05:00
|
|
|
|
|
|
|
|
# Initialize pygame mixer
|
|
|
|
|
pygame.mixer.init()
|
|
|
|
|
|
|
|
|
|
# Start playlist watcher thread
|
2026-01-22 22:05:31 -05:00
|
|
|
playlist_thread = threading.Thread(target=playlist_watcher,args=(playlist_path,))
|
2026-01-02 18:12:41 -05:00
|
|
|
playlist_thread.start()
|
|
|
|
|
|
|
|
|
|
# Start track watcher thread
|
2026-01-22 22:05:31 -05:00
|
|
|
watcher_thread = threading.Thread(target=track_watcher)
|
2026-01-02 18:12:41 -05:00
|
|
|
watcher_thread.start()
|
2026-01-10 17:22:43 -05:00
|
|
|
|
2026-01-22 01:05:38 -05:00
|
|
|
chosen_mod = None
|
|
|
|
|
run_mod = []
|
|
|
|
|
|
2026-01-22 15:06:25 -05:00
|
|
|
run_mod = ["+set", "fs_game", chosen_mod]
|
2026-01-22 14:16:31 -05:00
|
|
|
|
2026-01-20 20:31:57 -05:00
|
|
|
if os.name == "nt":
|
|
|
|
|
pty_proc = subprocess.Popen(
|
2026-01-22 15:06:25 -05:00
|
|
|
[game_exe, "+set", "ttycon", "1"] + gameargs,
|
2026-01-20 20:31:57 -05:00
|
|
|
stdin=subprocess.PIPE,
|
|
|
|
|
stdout=subprocess.PIPE,
|
|
|
|
|
stderr=subprocess.STDOUT,
|
|
|
|
|
bufsize=0,
|
2026-01-22 15:06:25 -05:00
|
|
|
universal_newlines=False,
|
|
|
|
|
creationflags=subprocess.CREATE_NO_WINDOW
|
2026-01-20 20:31:57 -05:00
|
|
|
)
|
|
|
|
|
master_fd = None
|
2026-01-22 20:11:47 -05:00
|
|
|
|
|
|
|
|
# Start monitoring in background thread
|
2026-01-22 22:05:31 -05:00
|
|
|
monitor_thread = threading.Thread(target=monitor_game, args=(pty_proc,))
|
2026-01-22 20:11:47 -05:00
|
|
|
monitor_thread.start()
|
|
|
|
|
|
2026-01-20 20:31:57 -05:00
|
|
|
else:
|
2026-01-22 22:05:31 -05:00
|
|
|
os.environ['TERM'] = 'xterm' # I can't believe this fixed tty console output in linux
|
2026-01-20 20:31:57 -05:00
|
|
|
master_fd, slave_fd = pty.openpty()
|
|
|
|
|
|
|
|
|
|
pty_proc = subprocess.Popen(
|
2026-01-22 15:06:25 -05:00
|
|
|
[game_exe, "+set", "ttycon", "1"] + gameargs,
|
2026-01-20 20:31:57 -05:00
|
|
|
stdin=slave_fd,
|
|
|
|
|
stdout=slave_fd,
|
|
|
|
|
stderr=slave_fd,
|
2026-01-22 20:11:47 -05:00
|
|
|
close_fds=True
|
2026-01-20 20:31:57 -05:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
os.close(slave_fd)
|
2026-01-02 18:12:41 -05:00
|
|
|
|
2026-01-22 20:11:47 -05:00
|
|
|
# Start monitoring in background thread
|
2026-01-22 22:05:31 -05:00
|
|
|
monitor_thread = threading.Thread(target=monitor_game_pty, args=(master_fd,))
|
2026-01-22 20:11:47 -05:00
|
|
|
monitor_thread.start()
|
|
|
|
|
|
2026-01-02 18:12:41 -05:00
|
|
|
# Monitor the game output
|
|
|
|
|
try:
|
2026-01-22 20:11:47 -05:00
|
|
|
while True:
|
|
|
|
|
# Check if the game process is still running
|
|
|
|
|
if pty_proc.poll() is not None:
|
|
|
|
|
# Process has ended
|
|
|
|
|
break
|
|
|
|
|
|
|
|
|
|
# Small sleep to prevent busy waiting
|
|
|
|
|
time.sleep(0.1)
|
2026-01-22 22:05:31 -05:00
|
|
|
|
2026-01-02 18:12:41 -05:00
|
|
|
except KeyboardInterrupt:
|
|
|
|
|
print("Exiting...")
|
|
|
|
|
finally:
|
|
|
|
|
stop_flag.set()
|
|
|
|
|
stop_playback()
|
2026-01-10 16:43:48 -05:00
|
|
|
if pty_proc:
|
|
|
|
|
try:
|
|
|
|
|
pty_proc.terminate()
|
|
|
|
|
pty_proc.wait(timeout=5)
|
|
|
|
|
except:
|
|
|
|
|
pass
|
2026-01-02 18:12:41 -05:00
|
|
|
pygame.mixer.quit()
|
|
|
|
|
|
2026-01-22 15:06:25 -05:00
|
|
|
def main():
|
|
|
|
|
global playlist, ra3_maps, pty_proc, playlist_path, ra3_maps_path, arena0_blacklist, arena0_bl_path, master_fd
|
|
|
|
|
|
2026-01-22 20:24:19 -05:00
|
|
|
# Linux-specific: Handle file manager launch
|
|
|
|
|
if os.name != 'nt' and not sys.stdout.isatty():
|
|
|
|
|
# Redirect stdin/stdout/stderr to prevent issues when launched from file manager
|
|
|
|
|
import subprocess
|
|
|
|
|
sys.stdin = open('/dev/null', 'r')
|
|
|
|
|
sys.stdout = open('/dev/null', 'w')
|
|
|
|
|
sys.stderr = open('/dev/null', 'w')
|
|
|
|
|
|
|
|
|
|
# Create a new session to detach from the file manager
|
|
|
|
|
try:
|
|
|
|
|
os.setsid()
|
|
|
|
|
except OSError:
|
|
|
|
|
pass # Already a session leader
|
|
|
|
|
|
2026-01-22 15:06:25 -05:00
|
|
|
game_exe = DEBUG_GAME_EXE if DEBUG else DEFAULT_GAME_EXE
|
|
|
|
|
|
|
|
|
|
chosen_mod = None
|
|
|
|
|
run_mod = []
|
|
|
|
|
|
|
|
|
|
if "fs_game" not in sys.argv[1:]:
|
|
|
|
|
game_path = Path(game_exe)
|
|
|
|
|
items = find_pk3_subfolders(game_path.parent)
|
|
|
|
|
|
|
|
|
|
# Create a queue to get the result from the menu thread
|
|
|
|
|
menu_result_queue = queue.Queue()
|
|
|
|
|
|
|
|
|
|
def run_menu():
|
|
|
|
|
menu = TextMenu(
|
|
|
|
|
items,
|
|
|
|
|
width=500,
|
|
|
|
|
height=400,
|
|
|
|
|
title="Quake III Arena mod loader menu",
|
|
|
|
|
border_fg="red",
|
|
|
|
|
border_bg="black",
|
|
|
|
|
inside_fg="dark red",
|
|
|
|
|
inside_bg="black",
|
|
|
|
|
selected_fg="black",
|
|
|
|
|
selected_bg="red",
|
|
|
|
|
timeout=10
|
|
|
|
|
)
|
|
|
|
|
result = menu.show()
|
|
|
|
|
menu_result_queue.put(result)
|
|
|
|
|
|
|
|
|
|
# Run the menu in a separate thread
|
2026-01-22 19:25:16 -05:00
|
|
|
#menu_thread = threading.Thread(target=run_menu)
|
|
|
|
|
#menu_thread.start()
|
|
|
|
|
#menu_thread.join() # Wait for menu to complete
|
|
|
|
|
run_menu()
|
2026-01-22 15:06:25 -05:00
|
|
|
|
|
|
|
|
# Get the result from the queue
|
|
|
|
|
choice = menu_result_queue.get()
|
|
|
|
|
|
|
|
|
|
if choice == None:
|
|
|
|
|
sys.exit(0)
|
|
|
|
|
if choice == "errmenutimeout":
|
|
|
|
|
chosen_mod = "baseq3"
|
|
|
|
|
else:
|
|
|
|
|
chosen_mod = choice.split("\n", 1)[0]
|
|
|
|
|
|
|
|
|
|
run_mod = ["+set", "fs_game", chosen_mod]
|
|
|
|
|
|
|
|
|
|
#sys.exit(0)
|
|
|
|
|
|
|
|
|
|
game_args = run_mod + sys.argv[1:]
|
|
|
|
|
|
|
|
|
|
# Start game launcher thread
|
2026-01-22 19:16:56 -05:00
|
|
|
#game_launch_thread = threading.Thread(target=game_launch,args=(game_args,))
|
|
|
|
|
#game_launch_thread.start()
|
|
|
|
|
#game_launch_thread.join()
|
|
|
|
|
|
|
|
|
|
game_launch(game_args)
|
2026-01-22 15:06:25 -05:00
|
|
|
|
2026-01-02 18:12:41 -05:00
|
|
|
if __name__ == "__main__":
|
2026-01-20 15:57:22 -05:00
|
|
|
lock_fd, lock_path = acquire_single_instance("q3a_launcher.lock")
|
|
|
|
|
if lock_fd is None:
|
|
|
|
|
print("Another instance is already running.")
|
|
|
|
|
sys.exit(1)
|
|
|
|
|
try:
|
|
|
|
|
main()
|
|
|
|
|
finally:
|
|
|
|
|
release_single_instance(lock_fd, lock_path)
|
|
|
|
|
|
2026-01-02 18:12:41 -05:00
|
|
|
# ==========================================================
|