Files
CapySR/gui/MainWindow.xaml.cs
T
2026-10-17 21:03:23 -07:00

1093 lines
42 KiB
C#

using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Runtime.InteropServices;
using System.Security.Cryptography;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Data;
using System.Windows.Input;
using System.Windows.Interop;
using System.Windows.Media;
using System.Windows.Media.Animation;
using System.Windows.Media.Effects;
using System.Windows.Media.Imaging;
using System.Windows.Threading;
using Microsoft.VisualBasic.FileIO;
namespace CapyLauncher
{
public partial class MainWindow : Window
{
enum State { Stopped, Starting, Online, Stopping, Crashed }
const int SwimFrames = 8;
const int MaxLines = 6000;
const string SrToolsUrl = "https://srtools.neonteam.dev/";
static readonly string[] PatchFiles = { "launcher.exe", "hkrpg.dll" };
static readonly Regex Authenticated = new Regex(@"uid (\d+) authenticated", RegexOptions.Compiled);
static readonly Regex BannerRow = new Regex(@"^\|\s*(dispatch|game)\s+(\S+)", RegexOptions.Compiled);
static readonly string[] SleepLines = { "Start the server to wake capy up", "capy is napping in the hot spring", "zzz… press start whenever" };
static readonly string[] OnlineLines = { "Ready when you are", "capy is vibing", "the hot spring is warm", "all systems capy" };
readonly string launcherDir = AppDomain.CurrentDomain.BaseDirectory.TrimEnd('\\');
readonly ServerHost server;
readonly ServerConfig config;
readonly Settings settings = Settings.Load();
readonly Random random = new Random();
readonly ObservableCollection<LogLine> lines = new ObservableCollection<LogLine>();
readonly ConcurrentQueue<string> incoming = new ConcurrentQueue<string>();
readonly List<ImageSource> swim = new List<ImageSource>();
readonly DispatcherTimer drain = new DispatcherTimer(DispatcherPriority.Background) { Interval = TimeSpan.FromMilliseconds(60) };
readonly DispatcherTimer clock = new DispatcherTimer { Interval = TimeSpan.FromSeconds(1) };
readonly DispatcherTimer swimmer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(120) };
readonly DispatcherTimer toastTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(2.8) };
Catalog catalog;
BuildFile builds;
FileSystemWatcher watcher;
DateTime quietUntil;
readonly DispatcherTimer reloadTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(500) };
readonly DispatcherTimer gameWatch = new DispatcherTimer { Interval = TimeSpan.FromSeconds(1.5) };
string pendingLanguage;
ListCollectionView view;
ScrollViewer logScroll;
TaskCompletionSource<bool> ready;
TaskCompletionSource<bool> answer;
State state = State.Stopped;
DateTime startedAt;
string dispatch, gamePort, lastError;
bool closing;
int frame;
public MainWindow()
{
InitializeComponent();
server = new ServerHost(launcherDir);
config = ServerConfig.Load(server.Root);
server.Line += text => incoming.Enqueue(text);
server.Exited += (code, asked) => Dispatcher.BeginInvoke(new Action(() => OnExited(code, asked)));
view = (ListCollectionView)CollectionViewSource.GetDefaultView(lines);
view.Filter = Visible;
LogList.ItemsSource = view;
ShowDebug.IsChecked = settings.ShowDebug;
AutoScroll.IsChecked = settings.AutoScroll;
AutoScroll.Checked += (s, e) => { SaveToggles(); FollowEnd(); };
AutoScroll.Unchecked += (s, e) => SaveToggles();
LoadSwim();
drain.Tick += (s, e) => Drain();
clock.Tick += (s, e) => Tick();
swimmer.Tick += (s, e) => Swim();
toastTimer.Tick += (s, e) => HideToast();
drain.Start();
clock.Start();
swimmer.Start();
reloadTimer.Tick += (s, e) => { reloadTimer.Stop(); ExternalChange(); };
gameWatch.Tick += (s, e) => GameWatch();
Loaded += (s, e) => OnLoaded();
Closing += OnClosing;
StateChanged += (s, e) => OnWindowState();
}
void OnLoaded()
{
logScroll = FindChild<ScrollViewer>(LogList);
ShowClient();
Apply(State.Stopped, instant: true);
PortsText.Text = config.HttpPort + "·" + config.GamePort;
Hint.Text = server.Exe == null ? "capysr.exe not found next to the launcher" : Shorten(server.Exe);
if (server.Exe == null)
ServerButton.IsEnabled = false;
Enter(ServerCard, 0);
Enter(GameCard, 70);
Enter(QuickCard, 140);
Enter(ConsoleCard, 110);
if (server.Strangers().Length > 0)
Toast("A CapySR server is already running outside the launcher", warn: true);
Dispatcher.BeginInvoke(new Action(() => MovePill(TabHome, false)), DispatcherPriority.Loaded);
LoadBuilds();
}
// ---- builds ----
async void LoadBuilds()
{
string catalogPath = Path.Combine(server.Root, "resources", "Catalog.json");
if (!File.Exists(catalogPath))
{
TabBuilds.IsEnabled = TabBattle.IsEnabled = false;
TabBuilds.ToolTip = TabBattle.ToolTip = "resources/Catalog.json is missing";
return;
}
try
{
catalog = await Task.Run(() => Catalog.Load(catalogPath));
builds = new BuildFile(Path.Combine(server.Root, config.SrToolsFile.Replace('/', '\\')));
builds.Load();
}
catch (Exception error)
{
TabBuilds.IsEnabled = TabBattle.IsEnabled = false;
Toast("Couldn't read the builds: " + error.Message, warn: true);
return;
}
Builds.Init(catalog, builds, BuildsChanged, (title, text, yes, danger) => Ask(title, text, yes, danger));
BattleView.Init(catalog, builds, BuildsChanged, config.BattleSource);
Watch();
}
void Watch()
{
string folder = Path.GetDirectoryName(builds.Path);
if (!Directory.Exists(folder))
return;
watcher = new FileSystemWatcher(folder, Path.GetFileName(builds.Path)) { NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.FileName };
FileSystemEventHandler poke = (s, e) => Dispatcher.BeginInvoke(new Action(() => { reloadTimer.Stop(); reloadTimer.Start(); }));
watcher.Changed += poke;
watcher.Created += poke;
watcher.Renamed += (s, e) => poke(s, null);
watcher.EnableRaisingEvents = true;
}
void ExternalChange()
{
if (builds == null || !File.Exists(builds.Path) || File.GetLastWriteTimeUtc(builds.Path) == builds.LoadedStamp)
return;
if (DateTime.Now < quietUntil)
{
builds.Restamp();
return;
}
if (builds.Dirty)
{
Toast("freesr-data.json changed outside the launcher. Saving will overwrite that", warn: true);
return;
}
try
{
builds.Load();
}
catch (Exception)
{
return;
}
Builds.Reload();
BattleView.Reload();
Toast("Builds reloaded, they changed in game");
}
void BuildsChanged()
{
bool show = builds != null && builds.Dirty;
if (show == SaveBar.IsHitTestVisible)
return;
SaveBar.IsHitTestVisible = show;
Animate(SaveBar, OpacityProperty, show ? 1 : 0, 240);
Animate(SaveBar.RenderTransform, TranslateTransform.YProperty, show ? 0 : 30, 320);
}
async Task<bool> SaveBuilds()
{
if (builds == null)
return false;
try
{
if (server.Running)
{
if (state != State.Online)
{
Toast("Wait for the server to finish starting, then save", warn: true);
return false;
}
string reply = await builds.SendToServer(dispatch ?? config.Dispatch);
if (reply != "OK")
{
Toast("The server refused the build: " + reply, warn: true);
return false;
}
Toast(PlayerText.Text == "In game" ? "Saved. Relog in game to see the changes" : "Saved and sent to the server");
}
else
{
builds.WriteToDisk();
Toast("Saved to freesr-data.json");
}
}
catch (Exception error)
{
Toast("Couldn't save: " + error.Message, warn: true);
return false;
}
quietUntil = DateTime.Now.AddSeconds(2);
BuildsChanged();
return true;
}
async void Save_Click(object sender, RoutedEventArgs e) => await SaveBuilds();
void Revert_Click(object sender, RoutedEventArgs e)
{
if (builds == null)
return;
builds.Load();
Builds.Reload();
BattleView.Reload();
BuildsChanged();
Toast("Changes thrown away");
}
// ---- tabs ----
void Tab_Checked(object sender, RoutedEventArgs e)
{
if (!IsLoaded)
return;
var tab = (RadioButton)sender;
MovePill(tab, true);
FrameworkElement show = tab == TabBuilds ? BuildsHost : tab == TabBattle ? (FrameworkElement)BattleHost : HomePage;
foreach (FrameworkElement page in new FrameworkElement[] { HomePage, BuildsHost, BattleHost })
{
if (page != show)
page.Visibility = Visibility.Collapsed;
}
show.Visibility = Visibility.Visible;
var move = new TranslateTransform(0, 14);
show.RenderTransform = move;
show.Opacity = 0;
Animate(show, OpacityProperty, 1, 260);
Animate(move, TranslateTransform.YProperty, 0, 340);
}
void MovePill(RadioButton tab, bool animate)
{
Point at = tab.TranslatePoint(new Point(0, 0), NavTabs);
var move = (TranslateTransform)NavPill.RenderTransform;
NavPill.Height = tab.ActualHeight;
if (!animate)
{
NavPill.Width = tab.ActualWidth;
move.X = at.X;
return;
}
Animate(NavPill, WidthProperty, tab.ActualWidth, 320);
Animate(move, TranslateTransform.XProperty, at.X, 320);
}
// ---- window chrome ----
[DllImport("dwmapi.dll")]
static extern int DwmSetWindowAttribute(IntPtr hwnd, int attribute, ref int value, int size);
protected override void OnSourceInitialized(EventArgs e)
{
base.OnSourceInitialized(e);
IntPtr hwnd = new WindowInteropHelper(this).Handle;
int dark = 1, round = 2, border = 0x4A2B35;
DwmSetWindowAttribute(hwnd, 20, ref dark, sizeof(int));
DwmSetWindowAttribute(hwnd, 33, ref round, sizeof(int));
DwmSetWindowAttribute(hwnd, 34, ref border, sizeof(int));
}
void OnWindowState()
{
Shell.Margin = WindowState == WindowState.Maximized ? new Thickness(7) : new Thickness(0);
MaxButton.Content = WindowState == WindowState.Maximized ? "" : "";
}
void Minimize_Click(object sender, RoutedEventArgs e) => WindowState = WindowState.Minimized;
void Maximize_Click(object sender, RoutedEventArgs e) =>
WindowState = WindowState == WindowState.Maximized ? WindowState.Normal : WindowState.Maximized;
void Close_Click(object sender, RoutedEventArgs e) => Close();
async void OnClosing(object sender, CancelEventArgs e)
{
if (closing)
return;
if (builds != null && builds.Dirty)
{
e.Cancel = true;
bool save = await Ask("Save your builds?", "You have changes in Builds or Battle that aren't saved yet.", "Save", false, "Discard");
if (save && !await SaveBuilds())
return;
builds.Dirty = false;
Close();
return;
}
if (!server.Running)
return;
e.Cancel = true;
closing = true;
OpenDialog("Saving and stopping…", "Giving the server a moment to save your progress before closing.", null);
await server.StopAsync();
Close();
}
// ---- animation helpers ----
static void Animate(DependencyObject target, DependencyProperty property, double to, int ms, int delay = 0, double? from = null)
{
var animation = new DoubleAnimation(to, TimeSpan.FromMilliseconds(ms))
{
BeginTime = TimeSpan.FromMilliseconds(delay),
EasingFunction = new CubicEase { EasingMode = EasingMode.EaseOut },
};
if (from.HasValue)
animation.From = from.Value;
((IAnimatable)target).BeginAnimation(property, animation);
}
static void Enter(FrameworkElement element, int delay)
{
var move = new TranslateTransform(0, 18);
element.RenderTransform = move;
element.Opacity = 0;
Animate(element, OpacityProperty, 1, 520, delay);
Animate(move, TranslateTransform.YProperty, 0, 620, delay);
}
void Fade(TextBlock block, string text)
{
if (block.Text == text)
return;
var fadeOut = new DoubleAnimation(0, TimeSpan.FromMilliseconds(110));
fadeOut.Completed += (s, e) =>
{
block.Text = text;
Animate(block, OpacityProperty, 1, 220);
};
block.BeginAnimation(OpacityProperty, fadeOut);
}
void Pulse(Brush color, bool on)
{
PillDot.Fill = color;
PulseRing.Fill = color;
var scale = (ScaleTransform)PulseRing.RenderTransform;
if (!on)
{
PulseRing.BeginAnimation(OpacityProperty, null);
scale.BeginAnimation(ScaleTransform.ScaleXProperty, null);
scale.BeginAnimation(ScaleTransform.ScaleYProperty, null);
PulseRing.Opacity = 0;
return;
}
var grow = new DoubleAnimation(1, 2.8, TimeSpan.FromMilliseconds(1500)) { RepeatBehavior = RepeatBehavior.Forever, EasingFunction = new CubicEase { EasingMode = EasingMode.EaseOut } };
var fade = new DoubleAnimation(0.6, 0, TimeSpan.FromMilliseconds(1500)) { RepeatBehavior = RepeatBehavior.Forever };
scale.BeginAnimation(ScaleTransform.ScaleXProperty, grow);
scale.BeginAnimation(ScaleTransform.ScaleYProperty, grow);
PulseRing.BeginAnimation(OpacityProperty, fade);
}
// ---- capy ----
void LoadSwim()
{
var strip = new BitmapImage(new Uri("pack://application:,,,/capy_swim.png"));
int width = strip.PixelWidth / SwimFrames;
for (int i = 0; i < SwimFrames; i++)
swim.Add(new CroppedBitmap(strip, new Int32Rect(i * width, 0, width, strip.PixelHeight)));
Capy.Source = swim[0];
EmptyCapy.Source = swim[0];
}
void Bob(bool on)
{
var move = (TranslateTransform)Capy.RenderTransform;
if (!on)
{
move.BeginAnimation(TranslateTransform.YProperty, new DoubleAnimation(0, TimeSpan.FromMilliseconds(300)));
return;
}
if (move.HasAnimatedProperties && move.Y != 0)
return;
move.BeginAnimation(TranslateTransform.YProperty, new DoubleAnimation(-1.6, 1.6, TimeSpan.FromMilliseconds(900))
{
AutoReverse = true,
RepeatBehavior = RepeatBehavior.Forever,
EasingFunction = new SineEase { EasingMode = EasingMode.EaseInOut },
});
}
void Swim()
{
bool awake = state == State.Online || state == State.Starting;
swimmer.Interval = TimeSpan.FromMilliseconds(state == State.Starting ? 80 : 120);
frame = (frame + 1) % SwimFrames;
if (awake)
{
Capy.Source = swim[frame];
}
if (EmptyState.Visibility == Visibility.Visible)
EmptyCapy.Source = swim[frame];
}
// ---- server state ----
void Apply(State next, bool instant = false)
{
state = next;
var sleepy = SleepLines[random.Next(SleepLines.Length)];
switch (next)
{
case State.Stopped:
SetState("Sleeping", sleepy, "Offline", "Faint", false);
break;
case State.Starting:
SetState("Waking up…", "Loading the game tables", "Starting", "Warn", true);
break;
case State.Online:
SetState("Online", OnlineLines[random.Next(OnlineLines.Length)], "Online", "Ok", true);
break;
case State.Stopping:
SetState("Saving…", "Writing your progress and shutting down", "Stopping", "Warn", true);
break;
case State.Crashed:
SetState("Oh no :(", lastError ?? "The server stopped on its own. The console has the details.", "Stopped", "Fail", false);
break;
}
bool busy = next == State.Starting || next == State.Stopping;
bool up = next == State.Online || next == State.Starting;
ServerButton.IsEnabled = !busy && server.Exe != null;
ServerLabel.Text = up ? "Stop server" : next == State.Crashed ? "Start again" : "Start server";
ServerGlyph.Text = up ? "" : "";
ServerButton.Background = (Brush)FindResource(up ? "Danger" : "Capy");
FreshButton.IsEnabled = !up && next != State.Stopping;
PlayLabel.Text = next == State.Online ? "Play" : "Start server & play";
double awake = up ? 1 : 0.45;
if (instant)
{
Capy.Opacity = awake;
Water.Opacity = up ? 0.55 : 0.2;
}
else
{
Animate(Capy, OpacityProperty, awake, 400);
Animate(Water, OpacityProperty, up ? 0.55 : 0.2, 600);
}
Bob(up);
if (!up)
{
Capy.Source = swim[0];
UptimeText.Text = "—";
PlayerText.Text = "—";
PlayerText.Foreground = (Brush)FindResource("Text");
}
}
void SetState(string title, string sub, string pill, string color, bool pulse)
{
Fade(StateTitle, title);
Fade(StateSub, sub);
PillText.Text = pill;
PillText.Foreground = (Brush)FindResource(color == "Faint" ? "Dim" : color);
Pulse((Brush)FindResource(color), pulse);
}
void Tick()
{
if (state != State.Online && state != State.Starting)
return;
TimeSpan up = DateTime.Now - startedAt;
UptimeText.Text = up.TotalHours >= 1 ? ((int)up.TotalHours) + ":" + up.ToString(@"mm\:ss") : up.ToString(@"mm\:ss");
}
async Task<bool> StartServer()
{
if (server.Running)
return true;
if (server.Exe == null)
{
Toast("capysr.exe isn't next to the launcher", warn: true);
return false;
}
Process[] strangers = server.Strangers();
if (strangers.Length > 0)
{
bool takeOver = await Ask("Another server is running",
"A CapySR server was started outside the launcher, and two can't share the ports. Close that one and start here?",
"Close it", danger: true);
if (!takeOver)
return false;
foreach (Process stranger in strangers)
{
try { stranger.Kill(); stranger.WaitForExit(3000); } catch (Exception) { }
}
}
lastError = null;
dispatch = null;
gamePort = null;
ready = new TaskCompletionSource<bool>();
startedAt = DateTime.Now;
Apply(State.Starting);
UptimeText.Text = "00:00";
PlayerText.Text = "Waiting";
PlayerText.Foreground = (Brush)FindResource("Dim");
try
{
server.Start();
}
catch (Exception error)
{
lastError = error.Message;
Apply(State.Crashed);
return false;
}
Task finished = await Task.WhenAny(ready.Task, Task.Delay(TimeSpan.FromSeconds(60)));
return finished == ready.Task && ready.Task.Result;
}
async Task StopServer()
{
if (!server.Running)
return;
Apply(State.Stopping);
await server.StopAsync();
}
void OnExited(int code, bool asked)
{
ready?.TrySetResult(false);
Drain();
if (asked || code == 0)
{
Apply(State.Stopped);
if (!closing)
Toast("Server stopped, progress saved");
}
else
{
if (lastError == null)
lastError = "The server exited with code " + code + ".";
Apply(State.Crashed);
}
}
async void Server_Click(object sender, RoutedEventArgs e)
{
if (server.Running)
await StopServer();
else if (await StartServer())
Toast("Server is up, have fun!");
}
// ---- log ----
void Drain()
{
if (incoming.IsEmpty)
return;
LogLine previous = lines.Count > 0 ? lines[lines.Count - 1] : null;
int taken = 0;
while (taken < 800 && incoming.TryDequeue(out string raw))
{
taken++;
Watch(raw);
if (raw.Trim().Length == 0 || raw.TrimStart().StartsWith("+-") || raw.Trim().All(c => c == '-' || c == '+'))
continue;
if (raw.StartsWith("|"))
continue;
previous = LogLine.Parse(raw, previous);
lines.Add(previous);
}
while (lines.Count > MaxLines)
lines.RemoveAt(0);
EmptyState.Visibility = lines.Count == 0 ? Visibility.Visible : Visibility.Collapsed;
LineCount.Text = lines.Count + " lines";
FollowEnd();
}
void Watch(string raw)
{
Match row = BannerRow.Match(raw);
if (row.Success)
{
if (row.Groups[1].Value == "dispatch")
dispatch = row.Groups[2].Value;
else
gamePort = row.Groups[2].Value.Split(':').Last();
PortsText.Text = Port(dispatch, config.HttpPort) + "·" + (gamePort ?? config.GamePort.ToString());
}
if (raw.Contains(" is ready") && state == State.Starting)
{
Apply(State.Online);
ready?.TrySetResult(true);
}
if (raw.Contains(" ERROR "))
lastError = raw.Substring(raw.IndexOf(" ERROR ") + 7).Trim();
Match login = Authenticated.Match(raw);
if (login.Success)
{
PlayerText.Text = "In game";
PlayerText.Foreground = (Brush)FindResource("Ok");
}
else if (raw.Contains(" disconnected (") && state == State.Online)
{
PlayerText.Text = "Offline";
PlayerText.Foreground = (Brush)FindResource("Dim");
}
else if (raw.Contains("accepting handshake") && state == State.Online)
{
PlayerText.Text = "Logging in";
PlayerText.Foreground = (Brush)FindResource("Warn");
}
}
static string Port(string url, int fallback)
{
if (url == null)
return fallback.ToString();
int colon = url.LastIndexOf(':');
return colon > 0 ? url.Substring(colon + 1).TrimEnd('/') : fallback.ToString();
}
bool Visible(object item)
{
var line = (LogLine)item;
bool shown = line.Kind == Level.Debug ? ShowDebug.IsChecked == true
: line.Kind == Level.Info ? ShowInfo.IsChecked == true
: line.Kind == Level.Warn ? ShowWarn.IsChecked == true
: ShowError.IsChecked == true;
if (!shown)
return false;
string search = SearchBox.Text;
return search.Length == 0
|| line.Message.IndexOf(search, StringComparison.OrdinalIgnoreCase) >= 0
|| line.Tag.IndexOf(search, StringComparison.OrdinalIgnoreCase) >= 0;
}
void FollowEnd()
{
if (AutoScroll.IsChecked == true)
logScroll?.ScrollToEnd();
}
void SaveToggles()
{
settings.ShowDebug = ShowDebug.IsChecked == true;
settings.AutoScroll = AutoScroll.IsChecked == true;
settings.Save();
}
void Filter_Click(object sender, RoutedEventArgs e)
{
SaveToggles();
view.Refresh();
FollowEnd();
}
void Search_Changed(object sender, TextChangedEventArgs e)
{
view?.Refresh();
FollowEnd();
}
void Copy_Click(object sender, RoutedEventArgs e)
{
var shown = (LogList.SelectedItems.Count > 0 ? LogList.SelectedItems.Cast<LogLine>() : view.Cast<LogLine>()).ToList();
if (shown.Count == 0)
return;
Clipboard.SetText(string.Join(Environment.NewLine, shown.Select(l => l.ToString())));
Toast("Copied " + shown.Count + (shown.Count == 1 ? " line" : " lines"));
}
void Clear_Click(object sender, RoutedEventArgs e)
{
lines.Clear();
EmptyState.Visibility = Visibility.Visible;
LineCount.Text = "";
}
// ---- game ----
string ClientExe => string.IsNullOrEmpty(settings.ClientFolder) ? null : Path.Combine(settings.ClientFolder, "StarRail.exe");
bool Patched()
{
if (string.IsNullOrEmpty(settings.ClientFolder))
return false;
return PatchFiles.All(name => SameFile(Path.Combine(server.Root, "launcher", name), Path.Combine(settings.ClientFolder, name)));
}
static bool SameFile(string a, string b)
{
if (!File.Exists(a) || !File.Exists(b) || new FileInfo(a).Length != new FileInfo(b).Length)
return false;
using (var md5 = MD5.Create())
{
byte[] left, right;
using (var stream = File.OpenRead(a)) left = md5.ComputeHash(stream);
using (var stream = File.OpenRead(b)) right = md5.ComputeHash(stream);
return left.SequenceEqual(right);
}
}
void ShowClient()
{
bool picked = !string.IsNullOrEmpty(settings.ClientFolder);
bool exe = picked && File.Exists(ClientExe);
bool patched = exe && Patched();
ClientPath.Text = picked ? settings.ClientFolder : "Pick your client folder";
ClientPath.ToolTip = picked ? settings.ClientFolder : null;
ClientPath.Foreground = (Brush)FindResource(picked ? "Text" : "Dim");
Mark(ExeMark, ExeText, exe, picked ? (exe ? "StarRail.exe" : "No StarRail.exe here") : "StarRail.exe");
Mark(PatchMark, PatchText, patched, patched ? "Patched" : exe ? "Patches on first play" : "Patch");
PatchMark.Foreground = (Brush)FindResource(patched ? "Ok" : exe ? "Warn" : "Faint");
PatchText.Foreground = PatchMark.Foreground;
PatchMark.Text = patched ? "" : exe ? "" : "";
PlayLabel.Text = state == State.Online ? "Play" : "Start server & play";
ShowLanguage(exe);
}
async void ShowLanguage(bool exe)
{
if (!exe)
{
LanguageRow.Visibility = Visibility.Collapsed;
return;
}
string folder = settings.ClientFolder;
LanguageState found = await Task.Run(() => LanguagePatch.Detect(folder));
if (folder != settings.ClientFolder)
return;
if (found == null)
{
LanguageRow.Visibility = Visibility.Collapsed;
return;
}
string wanted = found.Codes.Contains(settings.TextLanguage) ? settings.TextLanguage : found.Current;
if (wanted != found.Current && pendingLanguage == null)
{
if (GameRunning()) Wait(wanted);
else await ApplyLanguage(wanted, false);
}
LanguagePicker.Content = Ui.Segments(found.Codes.Select(LanguagePatch.Name), found.Codes.IndexOf(pendingLanguage ?? wanted), index => SetLanguage(found.Codes[index]));
LanguageRow.Visibility = Visibility.Visible;
}
void Wait(string code)
{
pendingLanguage = code;
LanguageNote.Text = LanguagePatch.Name(code) + " when the game closes";
LanguageNote.Visibility = Visibility.Visible;
gameWatch.Start();
}
async void GameWatch()
{
if (GameRunning() || pendingLanguage == null)
return;
gameWatch.Stop();
string code = pendingLanguage;
pendingLanguage = null;
LanguageNote.Visibility = Visibility.Collapsed;
await ApplyLanguage(code, true);
}
async Task<bool> ApplyLanguage(string code, bool announce)
{
string folder = settings.ClientFolder;
try
{
LanguageState after = await Task.Run(() =>
{
LanguagePatch.Apply(folder, code);
return LanguagePatch.Detect(folder);
});
if (after == null || after.Current != code)
{
Toast("The language files didn't take the change, try again with the game closed", warn: true);
return false;
}
if (announce)
Toast("Text language set to " + LanguagePatch.Name(code));
return true;
}
catch (Exception error)
{
Toast("Couldn't change the language: " + error.Message, warn: true);
return false;
}
}
bool GameRunning() => Process.GetProcessesByName("StarRail").Length > 0;
async void SetLanguage(string code)
{
settings.TextLanguage = code;
settings.Save();
if (GameRunning())
{
Wait(code);
Toast("The game is open, so " + LanguagePatch.Name(code) + " applies as soon as you close it", warn: true);
return;
}
pendingLanguage = null;
gameWatch.Stop();
LanguageNote.Visibility = Visibility.Collapsed;
await ApplyLanguage(code, true);
}
void Mark(TextBlock mark, TextBlock label, bool ok, string text)
{
mark.Text = ok ? "" : "";
mark.Foreground = (Brush)FindResource(ok ? "Ok" : string.IsNullOrEmpty(settings.ClientFolder) ? "Faint" : "Fail");
label.Foreground = mark.Foreground;
label.Text = text;
}
void Browse_Click(object sender, RoutedEventArgs e)
{
var dialog = new Microsoft.Win32.OpenFileDialog
{
Title = "Pick StarRail.exe in your client folder",
Filter = "Star Rail|StarRail.exe|Any exe|*.exe",
};
if (Directory.Exists(settings.ClientFolder))
dialog.InitialDirectory = settings.ClientFolder;
if (dialog.ShowDialog(this) != true)
return;
settings.ClientFolder = Path.GetDirectoryName(dialog.FileName);
settings.Save();
ShowClient();
}
bool Patch()
{
foreach (string name in PatchFiles)
{
string source = Path.Combine(server.Root, "launcher", name);
string target = Path.Combine(settings.ClientFolder, name);
if (!File.Exists(source))
{
Toast("launcher/" + name + " is missing from CapySR", warn: true);
return false;
}
if (SameFile(source, target))
continue;
try
{
File.Copy(source, target, true);
}
catch (Exception)
{
Toast("Couldn't copy " + name + " into the client folder. Try running the launcher as admin", warn: true);
return false;
}
}
return true;
}
async void Play_Click(object sender, RoutedEventArgs e)
{
if (string.IsNullOrEmpty(settings.ClientFolder) || !File.Exists(ClientExe))
{
Toast("Pick your client folder first", warn: true);
Browse_Click(sender, e);
return;
}
if (GameRunning())
{
Toast("The game is already running. Close it first to start it again", warn: true);
return;
}
PlayButton.IsEnabled = false;
try
{
if (!Patch())
return;
if (!string.IsNullOrEmpty(settings.TextLanguage) && LanguageRow.Visibility == Visibility.Visible
&& !await ApplyLanguage(settings.TextLanguage, false))
return;
ShowClient();
if (!server.Running)
{
PlayLabel.Text = "Starting the server…";
if (!await StartServer())
{
Toast("The server didn't come up, check the console", warn: true);
return;
}
}
PlayLabel.Text = "Launching…";
try
{
Process.Start(new ProcessStartInfo(Path.Combine(settings.ClientFolder, "launcher.exe"))
{
UseShellExecute = true,
Verb = "runas",
WorkingDirectory = settings.ClientFolder,
});
Toast("Game is launching, see you in the hot spring");
}
catch (Win32Exception error) when (error.NativeErrorCode == 1223)
{
Toast("The game needs admin to start", warn: true);
}
}
finally
{
PlayButton.IsEnabled = true;
ShowClient();
}
}
// ---- quick actions ----
static void Open(string target)
{
try
{
Process.Start(new ProcessStartInfo(target) { UseShellExecute = true });
}
catch (Exception) { }
}
void Config_Click(object sender, RoutedEventArgs e) => Open(Path.Combine(server.Root, "config", "config.json"));
void Data_Click(object sender, RoutedEventArgs e) => Open(Path.Combine(server.Root, "data"));
void LogFile_Click(object sender, RoutedEventArgs e)
{
string log = Path.Combine(server.Root, "logs", "capysr.log");
Open(File.Exists(log) ? log : Path.Combine(server.Root, "logs"));
}
void SrTools_Click(object sender, RoutedEventArgs e) => Open(SrToolsUrl);
void Unstick_Click(object sender, RoutedEventArgs e)
{
if (!server.Running)
{
Toast("Start the server first", warn: true);
return;
}
Open((dispatch ?? config.Dispatch).TrimEnd('/') + "/unstick");
}
async void Fresh_Click(object sender, RoutedEventArgs e)
{
string save = Path.Combine(server.Root, "data", "player.json");
if (!File.Exists(save))
{
Toast("Already fresh, there's no save yet");
return;
}
if (!await Ask("Start over?", "Your save (data/player.json) goes to the Recycle Bin, so you can still get it back. Your srtools builds stay.", "Start over", danger: true))
return;
try
{
FileSystem.DeleteFile(save, UIOption.OnlyErrorDialogs, RecycleOption.SendToRecycleBin);
Toast("Save moved to the Recycle Bin");
}
catch (Exception error)
{
Toast("Couldn't move it: " + error.Message, warn: true);
}
}
// ---- dialog and toast ----
Task<bool> Ask(string title, string text, string yes, bool danger = false, string no = "Cancel")
{
answer = new TaskCompletionSource<bool>();
DialogYes.Content = yes;
DialogNo.Content = no;
DialogYes.Background = (Brush)FindResource(danger ? "Danger" : "Capy");
OpenDialog(title, text, yes);
return answer.Task;
}
void OpenDialog(string title, string text, string yes)
{
DialogTitle.Text = title;
DialogText.Text = text;
DialogButtons.Visibility = yes == null ? Visibility.Collapsed : Visibility.Visible;
Overlay.Visibility = Visibility.Visible;
var scale = (ScaleTransform)Dialog.RenderTransform;
Animate(Overlay, OpacityProperty, 1, 200);
Animate(scale, ScaleTransform.ScaleXProperty, 1, 260, 0, 0.94);
Animate(scale, ScaleTransform.ScaleYProperty, 1, 260, 0, 0.94);
var blur = new BlurEffect { Radius = 0 };
Shell.Effect = blur;
Animate(blur, BlurEffect.RadiusProperty, 10, 260);
}
void HideDialog(bool result)
{
var fade = new DoubleAnimation(0, TimeSpan.FromMilliseconds(160));
fade.Completed += (s, e) => Overlay.Visibility = Visibility.Collapsed;
Overlay.BeginAnimation(OpacityProperty, fade);
if (Shell.Effect is BlurEffect blur)
{
var clear = new DoubleAnimation(0, TimeSpan.FromMilliseconds(200));
clear.Completed += (s, e) => { if (Overlay.Visibility != Visibility.Visible) Shell.Effect = null; };
blur.BeginAnimation(BlurEffect.RadiusProperty, clear);
}
answer?.TrySetResult(result);
}
void DialogYes_Click(object sender, RoutedEventArgs e) => HideDialog(true);
void DialogNo_Click(object sender, RoutedEventArgs e) => HideDialog(false);
void Toast(string text, bool warn = false)
{
ToastText.Text = text;
ToastGlyph.Text = warn ? "" : "";
ToastGlyph.Foreground = (Brush)FindResource(warn ? "Warn" : "Ok");
Animate(ToastCard, OpacityProperty, 1, 220);
Animate(ToastCard.RenderTransform, TranslateTransform.YProperty, 0, 320);
toastTimer.Stop();
toastTimer.Start();
}
void HideToast()
{
toastTimer.Stop();
Animate(ToastCard, OpacityProperty, 0, 260);
Animate(ToastCard.RenderTransform, TranslateTransform.YProperty, 16, 300);
}
// ---- misc ----
static string Shorten(string path)
{
string[] parts = path.Split('\\');
return parts.Length <= 3 ? path : "…\\" + string.Join("\\", parts.Skip(parts.Length - 3));
}
static T FindChild<T>(DependencyObject parent) where T : DependencyObject
{
for (int i = 0; i < VisualTreeHelper.GetChildrenCount(parent); i++)
{
DependencyObject child = VisualTreeHelper.GetChild(parent, i);
if (child is T found)
return found;
T deeper = FindChild<T>(child);
if (deeper != null)
return deeper;
}
return null;
}
}
}