using System; using System.Collections.Generic; using System.Collections.ObjectModel; using System.IO; using System.Linq; using System.Runtime.InteropServices; using System.Text.Json; using System.Diagnostics; using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using SlamCross.Models; using SlamCross.Services; namespace SlamCross.ViewModels; public partial class TrackItem : ObservableObject { [ObservableProperty] private bool _isPlaying; public string Name { get; set; } = ""; public string Hotkey { get; set; } = ""; public string Volume { get; set; } = ""; public string Trimmed { get; set; } = ""; public string Tags { get; set; } = ""; public int Index { get; set; } } public partial class MainWindowViewModel : ViewModelBase { private readonly SettingsService _settings = new(); private readonly IAudioService _audio = AudioServiceFactory.Create(); private SourceGame _game = null!; private string? _steamAppsPath; private CancellationTokenSource? _pollCts; [ObservableProperty] private ObservableCollection _tracks = new(); [ObservableProperty] private ObservableCollection _outputDevices = new(); [ObservableProperty] private ObservableCollection _inputDevices = new(); [ObservableProperty] private AudioDevice? _selectedVirtualInCable; [ObservableProperty] private AudioDevice? _selectedVirtualOutCable; [ObservableProperty] private AudioDevice? _selectedRegularMic; [ObservableProperty] private string _statusText = "Status: Idle."; [ObservableProperty] private bool _isRunning; [ObservableProperty] private bool _interfaceEnabled = true; [ObservableProperty] private double _progress; [ObservableProperty] private double _progressMax = 1; [ObservableProperty] private string _startButtonText = "Start"; [ObservableProperty] private string _playKeyButtonText = "Play key: (change)"; [ObservableProperty] private bool _isStreaming; [ObservableProperty] private string _streamButtonText = "Stream to Mic"; [ObservableProperty] private string _streamStatusText = ""; [ObservableProperty] private int _globalVolume = 50; public AppSettings Settings => _settings.Settings; public MainWindowViewModel() { _settings.Load(); _globalVolume = Settings.GlobalVolume; InitGame(); LoadAudioDevices(); RefreshTracks(); RefreshPlayKey(); if (Settings.StartEnabled) _ = StartPollAsync(); } partial void OnGlobalVolumeChanged(int value) { Settings.GlobalVolume = value; _settings.Save(); } private void InitGame() { var sep = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? '\\' : '/'; _game = new SourceGame { Name = "Counter-Strike 2", Id = 730, Directory = $"common{sep}Counter-Strike Global Offensive{sep}", ToCfg = $"game{sep}csgo{sep}cfg{sep}", LibraryName = $"game{sep}csgo{sep}", ExeName = "cs2", SampleRate = 22050, VoiceFadeOut = false }; _game.Blacklist.AddRange(new[] { "attack","attack2","autobuy","back","buy","buyammo1","buyammo2","buymenu","callvote", "cancelselect","cheer","compliment","coverme","drop","duck","enemydown","enemyspot", "fallback","followme","forward","getout","go","holdpos","inposition","invnext","invprev", "jump","lastinv","messagemode","messagemode2","moveleft","moveright","mute","negative", "quit","radio1","radio2","radio3","rebuy","regroup","reload","reporting_in", "reporting_in_team_only","roger","sectorclear","showscores","slot1","slot10","slot2", "slot3","slot4","slot5","slot6","slot7","slot8","slot9","speed","sticktog","takepoint", "takingfire","teammenu","thanks","toggleconsole","use","voicerecord", "say","say_team","exec","echo","alias","bind","unbind","unbindall","kill","explode", "disconnect","retry","status","map","volume","stop","play","record","screenshot", "host_writeconfig","voice_loopback","con_enable","clear","help","find" }); } private void LoadAudioDevices() { OutputDevices.Clear(); InputDevices.Clear(); foreach (var d in _audio.GetOutputDevices()) OutputDevices.Add(d); foreach (var d in _audio.GetInputDevices()) InputDevices.Add(d); if (Settings.VirtualMicInDeviceIndex >= 0 && Settings.VirtualMicInDeviceIndex < OutputDevices.Count) SelectedVirtualInCable = OutputDevices[Settings.VirtualMicInDeviceIndex]; if (Settings.VirtualMicOutDeviceIndex >= 0 && Settings.VirtualMicOutDeviceIndex < InputDevices.Count) SelectedVirtualOutCable = InputDevices[Settings.VirtualMicOutDeviceIndex]; if (Settings.RegularMicDeviceIndex >= 0 && Settings.RegularMicDeviceIndex < InputDevices.Count) SelectedRegularMic = InputDevices[Settings.RegularMicDeviceIndex]; } partial void OnSelectedVirtualInCableChanged(AudioDevice? value) { if (value == null) return; Settings.VirtualMicInDeviceIndex = value.Index; Settings.VirtualMicInDeviceName = value.Name; Settings.VirtualMicInPactlId = value.PactlId; _settings.Save(); } partial void OnSelectedVirtualOutCableChanged(AudioDevice? value) { if (value == null) return; Settings.VirtualMicOutDeviceIndex = value.Index; Settings.VirtualMicOutDeviceName = value.Name; Settings.VirtualMicOutPactlId = value.PactlId; _settings.Save(); } partial void OnSelectedRegularMicChanged(AudioDevice? value) { if (value == null) return; Settings.RegularMicDeviceIndex = value.Index; Settings.RegularMicDeviceName = value.Name; Settings.RegularMicPactlId = value.PactlId; _settings.Save(); } public void RefreshTracks() { if (!Directory.Exists(_game.LibraryName)) Directory.CreateDirectory(_game.LibraryName); _game.Tracks.Clear(); foreach (var f in Directory.GetFiles(_game.LibraryName)) { if (Path.GetExtension(f) == _game.FileExtension) _game.Tracks.Add(new Track { Name = Path.GetFileNameWithoutExtension(f) }); } CreateTags(_game); TrackSettingsService.Load(_game); TrackSettingsService.Save(_game); RebuildTrackList(); } private void RebuildTrackList() { Tracks.Clear(); for (int i = 0; i < _game.Tracks.Count; i++) { var t = _game.Tracks[i]; Tracks.Add(new TrackItem { Index = i, Name = t.Name, Hotkey = t.Hotkey, Volume = t.Volume + "%", Trimmed = t.EndPos > 0 ? "Yes" : "", Tags = t.Tags.Count > 0 ? "\"" + string.Join("\", \"", t.Tags) + "\"" : "" }); } } public async Task StartPollAsync() { IsRunning = true; StartButtonText = "Stop"; InterfaceEnabled = false; _pollCts = new CancellationTokenSource(); try { await Task.Run(() => PollLoop(_pollCts.Token)); } catch (OperationCanceledException) { } catch (Exception ex) { ShowError(ex.Message); } finally { IsRunning = false; StartButtonText = "Start"; InterfaceEnabled = true; StatusText = "Status: Idle."; RebuildTrackList(); } } public void StopPoll() { _pollCts?.Cancel(); _audio.Stop(); } private void PollLoop(CancellationToken ct) { StatusText = "Status: Searching..."; if (!Settings.OverrideFolders) { while (!ct.IsCancellationRequested) { _steamAppsPath = GameLocatorService.FindSteamAppsPath(_game.ExeName); if (!string.IsNullOrEmpty(_steamAppsPath) && Directory.Exists(_steamAppsPath)) break; Thread.Sleep(_game.PollInterval); } } else { _steamAppsPath = Settings.SteamAppsOverride; if (!Directory.Exists(_steamAppsPath)) throw new Exception("Steam apps folder does not exist. Check Settings."); } ct.ThrowIfCancellationRequested(); CfgService.WriteCfgFiles(_game, _steamAppsPath!, _settings.Settings); StatusText = "Status: Working."; var virtualMicOutPactl = string.IsNullOrEmpty(Settings.VirtualMicOutPactlId) ? "VirtualMic.monitor" : Settings.VirtualMicOutPactlId; var cs2SrcOutputIds = FindCs2SourceOutputIds(); DebugLog($"CS2 pactl source-outputs: [{string.Join(",", cs2SrcOutputIds)}] moving to {virtualMicOutPactl}"); MoveCs2SourceOutputs(cs2SrcOutputIds, virtualMicOutPactl); var userDataDir = Settings.OverrideFolders && !string.IsNullOrEmpty(Settings.UserDataOverride) ? Settings.UserDataOverride : FindUserDataDir(_steamAppsPath!); var startTime = DateTime.UtcNow; var seenMtimes = new Dictionary(); // seed from the newest existing vcfg so a stale bind from a previous session doesn't fire string? lastRelayValue = null; if (userDataDir != null && Directory.Exists(userDataDir)) { var newest = FindKeyVcfgs(userDataDir).OrderByDescending(File.GetLastWriteTimeUtc).FirstOrDefault(); if (newest != null) try { lastRelayValue = ParseRelayCommand(File.ReadAllText(newest), Settings.RelayKey); } catch { } } DebugLog($"SLAM started. userDataDir={userDataDir ?? "null"} startTime={startTime:O} lastRelayValue={lastRelayValue}"); while (!ct.IsCancellationRequested) { try { if (userDataDir != null && Directory.Exists(userDataDir)) { foreach (var vcfg in FindKeyVcfgs(userDataDir)) { var mtime = File.GetLastWriteTimeUtc(vcfg); if (mtime <= startTime) continue; if (seenMtimes.TryGetValue(vcfg, out var lastSeen) && mtime <= lastSeen) continue; seenMtimes[vcfg] = mtime; DebugLog($"New vcfg: {vcfg} mtime={mtime:O}"); var content = File.ReadAllText(vcfg); var command = ParseRelayCommand(content, Settings.RelayKey); DebugLog($" relayKey={Settings.RelayKey} command={command}"); // unchanged bind value means CS2 rewrote the file on its own (round start, buy menu) if (command == lastRelayValue) continue; lastRelayValue = command; int trackNum = ParseTrackNumber(command); if (trackNum > 0) { var idx = trackNum - 1; if (idx >= 0 && idx < _game.Tracks.Count) { // CS2 voice stream (SDL Application, media.role=game) only exists while +voicerecord is active. // host_writeconfig runs before +voicerecord, so the stream may not exist yet - poll briefly. var freshSrcIds = FindCs2SourceOutputIds(); if (freshSrcIds.Count == 0) { Thread.Sleep(150); freshSrcIds = FindCs2SourceOutputIds(); } if (freshSrcIds.Count == 0) { Thread.Sleep(150); freshSrcIds = FindCs2SourceOutputIds(); } DebugLog($" relay: game-capture source-outputs [{string.Join(",", freshSrcIds)}] -> {virtualMicOutPactl}"); MoveCs2SourceOutputs(freshSrcIds, virtualMicOutPactl); PlayTrack(idx); Avalonia.Threading.Dispatcher.UIThread.Post(() => { foreach (var ti in Tracks) ti.IsPlaying = false; if (idx < Tracks.Count) Tracks[idx].IsPlaying = true; }); } // Don't delete remote/ files - they are Steam Cloud data CS2 relies on. // seenMtimes prevents re-processing until the next host_writeconfig. if (!vcfg.Contains("/remote/")) { try { File.Delete(vcfg); seenMtimes.Remove(vcfg); } catch { } } } } } } catch (Exception ex) when (ex.HResult != -2147024864) { LogError(ex); } Thread.Sleep(_game.PollInterval); } _audio.Stop(); var regularMicPactl = string.IsNullOrEmpty(Settings.RegularMicPactlId) ? "@DEFAULT_SOURCE@" : Settings.RegularMicPactlId; MoveCs2SourceOutputs(FindCs2SourceOutputIds(), regularMicPactl); if (!string.IsNullOrEmpty(_steamAppsPath)) CfgService.DeleteCfgFiles(_game, _steamAppsPath); } private static List FindCs2SourceOutputIds() { var ids = new List(); if (!RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) return ids; try { var psi = new ProcessStartInfo("pactl") { Arguments = "--format=json list source-outputs", UseShellExecute = false, RedirectStandardOutput = true, CreateNoWindow = true }; using var proc = Process.Start(psi)!; var json = proc.StandardOutput.ReadToEnd(); proc.WaitForExit(); using var doc = JsonDocument.Parse(json); foreach (var so in doc.RootElement.EnumerateArray()) { if (!so.TryGetProperty("properties", out var props)) continue; var mediaRole = props.TryGetProperty("media.role", out var r) ? r.GetString() : null; var mediaCategory = props.TryGetProperty("media.category", out var c) ? c.GetString() : null; var binary = props.TryGetProperty("application.process.binary", out var b) ? b.GetString() : null; // CS2 voice capture appears as "SDL Application" with media.role=game, media.category=Capture if ((mediaRole == "game" && mediaCategory == "Capture") || binary == "cs2") if (so.TryGetProperty("index", out var idx)) ids.Add(idx.GetInt32()); } } catch (Exception ex) { DebugLog($"FindCs2SourceOutputIds error: {ex.Message}"); } return ids; } private static void MoveCs2SourceOutputs(IEnumerable sourceOutputIds, string pactlSourceName) { if (!RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) return; foreach (var id in sourceOutputIds) { try { var args = $"move-source-output {id} {pactlSourceName}"; var psi = new ProcessStartInfo("pactl", args) { UseShellExecute = false, CreateNoWindow = true }; Process.Start(psi)?.WaitForExit(); DebugLog($"pactl {args}"); } catch { } } } private static string? FindUserDataDir(string steamAppsPath) { var steamDir = Path.GetDirectoryName(steamAppsPath); if (steamDir == null) return null; var candidate = Path.Combine(steamDir, "userdata"); if (Directory.Exists(candidate)) return candidate; if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) { var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); candidate = Path.Combine(home, ".local/share/Steam/userdata"); if (Directory.Exists(candidate)) return candidate; } return null; } private List FindKeyVcfgs(string userDataDir) { // CS2 writes key bindings to remote/ (Steam Cloud) and optionally local/cfg/ var candidates = new List(); foreach (var userDir in Directory.GetDirectories(userDataDir)) { var gameUserDir = Path.Combine(userDir, _game.Id.ToString()); if (!Directory.Exists(gameUserDir)) continue; var remoteKeys = Path.Combine(gameUserDir, "remote", "cs2_user_keys.vcfg"); if (File.Exists(remoteKeys)) candidates.Add(remoteKeys); var localCfg = Path.Combine(gameUserDir, "local", "cfg"); if (Directory.Exists(localCfg)) candidates.AddRange(Directory.GetFiles(localCfg, "*keys*.vcfg")); } return candidates; } private static string ParseRelayCommand(string cfgContent, string relayKey) { var pattern = $"\"{Regex.Escape(relayKey)}\"\\s+\"(.*?)\""; var m = Regex.Match(cfgContent, pattern, RegexOptions.IgnoreCase); return m.Success ? m.Groups[1].Value : ""; } private static int ParseTrackNumber(string relayValue) { var m = Regex.Match(relayValue, @"^slam_r_(\d+)_[ab]$"); if (m.Success) return int.Parse(m.Groups[1].Value); return int.TryParse(relayValue, out var n) ? n : 0; // plain number from an old slam.cfg } public void PlayTrack(int index) { DebugLog($"PlayTrack({index}) trackCount={_game.Tracks.Count}"); if (index < 0 || index >= _game.Tracks.Count) { DebugLog(" -> index out of range"); return; } var track = _game.Tracks[index]; var filePath = Path.Combine(_game.LibraryName, track.Name + _game.FileExtension); DebugLog($" filePath={filePath} exists={File.Exists(filePath)} globalVol={Settings.GlobalVolume} trackVol={track.Volume} pactlId={Settings.VirtualMicInPactlId}"); if (!File.Exists(filePath)) { DebugLog(" -> file not found"); return; } try { float volume = (Settings.GlobalVolume / 100f) * (track.Volume / 100f); _audio.Play(filePath, Settings.VirtualMicInDeviceIndex, volume, track.StartPos, track.EndPos, Settings.VirtualMicInPactlId); DebugLog(" -> audio.Play called"); if (!string.IsNullOrEmpty(_steamAppsPath)) CfgService.WriteTrackCfgs(_game, _steamAppsPath, track); } catch (Exception ex) { LogError(ex); ShowError("Playback failed: " + ex.Message); } } public void StreamToMic(int index) { if (index < 0 || index >= _game.Tracks.Count) return; var track = _game.Tracks[index]; var filePath = Path.Combine(_game.LibraryName, track.Name + _game.FileExtension); if (!File.Exists(filePath)) return; try { _audio.Play(filePath, Settings.VirtualMicInDeviceIndex, (Settings.GlobalVolume / 100f) * (track.Volume / 100f), track.StartPos, track.EndPos, Settings.VirtualMicInPactlId); } catch (Exception ex) { LogError(ex); ShowError("Playback failed: " + ex.Message); return; } IsStreaming = true; StreamButtonText = "Stop Stream"; StreamStatusText = $"Streaming: {track.Name}"; foreach (var ti in Tracks) ti.IsPlaying = false; if (index < Tracks.Count) Tracks[index].IsPlaying = true; } public void StopStream() { _audio.Stop(); IsStreaming = false; StreamButtonText = "Stream to Mic"; StreamStatusText = ""; foreach (var ti in Tracks) ti.IsPlaying = false; } public async Task ImportFilesAsync(IEnumerable files, bool deleteSource = false) { var fileList = files.ToList(); InterfaceEnabled = false; ProgressMax = fileList.Count; Progress = 0; var failed = new List(); await Task.Run(() => { foreach (var file in fileList) { try { var outFile = Path.Combine(_game.LibraryName, Path.GetFileNameWithoutExtension(file) + ".wav"); ConversionService.ConvertToWav(file, outFile, _game.SampleRate, _game.Channels); if (deleteSource) File.Delete(file); } catch (Exception ex) { LogError(ex); failed.Add(Path.GetFileName(file)); } Avalonia.Threading.Dispatcher.UIThread.Post(() => Progress++); } }); Progress = 0; RefreshTracks(); InterfaceEnabled = true; if (failed.Count > 0) ShowError("Conversion complete! Failed: " + string.Join(", ", failed)); } public void DeleteTracks(IEnumerable indices) { foreach (var idx in indices.OrderByDescending(x => x)) { var t = _game.Tracks[idx]; var path = Path.Combine(_game.LibraryName, t.Name + _game.FileExtension); if (File.Exists(path)) try { File.Delete(path); } catch { } } RefreshTracks(); } public void SetTrackVolume(IEnumerable indices, int volume) { foreach (var idx in indices) if (idx < _game.Tracks.Count) _game.Tracks[idx].Volume = volume; TrackSettingsService.Save(_game); RefreshTracks(); } public bool SetTrackHotkey(int index, string key) { if (index < 0 || index >= _game.Tracks.Count) return false; if (key.Equals(Settings.PlayKey, StringComparison.OrdinalIgnoreCase) || key.Equals(Settings.RelayKey, StringComparison.OrdinalIgnoreCase)) { ShowError($"\"{key}\" is used as the play/relay key!"); return false; } bool inUse = _game.Tracks.Where((_, i) => i != index).Any(t => t.Hotkey == key); if (inUse) { ShowError($"\"{key}\" is already assigned!"); return false; } _game.Tracks[index].Hotkey = key; TrackSettingsService.Save(_game); RefreshTracks(); return true; } public void RemoveHotkey(int index) { if (index < 0 || index >= _game.Tracks.Count) return; _game.Tracks[index].Hotkey = ""; TrackSettingsService.Save(_game); RefreshTracks(); } public void SetTrackTrim(int index, int startPos, int endPos) { if (index < 0 || index >= _game.Tracks.Count) return; _game.Tracks[index].StartPos = startPos; _game.Tracks[index].EndPos = endPos; TrackSettingsService.Save(_game); RefreshTracks(); } public void RenameTrack(int index, string newName) { if (index < 0 || index >= _game.Tracks.Count) return; var track = _game.Tracks[index]; var oldPath = Path.Combine(_game.LibraryName, track.Name + _game.FileExtension); var newPath = Path.Combine(_game.LibraryName, newName + _game.FileExtension); if (File.Exists(newPath)) { ShowError("A track with that name already exists."); return; } try { File.Move(oldPath, newPath); } catch (Exception ex) { ShowError("Rename failed: " + ex.Message); return; } track.Name = newName; TrackSettingsService.Save(_game); RefreshTracks(); } public string GetTrackFilePath(int index) { if (index < 0 || index >= _game.Tracks.Count) return ""; return Path.Combine(_game.LibraryName, _game.Tracks[index].Name + _game.FileExtension); } public Track? GetTrack(int index) => index >= 0 && index < _game.Tracks.Count ? _game.Tracks[index] : null; public void SetPlayKey(string key) { if (key == Settings.RelayKey) { ShowError("Play key and relay key cannot be the same!"); return; } Settings.PlayKey = key; _settings.Save(); PlayKeyButtonText = $"Play key: \"{Settings.PlayKey}\" (change)"; } private void RefreshPlayKey() { PlayKeyButtonText = $"Play key: \"{Settings.PlayKey}\" (change)"; } private static void CreateTags(SourceGame game) { var wordMap = new Dictionary(StringComparer.OrdinalIgnoreCase); var ambiguous = new HashSet(StringComparer.OrdinalIgnoreCase); for (int i = 0; i < game.Tracks.Count; i++) { foreach (var w in game.Tracks[i].Name.Split(' ', '.', '-', '_')) { // only plain alphanumeric words survive as cfg aliases if (w.Length == 0 || w.Length >= 32) continue; if (!w.All(char.IsAsciiLetterOrDigit) || w.All(char.IsDigit)) continue; if (game.Blacklist.Contains(w.ToLower()) || ambiguous.Contains(w)) continue; if (wordMap.TryGetValue(w, out var owner)) { if (owner != i) { wordMap.Remove(w); ambiguous.Add(w); } } else wordMap[w] = i; } } foreach (var (word, idx) in wordMap) game.Tracks[idx].Tags.Add(word); } private void LogError(Exception ex) { if (!Settings.LogError) return; File.AppendAllText("errorlog.txt", $"----{DateTime.UtcNow} UTC----\n{ex}\n"); } private const bool DebugLogging = false; // flip to true to log to slam_debug.log private static void DebugLog(string msg) { if (DebugLogging) File.AppendAllText("slam_debug.log", $"{DateTime.UtcNow:O} {msg}\n"); } public event Action? ErrorOccurred; public void ShowError(string msg) => ErrorOccurred?.Invoke(msg); public void SaveSettings() => _settings.Save(); }