diff --git a/3074.csproj b/3074.csproj index 2d9d91f..40e44b5 100644 --- a/3074.csproj +++ b/3074.csproj @@ -72,15 +72,21 @@ App.xaml Code - - - - - - - + + + + + ConfigWindow.xaml + + + + KeybindingDialog.xaml + + OverlayWindow.xaml + + Code @@ -92,9 +98,13 @@ + + - + + + \ No newline at end of file diff --git a/App.xaml b/App.xaml index efbba5f..d6be09d 100644 --- a/App.xaml +++ b/App.xaml @@ -1,7 +1,7 @@ - diff --git a/App.xaml.cs b/App.xaml.cs index d278f35..83271f2 100644 --- a/App.xaml.cs +++ b/App.xaml.cs @@ -3,8 +3,11 @@ using System.IO; using System.Runtime.InteropServices; using System.Windows; using System.Windows.Threading; +using tsf_3074.Data; +using tsf_3074.Window; +using tsf_3074.Windows.Dialog; -namespace _3074 +namespace tsf_3074 { /// /// Interaction logic for App.xaml @@ -14,6 +17,7 @@ namespace _3074 private void App_OnStartup(object sender, StartupEventArgs e) { SetCurrentProcessExplicitAppUserModelID("com.squirrel.Tsf"); + Config.Instance.ToString(); Tsf.FFullGame.ToString(); var window = new OverlayWindow(); window.Show(); diff --git a/Config.cs b/Config.cs deleted file mode 100644 index d0622c4..0000000 --- a/Config.cs +++ /dev/null @@ -1,36 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using Newtonsoft.Json; - -namespace _3074 -{ - public static class Config - { - public static ConfigData Instance => _instance ?? ReadConfig(); - private static ConfigData _instance; - - private static ConfigData ReadConfig(string path = "config.json") - { - if (File.Exists(path)) - { - return _instance = JsonConvert.DeserializeObject(File.ReadAllText(path)); - } - - var defaults = new ConfigData - { - Destiny2Path = "Your Destiny 2 application path" - }; - - File.WriteAllText(path, JsonConvert.SerializeObject(defaults)); - - throw new Exception("Config is not set"); - } - } - - public class ConfigData - { - public string Destiny2Path; - } - -} \ No newline at end of file diff --git a/Data/Config.cs b/Data/Config.cs new file mode 100644 index 0000000..1a29a44 --- /dev/null +++ b/Data/Config.cs @@ -0,0 +1,79 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Windows; +using Microsoft.Win32; +using Newtonsoft.Json; + +namespace tsf_3074.Data +{ + public static class Config + { + public static ConfigData Instance => _instance ?? ReadConfig(); + private static ConfigData _instance; + + private static ConfigData ReadConfig(string path = "config.json") + { + if (File.Exists(path)) + { + return _instance = JsonConvert.DeserializeObject(File.ReadAllText(path)); + } + + FindDestiny2(out var appPath); + if (appPath == "") + { + MessageBox.Show("You'd better to read the README document."); + Application.Current.Shutdown(); + throw new Exception(); + } + + _instance = new ConfigData + { + Destiny2Path = appPath, + Hotkeys = new Dictionary + { + { "tsf_3074", new HotkeyPair { vk = VirtualKey.VkG, fs = VirtualKey.ModCtrl } }, + { "tsf_3074_ul", new HotkeyPair { vk = VirtualKey.VkH, fs = VirtualKey.ModCtrl } }, + { "tsf_7500", new HotkeyPair { vk = VirtualKey.VkT, fs = VirtualKey.ModCtrl } }, + { "tsf_fg", new HotkeyPair { vk = VirtualKey.VkJ, fs = VirtualKey.ModCtrl } }, + { "tsf_27k", new HotkeyPair { vk = VirtualKey.VkN, fs = VirtualKey.ModAlt } }, + { "tsf_30k", new HotkeyPair { vk = VirtualKey.VkB, fs = VirtualKey.ModAlt } }, + } + }; + + File.WriteAllText(path, JsonConvert.SerializeObject(_instance)); + + return _instance; + } + + public static void SaveConfig(string path = "config.json") + { + File.WriteAllText(path, JsonConvert.SerializeObject(_instance)); + } + + private static void FindDestiny2(out string appPath) + { + var dialog = new OpenFileDialog + { + Multiselect = false, + Title = "Select Destiny 2 Application", + Filter = "Destiny 2|destiny2.exe" + }; + dialog.ShowDialog(); + Console.WriteLine(dialog.FileName); + appPath = dialog.FileName; + } + } + + public class ConfigData + { + public string Destiny2Path; + public Dictionary Hotkeys; + } + + public class HotkeyPair + { + public uint vk; + public uint fs; + } +} \ No newline at end of file diff --git a/OverlayWindow.xaml.cs b/OverlayWindow.xaml.cs deleted file mode 100644 index 0acb61e..0000000 --- a/OverlayWindow.xaml.cs +++ /dev/null @@ -1,78 +0,0 @@ -using System; -using System.ComponentModel; -using System.Globalization; -using System.Windows; -using System.Windows.Data; -using System.Windows.Media; - -namespace _3074 -{ - public partial class OverlayWindow : Window - { - internal readonly HotkeyManager _hotkey; - - public OverlayWindow() - { - InitializeComponent(); - DataContext = new OverlayViewModel(); - _hotkey = new HotkeyManager(this); - } - - protected override void OnSourceInitialized(EventArgs e) - { - base.OnSourceInitialized(e); - TsfFilter.AllFilters.ForEach(f => f.RegisterHotkey(_hotkey)); - _hotkey.OnSourceInitialized(); - } - - protected override void OnClosed(EventArgs e) - { - _hotkey.OnClosed(); - base.OnClosed(e); - } - } - - public class OverlayViewModel : INotifyPropertyChanged - { - public event PropertyChangedEventHandler PropertyChanged; - - public string F3074 => Tsf.F3074.GetState(); - public string F3074UL => Tsf.F3074UL.GetState(); - public string F7500 => Tsf.F7500.GetState(); - public string F27K => Tsf.F27K.GetState(); - public string F30K => Tsf.F30K.GetState(); - public string FFullGame => Tsf.FFullGame.GetState(); - - public OverlayViewModel() - { - Tsf.F3074.OnStateChanged += () => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(F3074))); - Tsf.F3074UL.OnStateChanged += () => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(F3074UL))); - Tsf.F7500.OnStateChanged += () => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(F7500))); - Tsf.F27K.OnStateChanged += () => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(F27K))); - Tsf.F30K.OnStateChanged += () => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(F30K))); - Tsf.FFullGame.OnStateChanged += () => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(FFullGame))); - } - } - - public class OnOrOffColorSelector : IValueConverter - { - public object Convert(object value, Type targetType, object parameter, CultureInfo culture) - { - if (!(value is string str)) return Colors.Orange; - switch (str.ToLower()) - { - case "on": - return Colors.GreenYellow; - case "off": - return Colors.Red; - default: - return Colors.BlueViolet; - } - } - - public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) - { - throw new Exception(); - } - } -} \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..e756854 --- /dev/null +++ b/README.md @@ -0,0 +1 @@ +# Thirty Seventy Four (3074) diff --git a/Tsf.cs b/Tsf.cs index 30ccc6d..992fa2e 100644 --- a/Tsf.cs +++ b/Tsf.cs @@ -1,9 +1,11 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Windows.Input; using NetLimiter.Service; +using tsf_3074.Data; -namespace _3074 +namespace tsf_3074 { public static class Tsf { @@ -28,12 +30,23 @@ namespace _3074 uint fs = 0, uint vk = 0) { var cli = GetClient(); - var name = GetFilterNameFor(portName); + var name = GetFilterNameFor(portName, upload); var filter = cli.Filters.FirstOrDefault(f => f.Name == name) ?? cli.AddFilter(new Filter(name) { - Functions = { new FFRemotePortInRange(new PortRangeFilterValue(portStart, portEnd)) } + Functions = + { + new FFPathEqual(Config.Instance.Destiny2Path), + new FFRemotePortInRange(new PortRangeFilterValue(portStart, portEnd)) + } }); + var hotkey = Config.Instance.Hotkeys[name]; + if (hotkey != null) + { + vk = hotkey.vk; + fs = hotkey.fs; + } + return new TsfFilter(name, filter, cli, upload, 1, fs, vk); } @@ -46,6 +59,13 @@ namespace _3074 { Functions = { new FFPathEqual(Config.Instance.Destiny2Path) } }); + var hotkey = Config.Instance.Hotkeys[name]; + if (hotkey != null) + { + vk = hotkey.vk; + fs = hotkey.fs; + } + return new TsfFilter(name, filter, cli, false, 811, fs, vk); } @@ -74,11 +94,20 @@ namespace _3074 private readonly NLClient _client; private readonly bool _upload; private readonly uint _limitSize; // the limit rate, normally is 1, only FG is 800 or 811 - private readonly uint _fs; - private readonly uint _vk; + + private uint _fs; + private uint _vk; + + public string Name => _name; + + public uint FS => _fs; + public uint VK => _vk; private Rule _rule; + /** + * Invoked every time the IsEnable is changed. (Maybe not actually changed, like from false to false.) + */ public event OnEvent OnStateChanged = delegate { }; public static readonly List AllFilters = new List(); @@ -94,32 +123,48 @@ namespace _3074 _fs = fs; _vk = vk; - GetLimitRule(); + InitLimitRule(); AllFilters.Add(this); } - public void RegisterHotkey(HotkeyManager manager) + ~TsfFilter() + { + AllFilters.Remove(this); + } + + /** + * Set the keybindings. Need to reload the hotkey manager manually! + */ + public void SetHotkey(uint vk, uint fs) + { + _vk = vk; + _fs = fs; + } + + /** + * Register the hotkey to the HotkeyManager. + */ + internal void RegisterHotkey(HotkeyManager manager) { if (_vk != 0) { - Console.WriteLine($"Registering Hotkey for {_name}"); + Console.WriteLine( + $"Registering Hotkey for {_name} {VirtualKey.GetModifierName(_fs)} + {KeyInterop.KeyFromVirtualKey((int)_vk)}"); manager.RegisterHotkey(_fs, _vk, () => { Toggle(); - var currState = GetLimitRule().IsEnabled ? "On" : "Off"; - Console.WriteLine($"Updated [{_name}] {currState}"); + Console.WriteLine($"Updated {_name} to {GetState()}"); }); } } - private Rule GetLimitRule() + private void InitLimitRule() { - if (_rule != null) return _rule; + if (_rule != null) return; var dir = _upload ? RuleDir.Out : RuleDir.In; _rule = _client.Rules.FirstOrDefault(r => r.FilterId == _filter.Id && r.Dir == dir) ?? _client.AddRule(_filter.Id, new LimitRule(dir, _limitSize) { IsEnabled = false }); - return _rule; } public void Toggle() diff --git a/OnEvent.cs b/Utils/OnEvent.cs similarity index 63% rename from OnEvent.cs rename to Utils/OnEvent.cs index d7735a1..83468e3 100644 --- a/OnEvent.cs +++ b/Utils/OnEvent.cs @@ -1,4 +1,4 @@ -namespace _3074 +namespace tsf_3074 { public delegate void OnEvent(); } \ No newline at end of file diff --git a/VirtualKey.cs b/Utils/VirtualKey.cs similarity index 95% rename from VirtualKey.cs rename to Utils/VirtualKey.cs index dc64ea2..e48daf8 100644 --- a/VirtualKey.cs +++ b/Utils/VirtualKey.cs @@ -1,4 +1,4 @@ -namespace _3074 +namespace tsf_3074 { public static class VirtualKey { @@ -8,6 +8,22 @@ public const uint ModShift = 0x0004; public const uint ModWin = 0x0008; + public static string GetModifierName(uint mod) + { + switch (mod) + { + case ModNone: return "None"; + case ModAlt: return "Alt"; + case ModCtrl: return "Ctrl"; + case ModShift: return "Shift"; + case ModCtrl | ModAlt: return "Ctrl + Alt"; + case ModCtrl | ModShift: return "Ctrl + Shift"; + case ModAlt | ModShift: return "Alt + Shift"; + case ModCtrl | ModAlt | ModShift: return "Ctrl + Shift + Alt"; + default: return "Unknown"; + } + } + // https://learn.microsoft.com/en-us/windows/win32/inputdev/virtual-key-codes public const uint VkLButton = 0x01; // Left mouse button diff --git a/Windows/ConfigWindow.xaml b/Windows/ConfigWindow.xaml new file mode 100644 index 0000000..01149f3 --- /dev/null +++ b/Windows/ConfigWindow.xaml @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/Windows/ConfigWindow.xaml.cs b/Windows/ConfigWindow.xaml.cs new file mode 100644 index 0000000..b3082d1 --- /dev/null +++ b/Windows/ConfigWindow.xaml.cs @@ -0,0 +1,74 @@ +using System; +using System.Windows; +using System.Windows.Input; +using tsf_3074.Windows.Dialog; + +namespace tsf_3074.Windows +{ + public partial class ConfigWindow : System.Windows.Window + { + // dont use this ctor, + // use #ShowConfigDialog instead! + public ConfigWindow() + { + InitializeComponent(); + } + + private void ButtonBase_OnClick(object sender, RoutedEventArgs e) + { + if (!(e.Source is System.Windows.Controls.Button button)) return; + + switch (button.Content) + { + case "3074": + SetHotkeyFor(Tsf.F3074); + break; + case "3074 UL": + SetHotkeyFor(Tsf.F3074UL); + break; + case "7500": + SetHotkeyFor(Tsf.F7500); + break; + case "27k": + SetHotkeyFor(Tsf.F27K); + break; + case "30k": + SetHotkeyFor(Tsf.F30K); + break; + case "FG": + SetHotkeyFor(Tsf.FFullGame); + break; + } + } + + /** + * Use KeybindingDialog to receive a keybinding, + * and set the value to TsfFilter. + * When the Config window exits, the hotkeys should be reloaded. + */ + private static void SetHotkeyFor(TsfFilter filter) + { + KeybindingDialog.OpenKeybindingDialog(out var key, out var fs, + KeyInterop.KeyFromVirtualKey((int)filter.VK), filter.FS); + // don't change if escape is pressed + if (key == Key.Escape) return; + filter.SetHotkey((uint)KeyInterop.VirtualKeyFromKey(key), fs); + } + + private static bool _showing; + + /** + * Use this static method to ensure there is only 1 instance of Config window. + */ + public static void ShowConfigDialog() + { + if (_showing) return; + _showing = true; + + var window = new ConfigWindow(); + window.Closed += (sender, e) => { _showing = false; }; + + window.ShowDialog(); + } + } +} \ No newline at end of file diff --git a/HotkeyManager.cs b/Windows/Control/HotkeyManager.cs similarity index 77% rename from HotkeyManager.cs rename to Windows/Control/HotkeyManager.cs index 9fc6fe4..71d6c9e 100644 --- a/HotkeyManager.cs +++ b/Windows/Control/HotkeyManager.cs @@ -1,16 +1,15 @@ using System; using System.Collections.Generic; using System.Runtime.InteropServices; -using System.Windows; using System.Windows.Interop; -namespace _3074 +namespace tsf_3074 { public partial class HotkeyManager { - private readonly Window _window; + private readonly System.Windows.Window _window; - public HotkeyManager(Window window) + public HotkeyManager(System.Windows.Window window) { _window = window; } @@ -22,6 +21,10 @@ namespace _3074 private const int HotkeyIdStart = 9000; private int _hotkeyId = HotkeyIdStart; + /** + * Register a hotkey with given modifiers and virtual keys. + * When the keybinding is pressed, the onEvent is invoked. + */ public void RegisterHotkey(uint fsModifiers, uint vk, OnEvent onEvent) { var thisId = _hotkeyId++; @@ -29,7 +32,7 @@ namespace _3074 _listener[thisId] = onEvent; } - private void UnregisterAllHotkeys() + public void UnregisterAllHotkeys() { var helper = new WindowInteropHelper(_window); while (_hotkeyId >= HotkeyIdStart) @@ -38,12 +41,18 @@ namespace _3074 } } + /** + * Add the hook to the Overlay window + */ public void OnSourceInitialized() { _source = HwndSource.FromHwnd(new WindowInteropHelper(_window).Handle) ?? throw new Exception(); _source.AddHook(HwndHook); } + /** + * Remove the hook from Overlay window + */ public void OnClosed() { _source.RemoveHook(HwndHook); @@ -51,6 +60,10 @@ namespace _3074 UnregisterAllHotkeys(); } + /** + * The callback for keybinding being pressed. + * Don't change this function, use registerHotkey! + */ private IntPtr HwndHook(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled) { if (msg != 0x0312) return IntPtr.Zero; diff --git a/Windows/Dialog/KeybindingDialog.xaml b/Windows/Dialog/KeybindingDialog.xaml new file mode 100644 index 0000000..eed8c33 --- /dev/null +++ b/Windows/Dialog/KeybindingDialog.xaml @@ -0,0 +1,14 @@ + + + + + diff --git a/Windows/Dialog/KeybindingDialog.xaml.cs b/Windows/Dialog/KeybindingDialog.xaml.cs new file mode 100644 index 0000000..368de5b --- /dev/null +++ b/Windows/Dialog/KeybindingDialog.xaml.cs @@ -0,0 +1,84 @@ +using System.ComponentModel; +using System.Windows.Input; + +namespace tsf_3074.Windows.Dialog +{ + public partial class KeybindingDialog : System.Windows.Window + { + private readonly KeybindingDialogView _data = new KeybindingDialogView(); + + public KeybindingDialog() + { + InitializeComponent(); + DataContext = _data; + } + + private void KeybindingDialog_OnKeyDown(object sender, KeyEventArgs e) + { + if (e.Key == Key.Return || e.Key == Key.Escape) + { + Close(); + return; + } + + var ctrl = Keyboard.IsKeyDown(Key.LeftCtrl); + var alt = Keyboard.IsKeyDown(Key.LeftAlt); + var shift = Keyboard.IsKeyDown(Key.LeftShift); + + var fs = ctrl ? VirtualKey.ModCtrl : + alt ? VirtualKey.ModAlt : + shift ? VirtualKey.ModShift : 0; + + _data.SetKey(e.Key, fs); + } + + public static void OpenKeybindingDialog(out Key key, out uint fs, Key initKey = Key.None, uint initFs = 0) + { + var dialog = new KeybindingDialog + { + _data = + { + Key = initKey, + Modifier = initFs + } + }; + + // wait for exit, and put the values to the ref fields + dialog.ShowDialog(); + + key = dialog._data.Key; + fs = dialog._data.Modifier; + } + } + + public class KeybindingDialogView : INotifyPropertyChanged + { + public event PropertyChangedEventHandler PropertyChanged; + + public string StatusText { get; set; } = "Press keys"; + + internal Key Key { get; set; } + internal uint Modifier { get; set; } + + public void SetKey(Key key, uint fsMod) + { + if (key == Key.LeftCtrl || key == Key.LeftAlt || key == Key.LeftShift || key == Key.System) return; + + Key = key; + Modifier = fsMod; + if (fsMod != 0) + { + var mod = fsMod == VirtualKey.ModCtrl ? "Ctrl" : + fsMod == VirtualKey.ModAlt ? "Alt" : + fsMod == VirtualKey.ModShift ? "Shift" : "Unknown"; + StatusText = $"{mod} + {Key}"; + } + else + { + StatusText = $"{Key}"; + } + + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(StatusText))); + } + } +} \ No newline at end of file diff --git a/OverlayWindow.xaml b/Windows/OverlayWindow.xaml similarity index 91% rename from OverlayWindow.xaml rename to Windows/OverlayWindow.xaml index a1e20e4..dddc195 100644 --- a/OverlayWindow.xaml +++ b/Windows/OverlayWindow.xaml @@ -1,16 +1,16 @@ - + FontFamily="Consolas" d:DataContext="{d:DesignInstance window:OverlayViewModel}"> diff --git a/Windows/OverlayWindow.xaml.cs b/Windows/OverlayWindow.xaml.cs new file mode 100644 index 0000000..a234535 --- /dev/null +++ b/Windows/OverlayWindow.xaml.cs @@ -0,0 +1,82 @@ +using System; +using System.ComponentModel; +using tsf_3074.Data; +using tsf_3074.Windows; + +namespace tsf_3074.Window +{ + public partial class OverlayWindow : System.Windows.Window + { + private readonly HotkeyManager _hotkey; + + public OverlayWindow() + { + InitializeComponent(); + DataContext = new OverlayViewModel(); + _hotkey = new HotkeyManager(this); + } + + private void ReloadAllHotkeys(bool firstTime = false) + { + if (!firstTime) _hotkey.UnregisterAllHotkeys(); + + TsfFilter.AllFilters.ForEach(f => f.RegisterHotkey(_hotkey)); + + // add additional global keybindings (Alt+C) + _hotkey.RegisterHotkey(VirtualKey.ModAlt, VirtualKey.VkC, () => + { + Console.WriteLine("Configuration hotkey is pressed!"); + _hotkey.UnregisterAllHotkeys(); // unregister all hotkeys to prevent the issue, which the taken hotkeys are not able to be read from the dialog. + ConfigWindow.ShowConfigDialog(); + Console.WriteLine("Configuration dialog exited."); + Console.WriteLine("Saving configuration!"); + TsfFilter.AllFilters.ForEach(f => + { + Config.Instance.Hotkeys[f.Name] = new HotkeyPair { vk = f.VK, fs = f.FS }; + }); + Config.SaveConfig(); + Console.WriteLine("Refreshing hotkeys!"); + ReloadAllHotkeys(); + }); + } + + protected override void OnSourceInitialized(EventArgs e) + { + base.OnSourceInitialized(e); + ReloadAllHotkeys(true); + _hotkey.OnSourceInitialized(); + } + + protected override void OnClosed(EventArgs e) + { + _hotkey.OnClosed(); + base.OnClosed(e); + } + } + + public class OverlayViewModel : INotifyPropertyChanged + { + public event PropertyChangedEventHandler PropertyChanged; + + public string F3074 => Tsf.F3074.GetState(); + public string F3074UL => Tsf.F3074UL.GetState(); + public string F7500 => Tsf.F7500.GetState(); + public string F27K => Tsf.F27K.GetState(); + public string F30K => Tsf.F30K.GetState(); + public string FFullGame => Tsf.FFullGame.GetState(); + + public OverlayViewModel() + { + Tsf.F3074.OnStateChanged += + () => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(F3074))); + Tsf.F3074UL.OnStateChanged += + () => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(F3074UL))); + Tsf.F7500.OnStateChanged += + () => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(F7500))); + Tsf.F27K.OnStateChanged += () => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(F27K))); + Tsf.F30K.OnStateChanged += () => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(F30K))); + Tsf.FFullGame.OnStateChanged += () => + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(FFullGame))); + } + } +} \ No newline at end of file diff --git a/manual_zh.md b/manual_zh.md new file mode 100644 index 0000000..87d63eb --- /dev/null +++ b/manual_zh.md @@ -0,0 +1,24 @@ +# TSF 3074 使用手册 + +## 安装 + +1. 首先你需要安装 NetLimiter 5,你可以从任何其他地方下载到。 +2. 启动 TSF,会有一个文件选择弹窗,选择你的命运2文件(destiny2.exe)。 +3. 按下 Alt + C 打开快捷键设置窗口,你可以在这个窗口改绑按键。 +4. 例如点击 *3074 UL* 按钮,会弹出按键读取窗口,按下任意按键,例如 K,然后按回车,窗口会关闭。 +5. 所有按键改动完成后,右上角X掉,就会自动保存你的设置。 +6. 然后就可以进游戏用了。 + +## FAQ + +### 开不起来?有问题? + +如果你遇到了疑难杂症,可以先看看目录下有没有以 `tsf-crash_` 开头的文件,把他发送给我。 + +### 开源? + +为了避免被棒鸡检测特征,所以不公开分享源码,如果你希望为项目做贡献,可以联系我。 + +### 怎么找到作者? + +你可以通过邮箱 r0yalist¥outlook.com 联系到我。