2026-10-17 19:41:57 -07:00
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 ) };
2026-10-17 20:53:14 -07:00
readonly DispatcherTimer gameWatch = new DispatcherTimer { Interval = TimeSpan . FromSeconds ( 1.5 ) };
string pendingLanguage ;
2026-10-17 19:41:57 -07:00
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 (); };
2026-10-17 20:53:14 -07:00
gameWatch . Tick += ( s , e ) => GameWatch ();
2026-10-17 19:41:57 -07:00
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 ];
}
2026-10-17 20:15:13 -07:00
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 },
});
}
2026-10-17 19:41:57 -07:00
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 );
}
2026-10-17 20:15:13 -07:00
Bob ( up );
2026-10-17 19:41:57 -07:00
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" ;
2026-10-17 20:53:14 -07:00
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 );
2026-10-17 19:41:57 -07:00
}
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 ;
}
2026-10-17 20:53:14 -07:00
if ( GameRunning ())
{
Toast ( "The game is already running. Close it first to start it again" , warn : true );
return ;
}
2026-10-17 19:41:57 -07:00
PlayButton . IsEnabled = false ;
try
{
if (! Patch ())
return ;
2026-10-17 20:53:14 -07:00
if (! string . IsNullOrEmpty ( settings . TextLanguage ) && LanguageRow . Visibility == Visibility . Visible
&& ! await ApplyLanguage ( settings . TextLanguage , false ))
return ;
2026-10-17 19:41:57 -07:00
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 );
2026-10-17 20:15:13 -07:00
var blur = new BlurEffect { Radius = 0 };
Shell . Effect = blur ;
Animate ( blur , BlurEffect . RadiusProperty , 10 , 260 );
2026-10-17 19:41:57 -07:00
}
void HideDialog ( bool result )
{
var fade = new DoubleAnimation ( 0 , TimeSpan . FromMilliseconds ( 160 ));
fade . Completed += ( s , e ) => Overlay . Visibility = Visibility . Collapsed ;
Overlay . BeginAnimation ( OpacityProperty , fade );
2026-10-17 20:15:13 -07:00
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 );
}
2026-10-17 19:41:57 -07:00
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 ;
}
}
}