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
+