1093 lines
42 KiB
C#
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;
|
|
}
|
|
}
|
|
}
|