diff --git a/README.md b/README.md index 355941b7..1d0335f9 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,10 @@ # Toontown-Rewritten-Bot +> [!WARNING] +> Toontown Rewritten prohibits third-party programs that automate gameplay. Using this bot can +> result in penalties, including account or Toon stat action. Randomized timing or cursor movement +> does not make automation undetectable or safe. Use is entirely at your own risk. + Table of Contents - [Toontown-Rewritten-Bot](#toontown-rewritten-bot) diff --git a/ToonTown Rewritten Bot/Models/GameControls.cs b/ToonTown Rewritten Bot/Models/GameControls.cs index 7f7c5df0..8a05cfd8 100644 --- a/ToonTown Rewritten Bot/Models/GameControls.cs +++ b/ToonTown Rewritten Bot/Models/GameControls.cs @@ -44,6 +44,25 @@ public static VirtualKeyCode Remap(VirtualKeyCode key) }; } + /// + /// Maps a raw Windows virtual-key code from the global recorder to a movement action. + /// This keeps custom-action recording aligned with the bindings configured in the app. + /// + public static string GetMovementAction(int virtualKeyCode) + { + if (virtualKeyCode == (int)Forward) return "WALK FORWARDS"; + if (virtualKeyCode == (int)Reverse) return "WALK BACKWARDS"; + if (virtualKeyCode == (int)Left) return "TURN LEFT"; + if (virtualKeyCode == (int)Right) return "TURN RIGHT"; + return null; + } + + public static string GetMovementBindingSummary() + { + return $"{GetDisplayName(Forward)}, {GetDisplayName(Reverse)}, " + + $"{GetDisplayName(Left)}, {GetDisplayName(Right)}"; + } + /// /// Applies the saved bindings from to the runtime fields. /// Unparseable or empty values fall back to the TTR default for that control. diff --git a/ToonTown Rewritten Bot/Program.cs b/ToonTown Rewritten Bot/Program.cs index 74c838b5..b0c89edf 100644 --- a/ToonTown Rewritten Bot/Program.cs +++ b/ToonTown Rewritten Bot/Program.cs @@ -14,8 +14,8 @@ static class Program [STAThread] static void Main() { + Application.SetHighDpiMode(HighDpiMode.PerMonitorV2); Application.EnableVisualStyles(); - Application.SetHighDpiMode(HighDpiMode.SystemAware); Application.SetCompatibleTextRenderingDefault(false); Application.Run(new MainForm()); } diff --git a/ToonTown Rewritten Bot/Properties/AssemblyInfo.cs b/ToonTown Rewritten Bot/Properties/AssemblyInfo.cs index cefdd305..3505de2b 100644 --- a/ToonTown Rewritten Bot/Properties/AssemblyInfo.cs +++ b/ToonTown Rewritten Bot/Properties/AssemblyInfo.cs @@ -32,5 +32,5 @@ // You can specify all the values or you can default the Build and Revision Numbers // by using the '*' as shown below: // [assembly: AssemblyVersion("1.0.*")] -[assembly: AssemblyVersion("2.6.0.0")] -[assembly: AssemblyFileVersion("2.6.0.0")] +[assembly: AssemblyVersion("2.7.0.0")] +[assembly: AssemblyFileVersion("2.7.0.0")] diff --git a/ToonTown Rewritten Bot/Services/CoordinatesManager.cs b/ToonTown Rewritten Bot/Services/CoordinatesManager.cs index fd151c5c..cd883a5a 100644 --- a/ToonTown Rewritten Bot/Services/CoordinatesManager.cs +++ b/ToonTown Rewritten Bot/Services/CoordinatesManager.cs @@ -121,12 +121,61 @@ private static CoordinateActions FindCoordinateByKey(string key) Logger.Debug("Coordinates", $"Manual coords available for '{elementName}': ({manualCoords.x}, {manualCoords.y})"); UIElementManager.Instance.SetManualCoordinates(elementName, new Point(manualCoords.x, manualCoords.y)); } + else + { + // Keep UIElementManager's separate fallback store synchronized with the legacy + // coordinate file. Reset State previously zeroed this file but left a stale manual + // value (notably Red Fishing Button at 960,765) active in UIElementManager. + UIElementManager.Instance.ClearManualCoordinates(elementName); + } // Try to find using image recognition (will prompt for template capture if needed) var (location, source) = await UIElementManager.Instance.GetElementLocationWithSourceAsync(elementName, description); if (location.HasValue) { + if (source == UIElementSource.Manual) + { + // Manual updates are captured from the OS cursor, so they are already absolute + // screen coordinates. Adding the window offset again breaks moved/multi-monitor + // game windows. + var windowRect = CoreFunctionality.GetGameWindowRect(); + if (windowRect.IsEmpty) + { + throw new InvalidOperationException("Could not use manual coordinates because the Toontown window was not found."); + } + + if (!windowRect.Contains(location.Value)) + { + Logger.Warning( + "Coordinates", + $"Ignoring manual coordinates for '{elementName}' at ({location.Value.X}, {location.Value.Y}); " + + $"they are outside the game window {windowRect}."); + + // Remove the invalid value from both stores, then force a real search. If it + // still fails, UIElementManager will guide the user through recapture. + UpdateCoordinateByKey(keyAsString, 0, 0); + UIElementManager.Instance.ClearManualCoordinates(elementName); + (location, source) = await UIElementManager.Instance.GetElementLocationWithSourceAsync( + elementName, + description, + forceSearch: true); + } + else + { + Logger.Info( + "Coordinates", + $"'{elementName}': screen ({location.Value.X}, {location.Value.Y}) [source=Manual]"); + return (location.Value.X, location.Value.Y); + } + } + + if (!location.HasValue) + { + throw new InvalidOperationException( + $"Could not locate '{elementName}'. Recapture its template before retrying."); + } + // Convert window-relative coordinates to screen coordinates var windowOffset = CoreFunctionality.GetGameWindowOffset(); int screenX = location.Value.X + windowOffset.X; @@ -137,60 +186,9 @@ private static CoordinateActions FindCoordinateByKey(string key) return (screenX, screenY); } - // If still not found, fall back to manual coordinates from the old system - // Manual coordinates are already screen coordinates - if (manualCoords.x != 0 || manualCoords.y != 0) - { - Logger.Info("Coordinates", $"'{elementName}': screen ({manualCoords.x}, {manualCoords.y}) [source=ManualFallback]"); - return manualCoords; - } - - // Offer the user a chance to recapture the template before giving up - // Bring bot window to front so the dialog is visible over TTR - DialogResult recaptureChoice = DialogResult.No; - if (Application.OpenForms.Count > 0 && Application.OpenForms[0].InvokeRequired) - { - Application.OpenForms[0].Invoke(new Action(() => - { - CoreFunctionality.BringBotWindowToFront(); - recaptureChoice = MessageBox.Show( - $"Could not find '{elementName}' on screen. Would you like to capture the template for this element?", - "Element Not Found", - MessageBoxButtons.YesNo, - MessageBoxIcon.Question); - })); - } - else - { - CoreFunctionality.BringBotWindowToFront(); - recaptureChoice = MessageBox.Show( - $"Could not find '{elementName}' on screen. Would you like to capture the template for this element?", - "Element Not Found", - MessageBoxButtons.YesNo, - MessageBoxIcon.Question); - } - - if (recaptureChoice == DialogResult.Yes) - { - Logger.Info("Coordinates", $"User chose to recapture template for '{elementName}'"); - bool captured = UIElementManager.Instance.PromptForTemplateCapture(elementName, description ?? $"Please select the '{elementName}' on screen"); - if (captured) - { - // Retry with the newly captured template - var retryLocation = await UIElementManager.Instance.GetElementLocationAsync(elementName, description, forceSearch: true); - if (retryLocation.HasValue) - { - var windowOffset = CoreFunctionality.GetGameWindowOffset(); - int screenX = retryLocation.Value.X + windowOffset.X; - int screenY = retryLocation.Value.Y + windowOffset.Y; - Logger.Info("Coordinates", $"'{elementName}': screen ({screenX}, {screenY}) [source=Recapture]"); - return (screenX, screenY); - } - } - } - Logger.Error("Coordinates", $"Could not find '{elementName}' via image recognition or manual coordinates"); - throw new Exception($"Could not find element '{elementName}' via image recognition or manual coordinates."); + throw new InvalidOperationException( + $"Could not locate '{elementName}'. Recapture its template from the prompt or Dev tab before retrying."); } /// diff --git a/ToonTown Rewritten Bot/Services/CustomGolfActions/CustomActionsGolf.cs b/ToonTown Rewritten Bot/Services/CustomGolfActions/CustomActionsGolf.cs index 5c604abf..653d5628 100644 --- a/ToonTown Rewritten Bot/Services/CustomGolfActions/CustomActionsGolf.cs +++ b/ToonTown Rewritten Bot/Services/CustomGolfActions/CustomActionsGolf.cs @@ -1,4 +1,3 @@ -using Newtonsoft.Json; using System; using System.Collections.Generic; using System.IO; @@ -6,6 +5,7 @@ using System.Threading; using WindowsInput; using ToonTown_Rewritten_Bot.Models; +using ToonTown_Rewritten_Bot.Utilities; using System.Windows.Forms; namespace ToonTown_Rewritten_Bot.Services.CustomGolfActions @@ -50,19 +50,30 @@ public CustomActionsGolf(string filePath) private void LoadActionsFromJson(string filePath) { - if (File.Exists(filePath)) + var result = CustomGolfActionFileManager.Load(filePath); + if (!result.Success) { - string json = File.ReadAllText(filePath); - actions = JsonConvert.DeserializeObject>(json); + throw new InvalidDataException( + $"Could not load golf action file: {result.ErrorMessage}"); } - } - private async Task PrepareToHitBall() - { - var up = GameControls.Remap(VirtualKeyCode.UP); - InputSimulator.SimulateKeyDown(up); - await Task.Delay(50); - InputSimulator.SimulateKeyUp(up); + actions = result.File?.Actions ?? new List(); + if (actions.Count == 0) + { + throw new InvalidDataException("The selected golf action file contains no actions."); + } + + if (actions.Exists(action => action.Duration <= 0)) + { + throw new InvalidDataException( + "Every golf action must have a duration greater than zero."); + } + + if (!actions.Exists(action => action.Action == "SWING POWER")) + { + throw new InvalidDataException( + "The selected golf action file has no SWING POWER action, so it cannot shoot."); + } } private void ReportProgress(int currentStep, string currentAction, string nextAction, int durationMs) @@ -84,17 +95,21 @@ private void ReportStatus(string status) public async Task PerformGolfActions(CancellationToken cancellationToken) { - CoreFunctionality.FocusTTRWindow(); - ReportStatus("Starting"); - await Task.Delay(1000, cancellationToken); - GolfActionKeys keys = new GolfActionKeys(); + // Background mode is a fishing preference. Golf relies on foreground SendInput, + // so temporarily disable it to ensure FocusTTRWindow and key delivery agree. + bool previousBackgroundMode = CoreFunctionality.UseBackgroundInput; + CoreFunctionality.UseBackgroundInput = false; try { + CoreFunctionality.FocusTTRWindow(); + ReportStatus("Starting"); + await Task.Delay(1000, cancellationToken); + GolfActionKeys keys = new GolfActionKeys(); + ReportStatus("Running"); int executedStep = 0; - int totalExecutableActions = TotalActions; for (int i = 0; i < actions.Count; i++) { @@ -134,11 +149,16 @@ public async Task PerformGolfActions(CancellationToken cancellationToken) // Process other actions that should correspond to actual key presses if (keys.ActionKeyMap.TryGetValue(actionCommand.Action, out VirtualKeyCode keyCode)) { - // Translate the default control key into whatever the user has bound in TTR. - keyCode = GameControls.Remap(keyCode); - InputSimulator.SimulateKeyDown(keyCode); - await Task.Delay(actionCommand.Duration, cancellationToken); - InputSimulator.SimulateKeyUp(keyCode); + CoreFunctionality.SendKeyDown(keyCode); + try + { + await Task.Delay(actionCommand.Duration, cancellationToken); + } + finally + { + // Always release held keys, including when the user cancels mid-swing. + CoreFunctionality.SendKeyUp(keyCode); + } } else { @@ -154,6 +174,10 @@ public async Task PerformGolfActions(CancellationToken cancellationToken) ReportStatus("Cancelled"); throw; } + finally + { + CoreFunctionality.UseBackgroundInput = previousBackgroundMode; + } } } -} \ No newline at end of file +} diff --git a/ToonTown Rewritten Bot/Services/GolfService.cs b/ToonTown Rewritten Bot/Services/GolfService.cs index 71ef4003..b81947eb 100644 --- a/ToonTown Rewritten Bot/Services/GolfService.cs +++ b/ToonTown Rewritten Bot/Services/GolfService.cs @@ -1,7 +1,7 @@ -using Newtonsoft.Json; using System; using System.Diagnostics; using System.IO; +using System.Linq; using System.Threading; using System.Threading.Tasks; using System.Windows.Forms; @@ -82,10 +82,6 @@ public static async Task StartCustomGolfAction(string filePath, CancellationToke Debug.WriteLine("Golf operation was canceled by the user."); throw; } - catch (Exception ex) - { - MessageBox.Show($"An error occurred: {ex.Message}"); - } } /// @@ -182,10 +178,15 @@ public static async Task StartContinuousAutoGolfAsync(CancellationToken cancella { await detector.InitializeAsync(); - // Subscribe to detector status changes for overlay updates + int holesPlayed = 0; + string lastPlayedCourse = null; + + // Keep both the overlay and the main status label informative while the detector + // is waiting, confirming the timer, or guiding a recapture. detector.StatusChanged += (status) => { UpdateOverlayStatus(status); + RaiseStatusChanged(status, null, holesPlayed, holesPerRound); }; RaiseStatusChanged("Auto-golf started - waiting for course...", null, 0, holesPerRound); @@ -196,9 +197,6 @@ public static async Task StartContinuousAutoGolfAsync(CancellationToken cancella UpdateOverlayStatus("Auto-golf active"); } - int holesPlayed = 0; - string lastPlayedCourse = null; - while (!cancellationToken.IsCancellationRequested && holesPlayed < holesPerRound) { // Wait for course detection @@ -377,14 +375,14 @@ private static void HideOverlayInternal() public static GolfActionCommand[] GetCustomGolfActions(string filePath) { - if (!File.Exists(filePath)) + var result = CustomGolfActionFileManager.Load(filePath); + if (!result.Success) { - Debug.WriteLine("File does not exist: " + filePath); + Debug.WriteLine($"Could not load golf action file: {result.ErrorMessage}"); return Array.Empty(); } - string json = File.ReadAllText(filePath); - return JsonConvert.DeserializeObject(json); + return result.File?.Actions?.ToArray() ?? Array.Empty(); } public static string GetCustomGolfActionFilePath(string fileName) diff --git a/ToonTown Rewritten Bot/Templates/UIElementCoordinates.json b/ToonTown Rewritten Bot/Templates/UIElementCoordinates.json index a685a64d..57e527b5 100644 --- a/ToonTown Rewritten Bot/Templates/UIElementCoordinates.json +++ b/ToonTown Rewritten Bot/Templates/UIElementCoordinates.json @@ -65,9 +65,9 @@ "Templates\\Red_Fishing_Button.png" ], "TemplatePath": "Templates\\Red_Fishing_Button.png", - "ManualCoordinates": "960, 765", - "CachedCenter": "966, 782", - "LastFoundTime": "2026-03-03T15:35:57.4550924-05:00" + "ManualCoordinates": null, + "CachedCenter": null, + "LastFoundTime": null }, "Green SpeedChat Button": { "Name": "Green SpeedChat Button", @@ -315,4 +315,4 @@ "CachedCenter": null, "LastFoundTime": null } -} \ No newline at end of file +} diff --git a/ToonTown Rewritten Bot/ToonTown Rewritten Bot.csproj b/ToonTown Rewritten Bot/ToonTown Rewritten Bot.csproj index 62e23106..116aa4a8 100644 --- a/ToonTown Rewritten Bot/ToonTown Rewritten Bot.csproj +++ b/ToonTown Rewritten Bot/ToonTown Rewritten Bot.csproj @@ -14,7 +14,7 @@ false true 0 - 2.6.0.0 + 2.7.0.0 false true true @@ -138,4 +138,4 @@ - \ No newline at end of file + diff --git a/ToonTown Rewritten Bot/Utilities/DurationFormatter.cs b/ToonTown Rewritten Bot/Utilities/DurationFormatter.cs index aa039c5e..f6b5817c 100644 --- a/ToonTown Rewritten Bot/Utilities/DurationFormatter.cs +++ b/ToonTown Rewritten Bot/Utilities/DurationFormatter.cs @@ -1,6 +1,6 @@ using System; -using System.Linq; -using System.Text; +using System.Globalization; +using System.Text.RegularExpressions; namespace ToonTown_Rewritten_Bot.Utilities { @@ -17,17 +17,7 @@ public static class DurationFormatter public static string FormatSeconds(long milliseconds) { double seconds = milliseconds / 1000.0; - - if (seconds < 10) - { - // Show one decimal place for shorter durations - return $"{seconds:0.#}s"; - } - else - { - // Show as whole seconds for longer durations - return $"{Math.Round(seconds)}s"; - } + return seconds.ToString("0.###", CultureInfo.InvariantCulture) + "s"; } /// @@ -65,58 +55,33 @@ public static int ParseToMilliseconds(string input) if (string.IsNullOrWhiteSpace(input)) return 0; - // First, try to extract just digits (handles "TIME (500 milliseconds)" format) - string digitsOnly = new string(input.Where(char.IsDigit).ToArray()); - if (int.TryParse(digitsOnly, out int msValue)) - { - // If the input contains "s" or "sec" but NOT "ms" or "milliseconds", - // treat the number as seconds - string lower = input.ToLowerInvariant(); - bool hasSeconds = lower.Contains("sec") || (lower.Contains("s") && !lower.Contains("ms") && !lower.Contains("millisec")); - - if (hasSeconds) - { - // Number is in seconds, convert to milliseconds - // But first check if there's a decimal point we might have missed - return msValue * 1000; - } - - return msValue; - } + Match numericMatch = Regex.Match(input, @"[+-]?(?:\d+(?:[.,]\d+)?|[.,]\d+)"); + if (!numericMatch.Success) + return 0; - // Try parsing as a decimal for formats like "0.5s" - // Extract the numeric part including decimal point - var numericPart = new StringBuilder(); - bool foundDecimal = false; - foreach (char c in input) + string numericValue = numericMatch.Value.Replace(',', '.'); + if (!double.TryParse(numericValue, + NumberStyles.AllowLeadingSign | NumberStyles.AllowDecimalPoint, + CultureInfo.InvariantCulture, + out double value)) { - if (char.IsDigit(c)) - { - numericPart.Append(c); - } - else if (c == '.' && !foundDecimal) - { - numericPart.Append(c); - foundDecimal = true; - } + return 0; } - if (double.TryParse(numericPart.ToString(), out double value)) + string lower = input.ToLowerInvariant(); + bool hasMilliseconds = Regex.IsMatch(lower, + @"(? int.MaxValue || + double.IsNaN(milliseconds) || double.IsInfinity(milliseconds)) { - string lower = input.ToLowerInvariant(); - if (lower.Contains("s") && !lower.Contains("ms")) - { - // Seconds - convert to milliseconds - return (int)(value * 1000); - } - else - { - // Assume milliseconds - return (int)value; - } + return 0; } - return 0; + return (int)Math.Round(milliseconds, MidpointRounding.AwayFromZero); } /// diff --git a/ToonTown Rewritten Bot/Utilities/FishBubbleDetector.cs b/ToonTown Rewritten Bot/Utilities/FishBubbleDetector.cs index 5137adfa..c9a1308b 100644 --- a/ToonTown Rewritten Bot/Utilities/FishBubbleDetector.cs +++ b/ToonTown Rewritten Bot/Utilities/FishBubbleDetector.cs @@ -585,6 +585,10 @@ public async Task DetectFishAndCalculateCastAsync(CancellationTok return ScanForBubbleByColor(screenshot, sStartX, sStartY, sEndX, sEndY, windowOffset, cancellationToken); } } + catch (WindowCaptureException) + { + throw; + } catch (Exception ex) { Logger.Warning("FishDetect", $"Scan error: {ex.Message}"); diff --git a/ToonTown Rewritten Bot/Utilities/GlobalKeyboardHook.cs b/ToonTown Rewritten Bot/Utilities/GlobalKeyboardHook.cs index 763b5f86..3171c4a0 100644 --- a/ToonTown Rewritten Bot/Utilities/GlobalKeyboardHook.cs +++ b/ToonTown Rewritten Bot/Utilities/GlobalKeyboardHook.cs @@ -22,6 +22,9 @@ public class GlobalKeyboardHook : IDisposable private LowLevelKeyboardProc _proc; private bool _disposed = false; + public bool IsRunning => _hookId != IntPtr.Zero; + public int LastErrorCode { get; private set; } + /// /// When true, the next handled key press will be suppressed (not passed to the game). /// Set by the event handler to consume the key. @@ -64,10 +67,15 @@ public GlobalKeyboardHook() /// /// Starts listening for global keyboard events. /// - public void Start() + public bool Start() { if (_hookId != IntPtr.Zero) - return; // Already hooked + return true; // Already hooked + + if (_disposed) + { + throw new ObjectDisposedException(nameof(GlobalKeyboardHook)); + } using (Process curProcess = Process.GetCurrentProcess()) using (ProcessModule curModule = curProcess.MainModule) @@ -77,13 +85,14 @@ public void Start() if (_hookId == IntPtr.Zero) { - int errorCode = Marshal.GetLastWin32Error(); - Logger.Error("Input", $"Failed to install keyboard hook (error {errorCode}). F12/Esc hotkeys will not work."); - } - else - { - Logger.Debug("Input", "Global keyboard hook installed successfully"); + LastErrorCode = Marshal.GetLastWin32Error(); + Logger.Error("Input", $"Failed to install global keyboard hook (Windows error {LastErrorCode})."); + return false; } + + LastErrorCode = 0; + Logger.Debug("Input", "Global keyboard hook installed successfully"); + return true; } /// diff --git a/ToonTown Rewritten Bot/Utilities/GlobalSettings.cs b/ToonTown Rewritten Bot/Utilities/GlobalSettings.cs index 66de36eb..8b4fcd82 100644 --- a/ToonTown Rewritten Bot/Utilities/GlobalSettings.cs +++ b/ToonTown Rewritten Bot/Utilities/GlobalSettings.cs @@ -4,7 +4,7 @@ public class GlobalSettings { public static class ApplicationInfo { - public static string Version { get; } = "2.6.0"; + public static string Version { get; } = "2.7.0"; public static string Author { get; } = "primetime43"; } } diff --git a/ToonTown Rewritten Bot/Utilities/GolfCourseDetector.cs b/ToonTown Rewritten Bot/Utilities/GolfCourseDetector.cs index 3815775e..515ab7d6 100644 --- a/ToonTown Rewritten Bot/Utilities/GolfCourseDetector.cs +++ b/ToonTown Rewritten Bot/Utilities/GolfCourseDetector.cs @@ -20,6 +20,7 @@ public class GolfCourseDetector : IDisposable private TextRecognition _ocr; private bool _disposed = false; private string _pencilButtonTemplatePath; + private bool _turnTimerTemplateMismatch; /// /// Event raised when detection status changes. @@ -160,6 +161,10 @@ public string DetectCourse() } } } + catch (WindowCaptureException) + { + throw; + } catch (Exception ex) { System.Diagnostics.Debug.WriteLine($"[GolfDetector] Error detecting course: {ex.Message}"); @@ -267,6 +272,10 @@ public bool IsScoreboardOpen() return isOpen; } } + catch (WindowCaptureException) + { + throw; + } catch (Exception ex) { Debug.WriteLine($"[GolfDetector] Error in IsScoreboardOpen: {ex.Message}"); @@ -391,6 +400,10 @@ public async Task CloseScoreboardAsync() return; } } + catch (WindowCaptureException) + { + throw; + } catch (Exception ex) { Debug.WriteLine($"[GolfDetector] Error closing scoreboard: {ex.Message}"); @@ -582,6 +595,10 @@ private string PromptForPencilButtonTemplate() } } } + catch (WindowCaptureException) + { + throw; + } catch (Exception ex) { Debug.WriteLine($"[GolfDetector] Error finding pencil button: {ex.Message}"); @@ -622,6 +639,10 @@ public async Task ClickPencilButtonAsync() Debug.WriteLine($"[GolfDetector] Clicked pencil button at ({clickX}, {clickY})"); return true; } + catch (WindowCaptureException) + { + throw; + } catch (Exception ex) { Debug.WriteLine($"[GolfDetector] Error clicking pencil button: {ex.Message}"); @@ -664,6 +685,10 @@ public async Task DetectCourseViaScoreboardAsync() return course; } + catch (WindowCaptureException) + { + throw; + } catch (Exception ex) { ReportStatus($"Error: {ex.Message}"); @@ -836,6 +861,11 @@ private async Task PromptForManualCourseSelectionAsync() } private const string TurnTimerTemplateName = "Golf_Turn_Timer"; + private const double TurnTimerTemplateThreshold = 0.80; + private const int RequiredConsecutiveTurnDetections = 2; + private const int MissingTemplatePromptDelayMs = 15000; + private const int ExistingTemplatePromptDelayMs = 30000; + private const int WaitingStatusIntervalMs = 10000; /// /// Checks if the game is ready for the player to swing. @@ -843,37 +873,83 @@ private async Task PromptForManualCourseSelectionAsync() /// public bool IsReadyToSwing() { + _turnTimerTemplateMismatch = false; + try { using (var screenshot = (Bitmap)ImageRecognition.GetWindowScreenshot()) { if (screenshot == null) return false; - // Method 1: Template matching for the turn timer (most reliable if captured) - if (UIElementManager.Instance.HasTemplate(TurnTimerTemplateName)) + Rectangle timerRegion = GetTurnTimerSearchRegion(screenshot.Size); + + // Search only where the timer can appear. Full-window matching was both slow and + // susceptible to finding a similar orange graphic elsewhere on the course. + using (var timerImage = screenshot.Clone(timerRegion, screenshot.PixelFormat)) { - string templatePath = UIElementManager.Instance.GetTemplatePath(TurnTimerTemplateName); - using (var template = new Bitmap(templatePath)) + bool checkedUsableTemplate = false; + + // Try every captured variant so users can save timer appearances from + // different resolutions or countdown frames. + foreach (string templatePath in UIElementManager.Instance.GetAllTemplatePaths(TurnTimerTemplateName)) { - var result = ImageTemplateMatcher.FindTemplate(screenshot, template, 0.7); - if (result.Found) + try + { + using (var template = new Bitmap(templatePath)) + { + if (template.Width > timerImage.Width || template.Height > timerImage.Height) + { + Debug.WriteLine($"[GolfDetector] Skipping oversized timer template: {Path.GetFileName(templatePath)}"); + continue; + } + + checkedUsableTemplate = true; + var result = ImageTemplateMatcher.FindTemplate( + timerImage, + template, + TurnTimerTemplateThreshold); + if (result.Found) + { + Debug.WriteLine( + $"[GolfDetector] Timer template found in expected region " + + $"({Path.GetFileName(templatePath)}, confidence: {result.Confidence:P0})"); + return true; + } + } + } + catch (Exception ex) { - Debug.WriteLine($"[GolfDetector] Ready to swing - timer template found (confidence: {result.Confidence:P0})"); - return true; + // A corrupt or obsolete variant should not disable the color fallback + // or prevent another valid variant from being checked. + Debug.WriteLine($"[GolfDetector] Could not check timer template '{templatePath}': {ex.Message}"); } } - } - // Method 2: Detect the orange countdown timer by color in the top-right corner - if (DetectTurnTimerByColor(screenshot)) - { - Debug.WriteLine("[GolfDetector] Ready to swing - timer detected by color"); - return true; + bool colorDetected = DetectTurnTimerByColor(timerImage); + + // Once the user has a usable template, treat it as authoritative. Falling + // through to the looser color heuristic after a template miss caused random + // starts on orange course graphics. If color suggests the real timer is now + // visible, use that moment to offer recapture instead of starting the shot. + if (checkedUsableTemplate && colorDetected) + { + _turnTimerTemplateMismatch = true; + Debug.WriteLine("[GolfDetector] Orange timer candidate found, but saved templates did not match"); + } + else if (colorDetected) + { + Debug.WriteLine("[GolfDetector] Timer detected by color in expected region"); + return true; + } } return false; } } + catch (WindowCaptureException) + { + throw; + } catch (Exception ex) { Debug.WriteLine($"[GolfDetector] Error checking ready state: {ex.Message}"); @@ -881,20 +957,29 @@ public bool IsReadyToSwing() } } + private static Rectangle GetTurnTimerSearchRegion(Size screenshotSize) + { + // The countdown clock is near the upper-right corner. Keep enough padding for + // different window sizes while excluding most course graphics from consideration. + int width = Math.Max(1, (int)Math.Ceiling(screenshotSize.Width * 0.25)); + int height = Math.Max(1, (int)Math.Ceiling(screenshotSize.Height * 0.22)); + return new Rectangle(screenshotSize.Width - width, 0, width, height); + } + /// /// Detects the orange countdown timer in the top-right corner. /// The timer is an orange/yellow circular clock that appears when it's your turn. /// - private bool DetectTurnTimerByColor(Bitmap screenshot) + private bool DetectTurnTimerByColor(Bitmap timerImage) { try { - // The timer is in the top-right corner of the screen - // Sample the area where the orange clock appears - int timerCenterX = screenshot.Width - (int)(screenshot.Width * 0.05); // ~5% from right edge - int timerCenterY = (int)(screenshot.Height * 0.07); // ~7% from top + // Coordinates are relative to the cropped top-right search region. The clock's + // expected full-window position (~95% across, ~7% down) lands near here. + int timerCenterX = (int)(timerImage.Width * 0.80); + int timerCenterY = (int)(timerImage.Height * 0.32); - int searchRadius = Math.Min(screenshot.Width, screenshot.Height) / 15; + int searchRadius = Math.Max(4, (int)(timerImage.Height * 0.30)); int orangePixelCount = 0; int totalSamples = 0; @@ -906,10 +991,10 @@ private bool DetectTurnTimerByColor(Bitmap screenshot) int x = timerCenterX + xOffset; int y = timerCenterY + yOffset; - if (x < 0 || x >= screenshot.Width || y < 0 || y >= screenshot.Height) + if (x < 0 || x >= timerImage.Width || y < 0 || y >= timerImage.Height) continue; - Color pixel = screenshot.GetPixel(x, y); + Color pixel = timerImage.GetPixel(x, y); totalSamples++; // Orange color: high R, medium-high G, low B @@ -973,6 +1058,10 @@ public bool IsTeeSelectionScreen() lowerText.Contains("left") && lowerText.Contains("right"); } } + catch (WindowCaptureException) + { + throw; + } catch { return false; @@ -984,23 +1073,72 @@ public bool IsTeeSelectionScreen() /// public async Task WaitUntilReadyToSwingAsync(CancellationToken cancellationToken, int scanIntervalMs = 500) { - int attempts = 0; - const int promptAfterAttempts = 30; // Prompt for template after ~15 seconds of not detecting + if (scanIntervalMs <= 0) + { + throw new ArgumentOutOfRangeException(nameof(scanIntervalMs), "Scan interval must be greater than zero."); + } + + int consecutiveDetections = 0; + int nextStatusAtMs = WaitingStatusIntervalMs; + bool recoveryOffered = false; + var waitTimer = Stopwatch.StartNew(); + + ReportStatus("Waiting for your turn - watching for the orange timer..."); while (!cancellationToken.IsCancellationRequested) { if (IsReadyToSwing()) { - return; + consecutiveDetections++; + if (consecutiveDetections >= RequiredConsecutiveTurnDetections) + { + ReportStatus("Turn confirmed - starting shot..."); + return; + } + + ReportStatus("Turn timer found - confirming..."); + } + else + { + // A single-frame result can be a transient course graphic. Require the timer + // to survive the next scan before allowing the macro to start. + if (consecutiveDetections > 0) + { + ReportStatus("Timer signal was brief - still waiting for your turn..."); + } + consecutiveDetections = 0; } - attempts++; + int elapsedMs = (int)Math.Min(int.MaxValue, waitTimer.ElapsedMilliseconds); + + if (elapsedMs >= nextStatusAtMs) + { + int elapsedSeconds = elapsedMs / 1000; + ReportStatus($"Still waiting for your turn ({elapsedSeconds}s) - looking for the orange timer..."); + nextStatusAtMs += WaitingStatusIntervalMs; + } - // If we've been waiting a while and don't have a timer template, offer to capture one - if (attempts == promptAfterAttempts && !UIElementManager.Instance.HasTemplate(TurnTimerTemplateName)) + bool hasTemplate = UIElementManager.Instance.HasTemplate(TurnTimerTemplateName); + int recoveryDelayMs = hasTemplate ? ExistingTemplatePromptDelayMs : MissingTemplatePromptDelayMs; + bool templateMismatchNowVisible = hasTemplate && _turnTimerTemplateMismatch; + if (!recoveryOffered && (templateMismatchNowVisible || elapsedMs >= recoveryDelayMs)) { - ReportStatus("Having trouble detecting turn - capture template?"); - PromptForTurnTimerTemplate(); + recoveryOffered = true; + ReportStatus(templateMismatchNowVisible + ? "Orange timer found, but the saved template is outdated - recapture help opened" + : hasTemplate + ? "Turn timer not detected - recapture help opened" + : "Turn timer template needed - capture help opened"); + + bool captured = PromptForTurnTimerTemplate( + allowRecapture: hasTemplate, + timerCandidateVisible: templateMismatchNowVisible); + ReportStatus(captured + ? "Timer template saved - resuming turn detection..." + : "Continuing to wait for the orange turn timer..."); + + // Do not count a frame from before a modal capture dialog toward confirmation. + consecutiveDetections = 0; } await Task.Delay(scanIntervalMs, cancellationToken); @@ -1010,35 +1148,52 @@ public async Task WaitUntilReadyToSwingAsync(CancellationToken cancellationToken /// /// Prompts the user to capture the turn timer template. /// - private void PromptForTurnTimerTemplate() + private bool PromptForTurnTimerTemplate(bool allowRecapture, bool timerCandidateVisible) { - if (UIElementManager.Instance.HasTemplate(TurnTimerTemplateName)) + bool hasTemplate = UIElementManager.Instance.HasTemplate(TurnTimerTemplateName); + if (hasTemplate && !allowRecapture) { - return; + return false; } // Need to invoke on UI thread if (Application.OpenForms.Count > 0) { var mainForm = Application.OpenForms[0]; - if (mainForm.InvokeRequired) + Func showCapture = () => { - mainForm.Invoke(new Action(() => + if (hasTemplate) { - TemplateCaptureForm.CaptureTemplate( - TurnTimerTemplateName, - "Capture the orange countdown timer (clock) in the top-right corner.\n" + - "This appears when it's your turn to swing."); - })); - } - else - { - TemplateCaptureForm.CaptureTemplate( + var choice = MessageBox.Show( + (timerCandidateVisible + ? "Auto Golf can see the orange timer, but your saved template did not match.\n\n" + : "Auto Golf has not detected your turn timer for 30 seconds.\n\n") + + "If the orange countdown clock is visible now, choose Yes and capture only the clock.\n" + + "If another player is taking a turn, choose No and Auto Golf will keep waiting.", + "Turn Timer Not Detected", + MessageBoxButtons.YesNo, + MessageBoxIcon.Information); + if (choice != DialogResult.Yes) + { + return false; + } + } + + return TemplateCaptureForm.CaptureTemplate( TurnTimerTemplateName, - "Capture the orange countdown timer (clock) in the top-right corner.\n" + - "This appears when it's your turn to swing."); + "Wait until the orange countdown clock is visible, then capture only the clock.\n" + + "Avoid including the course background or other UI so turn detection stays reliable."); + }; + + if (mainForm.InvokeRequired) + { + return (bool)mainForm.Invoke(showCapture); } + + return showCapture(); } + + return false; } /// diff --git a/ToonTown Rewritten Bot/Utilities/ImageRecognition.cs b/ToonTown Rewritten Bot/Utilities/ImageRecognition.cs index aa4536ed..8a6c2602 100644 --- a/ToonTown Rewritten Bot/Utilities/ImageRecognition.cs +++ b/ToonTown Rewritten Bot/Utilities/ImageRecognition.cs @@ -11,8 +11,18 @@ namespace ToonTown_Rewritten_Bot.Utilities { + internal sealed class WindowCaptureException : InvalidOperationException + { + public WindowCaptureException(string message) : base(message) + { + } + } + class ImageRecognition { + private static volatile bool _printWindowUnavailable; + private static int _screenFallbackWarningLogged; + /// /// Captures a screenshot of the game window. /// @@ -25,68 +35,250 @@ public static Image GetWindowScreenshot(bool captureBackground = true) nint windowHandle = NativeMethods.FindWindow(null, windowName); if (windowHandle == nint.Zero) { - throw new ArgumentException("Window not found."); + throw new WindowCaptureException("The Toontown Rewritten window was not found."); } // Get the window's position and size NativeMethods.Rect windowRect = new NativeMethods.Rect(); - NativeMethods.GetWindowRect(windowHandle, ref windowRect); + if (!NativeMethods.GetWindowRect(windowHandle, ref windowRect)) + { + throw new WindowCaptureException("Could not read the Toontown window bounds for screen capture."); + } if (captureBackground) { - // Use PrintWindow to capture even when window is behind other windows + // Some GPU/driver combinations report PrintWindow success but return blank pixels. + // Once that is detected, avoid repeatedly calling the broken path for this session. + if (_printWindowUnavailable) + { + return CaptureVisibleWindow(windowHandle, windowRect); + } + return CaptureWindowWithPrintWindow(windowHandle, windowRect.Width, windowRect.Height); } else { - // Traditional screen capture (only works when window is visible) - Bitmap screenshot = new Bitmap(windowRect.Width, windowRect.Height); - using (Graphics graphics = Graphics.FromImage(screenshot)) - { - graphics.CopyFromScreen(windowRect.Left, windowRect.Top, 0, 0, screenshot.Size); - } - return screenshot; + return CaptureVisibleWindow(windowHandle, windowRect); } } /// /// Captures a window using PrintWindow API, which works even when the window is obscured. + /// Retries with the legacy flag and falls back to visible screen capture when Windows + /// reports success but returns a black or uniform blank frame. /// private static Bitmap CaptureWindowWithPrintWindow(nint windowHandle, int width, int height) + { + Bitmap bitmap = TryCaptureWithPrintWindow( + windowHandle, + width, + height, + NativeMethods.PW_RENDERFULLCONTENT); + if (bitmap != null) + { + return bitmap; + } + + // Some drivers support PrintWindow but not PW_RENDERFULLCONTENT. + bitmap = TryCaptureWithPrintWindow(windowHandle, width, height, 0); + if (bitmap != null) + { + return bitmap; + } + + _printWindowUnavailable = true; + if (Interlocked.Exchange(ref _screenFallbackWarningLogged, 1) == 0) + { + Logger.Warning( + "Capture", + "PrintWindow returned unusable blank frames. Using visible screen capture for this session; " + + "keep the Toontown window visible and unobscured."); + } + + NativeMethods.Rect windowRect = new NativeMethods.Rect(); + if (!NativeMethods.GetWindowRect(windowHandle, ref windowRect)) + { + throw new WindowCaptureException("Could not read the Toontown window bounds for screen capture."); + } + + return CaptureVisibleWindow(windowHandle, windowRect); + } + + private static Bitmap TryCaptureWithPrintWindow( + nint windowHandle, + int width, + int height, + uint flags) { Bitmap bitmap = new Bitmap(width, height, PixelFormat.Format32bppArgb); + bool success; using (Graphics graphics = Graphics.FromImage(bitmap)) { IntPtr hdc = graphics.GetHdc(); try { - // PW_RENDERFULLCONTENT (0x2) works better with DWM/hardware-accelerated windows - bool success = NativeMethods.PrintWindow(windowHandle, hdc, NativeMethods.PW_RENDERFULLCONTENT); + success = NativeMethods.PrintWindow(windowHandle, hdc, flags); + } + finally + { + // Release exactly once. The previous fallback path released here and inside the + // failure branch, leaving Graphics in an invalid state on some machines. + graphics.ReleaseHdc(hdc); + } + } - if (!success) - { - // Fallback: try without the flag - success = NativeMethods.PrintWindow(windowHandle, hdc, 0); - } + string unusableReason = success ? GetPrintWindowFrameFailureReason(bitmap) : null; + if (success && unusableReason == null) + { + return bitmap; + } + + Debug.WriteLine(success + ? $"PrintWindow returned an unusable {unusableReason} (flags=0x{flags:X})" + : $"PrintWindow failed (flags=0x{flags:X})"); + bitmap.Dispose(); + return null; + } + + private static Bitmap CaptureVisibleWindow(nint windowHandle, NativeMethods.Rect windowRect) + { + if (windowRect.Width <= 0 || windowRect.Height <= 0) + { + throw new WindowCaptureException("The Toontown window has invalid capture dimensions."); + } + + if (NativeMethods.IsIconic(windowHandle)) + { + throw new WindowCaptureException( + "Toontown is minimized and this graphics driver does not support background capture. " + + "Restore the game window and try again."); + } + + Bitmap screenshot = new Bitmap(windowRect.Width, windowRect.Height, PixelFormat.Format32bppArgb); + try + { + using (Graphics graphics = Graphics.FromImage(screenshot)) + { + graphics.CopyFromScreen(windowRect.Left, windowRect.Top, 0, 0, screenshot.Size); + } + + if (IsBitmapEffectivelyBlack(screenshot)) + { + throw new WindowCaptureException( + "Both background and visible Toontown capture returned a black frame. Keep the game " + + "visible and unobscured, then try disabling Hardware-accelerated GPU scheduling or " + + "updating/rolling back the graphics driver."); + } + + return screenshot; + } + catch + { + screenshot.Dispose(); + throw; + } + } + + /// + /// Detects the effectively empty frames returned by PrintWindow on affected Intel/Windows + /// configurations. A grid is used instead of a single pixel so dark UI borders do not + /// trigger the fallback, while a frame with only a few non-black artifacts still does. + /// + private static bool IsBitmapEffectivelyBlack(Bitmap bitmap) + { + if (bitmap == null || bitmap.Width <= 0 || bitmap.Height <= 0) + { + return true; + } + + const int columns = 9; + const int rows = 7; + const int blackChannelThreshold = 12; + const double requiredBlackRatio = 0.95; + + int marginX = bitmap.Width / 10; + int marginY = bitmap.Height / 10; + int sampleWidth = Math.Max(1, bitmap.Width - (marginX * 2)); + int sampleHeight = Math.Max(1, bitmap.Height - (marginY * 2)); + int blackSamples = 0; + int totalSamples = 0; - if (!success) + for (int row = 0; row < rows; row++) + { + int y = marginY + (sampleHeight - 1) * row / (rows - 1); + y = Math.Min(y, bitmap.Height - 1); + + for (int column = 0; column < columns; column++) + { + int x = marginX + (sampleWidth - 1) * column / (columns - 1); + x = Math.Min(x, bitmap.Width - 1); + + Color pixel = bitmap.GetPixel(x, y); + if (pixel.R <= blackChannelThreshold && + pixel.G <= blackChannelThreshold && + pixel.B <= blackChannelThreshold) { - // If PrintWindow fails completely, fall back to screen capture - graphics.ReleaseHdc(hdc); - NativeMethods.Rect windowRect = new NativeMethods.Rect(); - NativeMethods.GetWindowRect(windowHandle, ref windowRect); - graphics.CopyFromScreen(windowRect.Left, windowRect.Top, 0, 0, bitmap.Size); - return bitmap; + blackSamples++; } + totalSamples++; } - finally + } + + return totalSamples == 0 || (double)blackSamples / totalSamples >= requiredBlackRatio; + } + + /// + /// Identifies frames where PrintWindow rendered the non-client title bar but filled the + /// game client area with one flat gray/white color. The interior-only grid deliberately + /// excludes the title bar and borders that can otherwise make a blank frame look valid. + /// + private static string GetPrintWindowFrameFailureReason(Bitmap bitmap) + { + if (IsBitmapEffectivelyBlack(bitmap)) + { + return "black frame"; + } + + const int columns = 9; + const int rows = 7; + const int uniformChannelRange = 8; + + int marginX = bitmap.Width / 10; + int marginY = bitmap.Height / 10; + int sampleWidth = Math.Max(1, bitmap.Width - (marginX * 2)); + int sampleHeight = Math.Max(1, bitmap.Height - (marginY * 2)); + int minR = 255; + int minG = 255; + int minB = 255; + int maxR = 0; + int maxG = 0; + int maxB = 0; + + for (int row = 0; row < rows; row++) + { + int y = marginY + (sampleHeight - 1) * row / (rows - 1); + y = Math.Min(y, bitmap.Height - 1); + + for (int column = 0; column < columns; column++) { - graphics.ReleaseHdc(hdc); + int x = marginX + (sampleWidth - 1) * column / (columns - 1); + x = Math.Min(x, bitmap.Width - 1); + + Color pixel = bitmap.GetPixel(x, y); + minR = Math.Min(minR, pixel.R); + minG = Math.Min(minG, pixel.G); + minB = Math.Min(minB, pixel.B); + maxR = Math.Max(maxR, pixel.R); + maxG = Math.Max(maxG, pixel.G); + maxB = Math.Max(maxB, pixel.B); } } - return bitmap; + bool isUniform = maxR - minR <= uniformChannelRange && + maxG - minG <= uniformChannelRange && + maxB - minB <= uniformChannelRange; + return isUniform ? "uniform/blank frame" : null; } /// @@ -193,6 +385,10 @@ public struct Rect [return: MarshalAs(UnmanagedType.Bool)] public static extern bool IsWindowVisible(IntPtr hWnd); + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + public static extern bool IsIconic(IntPtr hWnd); + // PrintWindow flags public const uint PW_RENDERFULLCONTENT = 0x00000002; // Works better with DWM/hardware acceleration } diff --git a/ToonTown Rewritten Bot/Utilities/UIElementManager.cs b/ToonTown Rewritten Bot/Utilities/UIElementManager.cs index 3988a341..77cb2f69 100644 --- a/ToonTown Rewritten Bot/Utilities/UIElementManager.cs +++ b/ToonTown Rewritten Bot/Utilities/UIElementManager.cs @@ -518,6 +518,23 @@ public void SetManualCoordinates(string elementName, Point coordinates) } } + /// + /// Clears a manual fallback for an element. Also clears any location cached while that + /// fallback was active so the next lookup performs a real template search. + /// + public void ClearManualCoordinates(string elementName) + { + var element = GetOrCreateElement(elementName); + if (element.ManualCoordinates.HasValue) + { + element.ManualCoordinates = null; + element.CachedCenter = null; + element.LastFoundTime = null; + Logger.Info("TemplateMatch", $"Manual coordinates cleared for '{elementName}' (cache cleared)"); + SaveElementData(); + } + } + /// /// Gets manual fallback coordinates for an element. /// @@ -556,6 +573,21 @@ public void ClearAllCache() SaveElementData(); } + /// + /// Clears all manual fallbacks and cached locations. Used by Reset State so both the + /// legacy coordinate file and image-recognition state are actually reset together. + /// + public void ClearAllPositionData() + { + foreach (var element in _elements.Values) + { + element.ManualCoordinates = null; + element.CachedCenter = null; + element.LastFoundTime = null; + } + SaveElementData(); + } + /// /// Gets all registered element names. /// diff --git a/ToonTown Rewritten Bot/Views/CustomFishingActions.cs b/ToonTown Rewritten Bot/Views/CustomFishingActions.cs index 0e2c10e8..646ef3e3 100644 --- a/ToonTown Rewritten Bot/Views/CustomFishingActions.cs +++ b/ToonTown Rewritten Bot/Views/CustomFishingActions.cs @@ -43,6 +43,7 @@ public CustomFishingActions() _keyboardHook = new GlobalKeyboardHook(); _keyboardHook.KeyPressed += OnGlobalKeyPressed; _keyboardHook.KeyReleased += OnGlobalKeyReleased; + lblRecorderHelp.Text = $"Start recording, then walk in TTR using {GameControls.GetMovementBindingSummary()}. Add Sell at the bucket, then stop."; UpdatePathPreview(); } @@ -279,11 +280,9 @@ private void actionItemsListBox_SelectedIndexChanged(object sender, EventArgs e) // Select "TIME" in comboBox1 if available comboBox1.SelectedItem = "TIME"; - // Extract the numeric value (time in seconds) - string timeValue = new String(selectedItem.Where(char.IsDigit).ToArray()); - - // Set the extracted time into textBox1 - actionTimeTxtBox.Text = timeValue; + // The editor accepts milliseconds, so convert the formatted display + // value back to milliseconds before populating the input. + actionTimeTxtBox.Text = ExtractMillisecondsFromDisplay(selectedItem).ToString(); } else { @@ -357,6 +356,22 @@ private void btnStartRecording_Click(object sender, EventArgs e) return; } + if (!_keyboardHook.Start()) + { + this.WindowState = FormWindowState.Normal; + this.BringToFront(); + lblRecordingStatus.Text = "Status: Could not start keyboard recording"; + lblRecordingStatus.ForeColor = Color.Red; + MessageBox.Show( + this, + $"The keyboard recorder could not start (Windows error {_keyboardHook.LastErrorCode}).\n\n" + + "Restart the bot and make sure the bot and Toontown are running at the same administrator level.", + "Recording Unavailable", + MessageBoxButtons.OK, + MessageBoxIcon.Error); + return; + } + // Clear previous recording _recordedKeys.Clear(); _currentKeyHeld = null; @@ -366,14 +381,13 @@ private void btnStartRecording_Click(object sender, EventArgs e) btnStartRecording.Enabled = false; btnStopRecording.Enabled = true; btnAddSellFish.Enabled = true; - lblRecordingStatus.Text = "Status: Recording... Press arrow keys in TTR"; + lblRecordingStatus.Text = $"Status: Recording... Use {GameControls.GetMovementBindingSummary()}"; lblRecordingStatus.ForeColor = Color.Green; lblLivePreview.Text = ""; // Start recording _isRecording = true; _recordingStopwatch.Restart(); - _keyboardHook.Start(); } private void btnStopRecording_Click(object sender, EventArgs e) @@ -386,13 +400,28 @@ private void btnStopRecording_Click(object sender, EventArgs e) // Convert recorded keys to action items ConvertRecordedKeysToActionItems(); - // Update UI - lblRecordingStatus.Text = $"Status: Stopped - {_recordedKeys.Count} actions recorded"; - lblRecordingStatus.ForeColor = Color.Blue; - - // Restore window + // Restore before showing any feedback so warnings cannot appear behind a minimized owner. this.WindowState = FormWindowState.Normal; this.BringToFront(); + + // Update UI + if (_recordedKeys.Count == 0) + { + lblRecordingStatus.Text = "Status: No actions recorded"; + lblRecordingStatus.ForeColor = Color.Red; + MessageBox.Show( + this, + $"No movement keys were recorded. Use the configured keys ({GameControls.GetMovementBindingSummary()}) and make sure the bot and Toontown run at the same administrator level.", + "Nothing Recorded", + MessageBoxButtons.OK, + MessageBoxIcon.Warning); + } + else + { + lblRecordingStatus.Text = $"Status: Stopped - {_recordedKeys.Count} actions recorded"; + lblRecordingStatus.ForeColor = Color.Blue; + } + } private void btnAddSellFish_Click(object sender, EventArgs e) @@ -497,14 +526,7 @@ private void FinalizeCurrentKey() private string GetActionFromKey(Keys key) { - return key switch - { - Keys.Up => "WALK FORWARDS", - Keys.Down => "WALK BACKWARDS", - Keys.Left => "TURN LEFT", - Keys.Right => "TURN RIGHT", - _ => null - }; + return GameControls.GetMovementAction((int)(key & Keys.KeyCode)); } private void StopRecordingCleanup() diff --git a/ToonTown Rewritten Bot/Views/CustomFishingWizardForm.cs b/ToonTown Rewritten Bot/Views/CustomFishingWizardForm.cs index ca330360..241c7ff3 100644 --- a/ToonTown Rewritten Bot/Views/CustomFishingWizardForm.cs +++ b/ToonTown Rewritten Bot/Views/CustomFishingWizardForm.cs @@ -332,7 +332,7 @@ private void UpdateUI() { case 1: lblStepTitle.Text = "Step 1: Record Walk to Fisherman"; - lblStepDescription.Text = "Stand on your fishing dock in TTR. Click 'Start Recording', then walk to the fisherman using arrow keys. Stop when you reach the sell bucket."; + lblStepDescription.Text = $"Stand on your fishing dock in TTR. Click 'Start Recording', then walk to the fisherman using your configured keys ({GameControls.GetMovementBindingSummary()})."; groupBoxContent.Text = "Record Path to Fisherman"; btnStartRecording.Visible = true; btnStopRecording.Visible = true; @@ -354,7 +354,7 @@ private void UpdateUI() case 3: lblStepTitle.Text = "Step 3: Record Walk Back to Dock"; - lblStepDescription.Text = "After selling, you need to walk back to the dock. Click 'Start Recording', then walk back to your fishing dock."; + lblStepDescription.Text = $"After selling, click 'Start Recording' and walk back using your configured keys ({GameControls.GetMovementBindingSummary()})."; groupBoxContent.Text = "Record Path Back to Dock"; btnStartRecording.Visible = true; btnStopRecording.Visible = true; @@ -455,6 +455,22 @@ private void BtnStartRecording_Click(object sender, EventArgs e) return; } + if (!_keyboardHook.Start()) + { + this.WindowState = FormWindowState.Normal; + this.BringToFront(); + lblRecordingStatus.Text = "Status: Could not start keyboard recording"; + lblRecordingStatus.ForeColor = Color.Red; + MessageBox.Show( + this, + $"The keyboard recorder could not start (Windows error {_keyboardHook.LastErrorCode}).\n\n" + + "Restart the bot and make sure the bot and Toontown are running at the same administrator level.", + "Recording Unavailable", + MessageBoxButtons.OK, + MessageBoxIcon.Error); + return; + } + // Clear previous recording _recordedKeys.Clear(); _currentKeyHeld = null; @@ -463,13 +479,12 @@ private void BtnStartRecording_Click(object sender, EventArgs e) // Update UI btnStartRecording.Enabled = false; btnStopRecording.Enabled = true; - lblRecordingStatus.Text = "Status: Recording... Press arrow keys in TTR"; + lblRecordingStatus.Text = $"Status: Recording... Use {GameControls.GetMovementBindingSummary()}"; lblRecordingStatus.ForeColor = Color.Green; // Start recording _isRecording = true; _recordingStopwatch.Restart(); - _keyboardHook.Start(); } private void BtnStopRecording_Click(object sender, EventArgs e) @@ -491,12 +506,27 @@ private void BtnStopRecording_Click(object sender, EventArgs e) UpdatePathPreviewLabel(actions); - // Update UI - lblRecordingStatus.Text = $"Status: Recorded {_recordedKeys.Count} movements"; - lblRecordingStatus.ForeColor = Color.Blue; - this.WindowState = FormWindowState.Normal; this.BringToFront(); + + // Update UI + if (_recordedKeys.Count == 0) + { + lblRecordingStatus.Text = "Status: No movements recorded"; + lblRecordingStatus.ForeColor = Color.Red; + MessageBox.Show( + this, + $"No movement keys were recorded. Use the configured keys ({GameControls.GetMovementBindingSummary()}) and make sure the bot and Toontown run at the same administrator level.", + "Nothing Recorded", + MessageBoxButtons.OK, + MessageBoxIcon.Warning); + } + else + { + lblRecordingStatus.Text = $"Status: Recorded {_recordedKeys.Count} movements"; + lblRecordingStatus.ForeColor = Color.Blue; + } + } private void OnGlobalKeyPressed(object sender, Keys key) @@ -561,14 +591,7 @@ private void FinalizeCurrentKey() private string GetActionFromKey(Keys key) { - return key switch - { - Keys.Up => "WALK FORWARDS", - Keys.Down => "WALK BACKWARDS", - Keys.Left => "TURN LEFT", - Keys.Right => "TURN RIGHT", - _ => null - }; + return GameControls.GetMovementAction((int)(key & Keys.KeyCode)); } private void StopRecordingCleanup() diff --git a/ToonTown Rewritten Bot/Views/CustomGolfWizardForm.cs b/ToonTown Rewritten Bot/Views/CustomGolfWizardForm.cs index 4ae08c57..ba8ef833 100644 --- a/ToonTown Rewritten Bot/Views/CustomGolfWizardForm.cs +++ b/ToonTown Rewritten Bot/Views/CustomGolfWizardForm.cs @@ -467,6 +467,8 @@ private async void BtnTest_Click(object sender, EventArgs e) lblTestStatus.Text = "Testing sequence... (Press ESC to cancel)"; btnTest.Enabled = false; + bool previousBackgroundMode = CoreFunctionality.UseBackgroundInput; + CoreFunctionality.UseBackgroundInput = false; try { @@ -488,9 +490,15 @@ private async void BtnTest_Click(object sender, EventArgs e) } else if (golfKeys.ActionKeyMap.TryGetValue(action.Action, out var keyCode)) { - WindowsInput.InputSimulator.SimulateKeyDown(keyCode); - await Task.Delay(action.Duration, _testCts.Token); - WindowsInput.InputSimulator.SimulateKeyUp(keyCode); + CoreFunctionality.SendKeyDown(keyCode); + try + { + await Task.Delay(action.Duration, _testCts.Token); + } + finally + { + CoreFunctionality.SendKeyUp(keyCode); + } } } @@ -506,6 +514,7 @@ private async void BtnTest_Click(object sender, EventArgs e) } finally { + CoreFunctionality.UseBackgroundInput = previousBackgroundMode; btnTest.Enabled = true; _testCts?.Dispose(); _testCts = null; diff --git a/ToonTown Rewritten Bot/Views/MainForm.Designer.cs b/ToonTown Rewritten Bot/Views/MainForm.Designer.cs index ecb62234..77382a68 100644 --- a/ToonTown Rewritten Bot/Views/MainForm.Designer.cs +++ b/ToonTown Rewritten Bot/Views/MainForm.Designer.cs @@ -520,7 +520,7 @@ private void InitializeComponent() randomFishingCheckBox.Size = new System.Drawing.Size(135, 20); randomFishingCheckBox.TabIndex = 6; randomFishingCheckBox.Text = "Random Variance"; - toolTip1.SetToolTip(randomFishingCheckBox, "Adds randomness so you won't cast your line at the same spot every time."); + toolTip1.SetToolTip(randomFishingCheckBox, "Varies the cast position only. This does not provide ban protection or make automation safe."); randomFishingCheckBox.UseVisualStyleBackColor = true; randomFishingCheckBox.CheckedChanged += randomFishing_CheckedChanged; // diff --git a/ToonTown Rewritten Bot/Views/MainForm.Dev.cs b/ToonTown Rewritten Bot/Views/MainForm.Dev.cs index 395ec603..f5fe04d5 100644 --- a/ToonTown Rewritten Bot/Views/MainForm.Dev.cs +++ b/ToonTown Rewritten Bot/Views/MainForm.Dev.cs @@ -16,9 +16,9 @@ public partial class MainForm private void devResetCoordinatesBtn_Click(object sender, EventArgs e) { CoordinatesManager.CreateFreshCoordinatesFile(); - // Also clear the image-recognition cache — otherwise stale cached centers - // from previous runs keep getting returned and "reset" feels broken. - UIElementManager.Instance.ClearAllCache(); + // Clear both image-recognition caches and its separate manual fallback store. + // Previously Reset State left stale manual coordinates behind there. + UIElementManager.Instance.ClearAllPositionData(); MessageBox.Show("Manual coordinates reset and image-recognition cache cleared.", "Reset State", MessageBoxButtons.OK, MessageBoxIcon.Information); } diff --git a/ToonTown Rewritten Bot/Views/MainForm.Golf.cs b/ToonTown Rewritten Bot/Views/MainForm.Golf.cs index b6a897a2..7b362fce 100644 --- a/ToonTown Rewritten Bot/Views/MainForm.Golf.cs +++ b/ToonTown Rewritten Bot/Views/MainForm.Golf.cs @@ -10,6 +10,22 @@ namespace ToonTown_Rewritten_Bot { public partial class MainForm { + private string ConfiguredGolfSwingKey => + Models.GameControls.GetDisplayName(Models.GameControls.Jump); + + private void UpdateGolfInstructionsLabel() + { + golfInstructionsLabel.Text = + $"Golf uses your configured Jump key: {ConfiguredGolfSwingKey}.\n" + + "It must match TTR (Options & Codes → Controls).\n\n" + + "1. Select a golf course\n" + + "2. Check the required tee position\n" + + "3. Move to that position in TTR\n" + + "4. Click Start Golf\n" + + "5. Keep TTR focused until the shot finishes\n\n" + + "Change the key under Settings → Configure Game Controls."; + } + private void createCustomGolfActionsBtn_Click(object sender, EventArgs e) { using (var form = new CustomGolfActions()) @@ -42,8 +58,44 @@ private async void startGolfBtn_Click(object sender, EventArgs e) // Get the full path to the selected golf action file. string filePath = GolfService.GetCustomGolfActionFilePath(selectedFileName); + if (CoreFunctionality.FindToontownWindow() == IntPtr.Zero) + { + MessageBox.Show( + "The Toontown Rewritten window was not found.\n\n" + + "Start TTR and log in before starting golf.", + "TTR Not Found", + MessageBoxButtons.OK, + MessageBoxIcon.Warning); + return; + } + + var actions = GolfService.GetCustomGolfActions(filePath); + if (actions.Length == 0) + { + MessageBox.Show( + "The selected golf action file is empty, missing, or invalid.\n\n" + + "Choose another course or recreate the action file.", + "Invalid Golf Actions", + MessageBoxButtons.OK, + MessageBoxIcon.Error); + return; + } + + var swingAction = Array.Find(actions, + action => action.Action == "SWING POWER" && action.Duration > 0); + if (swingAction == null) + { + MessageBox.Show( + "The selected action file has no valid SWING POWER action, so the bot cannot shoot.\n\n" + + "Edit or recreate the action file and add a Swing Power step.", + "No Swing Action", + MessageBoxButtons.OK, + MessageBoxIcon.Error); + return; + } + // Get shot summary to show helpful instructions - var summary = GolfService.GetShotSummaryFromFile(filePath); + var summary = GolfService.GetShotSummary(actions); // Build instruction message string positionInstruction = summary.RequiresPositionChange @@ -54,11 +106,14 @@ private async void startGolfBtn_Click(object sender, EventArgs e) $"Course: {selectedFileName}\n\n" + $"━━━ YOU DO (before clicking OK) ━━━\n" + $"1. {positionInstruction}\n" + - $"2. Make sure your swing key is set to CTRL\n\n" + + $"2. Bot swing key: {ConfiguredGolfSwingKey}\n" + + $"3. Make sure TTR's Jump control uses the same key\n" + + $" (Bot Settings → Configure Game Controls)\n\n" + $"━━━ BOT WILL DO ━━━\n" + $"• Aim: {summary.Aim}\n" + $"• Power: ~{summary.Power}%\n\n" + - $"After clicking OK, switch to TTR within {summary.DelaySeconds} seconds.\n\n" + + $"After clicking OK, TTR will be focused automatically.\n" + + $"The shot starts after {summary.DelaySeconds} seconds—keep TTR focused.\n\n" + "Ready to start?", "Golf Setup - " + selectedFileName, MessageBoxButtons.OKCancel, @@ -109,6 +164,17 @@ private async void startAutoGolfBtn_Click(object sender, EventArgs e) return; } + if (CoreFunctionality.FindToontownWindow() == IntPtr.Zero) + { + MessageBox.Show( + "The Toontown Rewritten window was not found.\n\n" + + "Start TTR and log in before starting auto golf.", + "TTR Not Found", + MessageBoxButtons.OK, + MessageBoxIcon.Warning); + return; + } + // Remind user about auto-golf setup var result = MessageBox.Show( "━━━ AUTO GOLF MODE ━━━\n\n" + @@ -118,7 +184,8 @@ private async void startAutoGolfBtn_Click(object sender, EventArgs e) "3. Aim and swing with the right power\n" + "4. Repeat for all 3 holes\n\n" + "━━━ YOU MUST DO ━━━\n" + - "• Set swing key to CTRL (default)\n" + + $"• TTR's Jump/swing key must match: {ConfiguredGolfSwingKey}\n" + + " (Bot Settings → Configure Game Controls)\n" + "• Keep TTR visible on screen\n" + "• ⚠️ Move to LEFT or RIGHT tee when instructed!\n" + " (Watch the overlay for position instructions)\n\n" + diff --git a/ToonTown Rewritten Bot/Views/MainForm.Settings.cs b/ToonTown Rewritten Bot/Views/MainForm.Settings.cs index 122d8de1..bd5c4358 100644 --- a/ToonTown Rewritten Bot/Views/MainForm.Settings.cs +++ b/ToonTown Rewritten Bot/Views/MainForm.Settings.cs @@ -87,6 +87,7 @@ private void btnGameControls_Click(object sender, EventArgs e) if (form.ShowDialog(this) == DialogResult.OK) { RefreshPreferencesDisplay(); + UpdateGolfInstructionsLabel(); } } } diff --git a/ToonTown Rewritten Bot/Views/MainForm.cs b/ToonTown Rewritten Bot/Views/MainForm.cs index ed05aaf1..2df1406e 100644 --- a/ToonTown Rewritten Bot/Views/MainForm.cs +++ b/ToonTown Rewritten Bot/Views/MainForm.cs @@ -45,7 +45,16 @@ public MainForm() // Enable global keyboard hook (works even when game has focus) _globalKeyboardHook = new GlobalKeyboardHook(); _globalKeyboardHook.KeyPressed += GlobalKeyboardHook_KeyPressed; - _globalKeyboardHook.Start(); + if (!_globalKeyboardHook.Start()) + { + MessageBox.Show( + $"Global keyboard shortcuts could not start (Windows error {_globalKeyboardHook.LastErrorCode}).\n\n" + + "F11/F12/Esc shortcuts and custom path recording may not work. Restart the bot and make " + + "sure the bot and Toontown are running at the same administrator level.", + "Keyboard Shortcuts Unavailable", + MessageBoxButtons.OK, + MessageBoxIcon.Warning); + } // Clean up hook when form closes this.FormClosing += MainForm_FormClosing; @@ -174,6 +183,7 @@ private void LoadUserPreferences() // Game control bindings — apply saved key bindings to the input layer. Models.GameControls.LoadFrom(prefs); + UpdateGolfInstructionsLabel(); // Global stop/pause hotkeys — apply saved bindings, then reflect them in the UI hints. Models.Hotkeys.LoadFrom(prefs); diff --git a/ToonTown Rewritten Bot/Views/MainForm.resx b/ToonTown Rewritten Bot/Views/MainForm.resx index 55c4d842..65abf9e6 100644 --- a/ToonTown Rewritten Bot/Views/MainForm.resx +++ b/ToonTown Rewritten Bot/Views/MainForm.resx @@ -3257,7 +3257,7 @@ Use 'Edit Selected' to manually modify existing files. - IMPORTANT: Your golf swing key in TTR must be set to CTRL (default). + IMPORTANT: Your configured Jump key must match TTR. 1. Select a golf course from the dropdown @@ -3265,12 +3265,12 @@ Use 'Edit Selected' to manually modify existing files. 3. In TTR, move to the correct tee position if required -4. Click 'Start Golf' and switch to TTR within 15 seconds +4. Click 'Start Golf' -5. Bot waits 15 sec (for position lock), then aims and swings! +5. Keep TTR focused until the bot aims and swings Note: Some courses require LEFT or RIGHT tee position. -The preview tells you where to stand - you must move there yourself. +Change keys under Settings → Configure Game Controls. @@ -5195,4 +5195,4 @@ Use this if the bot is clicking the wrong locations even after recapturing templ ////+AAA///////////////////////4AAA= - \ No newline at end of file +