using System.Collections.ObjectModel; using System.Numerics; using System.Runtime.InteropServices; using Avalonia; using Avalonia.Media.Imaging; using Roboto.Memory; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using Automata.GameLog; using Roboto.Core; using Roboto.Data; using Roboto.Engine; using Roboto.Input; using Roboto.Navigation; namespace Automata.Ui.ViewModels; /// /// Thread-safe snapshot read by the overlay layer each frame. /// public sealed class EntityOverlayData { /// Player position at time the entity list was built (cold tick, 10Hz). public Vector2 SnapshotPlayerPosition; public float SnapshotPlayerZ; public EntityOverlayEntry[] Entries = []; } public readonly struct EntityOverlayEntry { public readonly float X, Y; public readonly string Label; public EntityOverlayEntry(float x, float y, string label) { X = x; Y = y; Label = label; } } /// /// View model item for entity checkbox list. /// public partial class EntityListItem : ObservableObject { public uint Id { get; } public string Label { get; } public string Category { get; } public string Distance { get; set; } public float X { get; set; } public float Y { get; set; } [ObservableProperty] private bool _isChecked; public EntityListItem(uint id, string label, string category, float distance, float x, float y) { Id = id; Label = label; Category = category; Distance = $"{distance:F0}"; X = x; Y = y; } } public partial class RobotoViewModel : ObservableObject, IDisposable { [LibraryImport("user32.dll")] private static partial short GetAsyncKeyState(int vKey); private const int VK_END = 0x23; private bool _endWasDown; private readonly BotEngine _engine; private long _lastUiUpdate; private bool _disposed; [ObservableProperty] private string _statusText = "Stopped"; [ObservableProperty] private bool _isRunning; // Player state [ObservableProperty] private string _playerPosition = "—"; [ObservableProperty] private string _playerLife = "—"; [ObservableProperty] private string _playerMana = "—"; [ObservableProperty] private string _playerEs = "—"; // Game state [ObservableProperty] private string _areaInfo = "—"; [ObservableProperty] private string _dangerLevel = "—"; [ObservableProperty] private string _entityCount = "—"; [ObservableProperty] private string _hostileCount = "—"; [ObservableProperty] private string _tickInfo = "—"; // Systems [ObservableProperty] private string _systemsInfo = "—"; [ObservableProperty] private string _apmInfo = "0"; // Navigation [ObservableProperty] private string _navMode = "Idle"; [ObservableProperty] private string _navStatus = "—"; // Terrain minimap [ObservableProperty] private Bitmap? _terrainImage; private byte[]? _terrainBasePixels; private byte[]? _minimapBuffer; private int _terrainWidth, _terrainHeight; private uint _terrainAreaHash; private const int MinimapViewSize = 400; private const float WorldToGrid = 23.0f / 250.0f; private const float Cos45 = 0.70710678f; private const float Sin45 = 0.70710678f; // Entity list for checkbox UI [ObservableProperty] private bool _showAllEntities; public ObservableCollection Entities { get; } = []; // ── Profile Editor ── [ObservableProperty] private string _characterName = "—"; [ObservableProperty] private bool _hasProfile; [ObservableProperty] private string _profileName = "—"; [ObservableProperty] private string? _selectedProfile; public ObservableCollection AvailableProfiles { get; } = []; public ObservableCollection SkillProfiles { get; } = []; // Flask settings [ObservableProperty] private float _lifeFlaskThreshold = 50f; [ObservableProperty] private float _manaFlaskThreshold = 50f; [ObservableProperty] private int _flaskCooldownMs = 4000; // Combat settings [ObservableProperty] private int _globalCooldownMs = 500; [ObservableProperty] private float _attackRange = 600f; [ObservableProperty] private float _safeRange = 400f; [ObservableProperty] private bool _kiteEnabled; [ObservableProperty] private float _kiteRange = 300f; [ObservableProperty] private int _kiteDelayMs = 200; /// /// Thread-safe snapshot for the overlay layer (written on UI thread, read on overlay thread). /// public static volatile EntityOverlayData? OverlayData; /// /// Shared GameDataCache for the overlay layer to read camera/player data directly. /// public static volatile GameDataCache? SharedCache; public RobotoViewModel(IClientLogWatcher logWatcher) { var config = new BotConfig(); var reader = new GameMemoryReader(); var humanizer = new Humanizer(config); // Try Interception driver first, fall back to SendInput var interception = new InterceptionInputController(humanizer); IInputController input; if (interception.Initialize()) { input = interception; } else { var sendInput = new SendInputController(humanizer); sendInput.Initialize(); input = sendInput; } _engine = new BotEngine(config, reader, input, humanizer); _engine.StatusChanged += status => { Avalonia.Threading.Dispatcher.UIThread.Post(() => StatusText = status); }; _engine.StateUpdated += OnStateUpdated; _engine.ProfileChanged += profile => { if (!_suppressProfileChanged) Avalonia.Threading.Dispatcher.UIThread.Post(() => PopulateFromProfile(profile)); }; // Bridge area name from log watcher to engine logWatcher.AreaEntered += area => _engine.SetCurrentAreaName(area); if (logWatcher.CurrentArea is { Length: > 0 } current) _engine.SetCurrentAreaName(current); } [RelayCommand] private void Start() { if (_engine.IsRunning) return; var ok = _engine.Start(); IsRunning = _engine.IsRunning; if (ok) SharedCache = _engine.Cache; else StatusText = _engine.Status; } [RelayCommand] private void Stop() { _engine.Stop(); IsRunning = false; StatusText = "Stopped"; PlayerPosition = "—"; PlayerLife = "—"; PlayerMana = "—"; PlayerEs = "—"; AreaInfo = "—"; DangerLevel = "—"; EntityCount = "—"; HostileCount = "—"; TickInfo = "—"; ApmInfo = "0"; NavMode = "Idle"; NavStatus = "—"; Entities.Clear(); OverlayData = null; SharedCache = null; } [RelayCommand] private void Explore() { if (!_engine.IsRunning) return; _engine.Nav.Explore(); } [RelayCommand] private void StopNav() { _engine.Nav.Stop(); } [RelayCommand] private void SaveProfile() { var profile = _engine.ActiveProfile; if (profile is null) return; // Write VM fields back to profile model profile.Flasks.LifeFlaskThreshold = LifeFlaskThreshold; profile.Flasks.ManaFlaskThreshold = ManaFlaskThreshold; profile.Flasks.FlaskCooldownMs = FlaskCooldownMs; profile.Combat.GlobalCooldownMs = GlobalCooldownMs; profile.Combat.AttackRange = AttackRange; profile.Combat.SafeRange = SafeRange; profile.Combat.KiteEnabled = KiteEnabled; profile.Combat.KiteRange = KiteRange; profile.Combat.KiteDelayMs = KiteDelayMs; // Skill write-through is handled by SkillProfileViewModel partial methods _engine.Profiles.Save(profile); // Apply to engine without rebuilding UI (same profile object, just re-apply to systems) _suppressProfileChanged = true; _engine.ApplyProfile(profile); _suppressProfileChanged = false; } [RelayCommand] private void DuplicateProfile() { var profile = _engine.ActiveProfile; if (profile is null) return; var newName = $"{profile.Name}_Copy"; var dupe = _engine.Profiles.Duplicate(profile.Name, newName); if (dupe is null) return; RefreshAvailableProfiles(); _suppressProfileSwitch = true; SelectedProfile = newName; _suppressProfileSwitch = false; SwitchToProfile(newName); } [RelayCommand] private void DeleteProfile() { var charName = _engine.Cache.CharacterName; if (charName is null || SelectedProfile is null) return; // Don't allow deleting the last profile if (AvailableProfiles.Count <= 1) return; var toDelete = SelectedProfile; _engine.Profiles.Delete(toDelete); RefreshAvailableProfiles(); // If we deleted the active profile, switch to the first available if (_engine.ActiveProfile?.Name == toDelete && AvailableProfiles.Count > 0) { var fallback = AvailableProfiles[0]; _suppressProfileSwitch = true; SelectedProfile = fallback; _suppressProfileSwitch = false; SwitchToProfile(fallback); } } // When the user picks a different profile from the ComboBox, switch immediately private bool _suppressProfileSwitch; private bool _suppressProfileChanged; partial void OnSelectedProfileChanged(string? value) { if (_suppressProfileSwitch || value is null) return; var charName = _engine.Cache.CharacterName; if (charName is null) return; // Don't re-switch if it's already the active profile if (_engine.ActiveProfile?.Name == value) return; SwitchToProfile(value); } private void SwitchToProfile(string profileName) { var charName = _engine.Cache.CharacterName; if (charName is null) return; _engine.Profiles.AssignToCharacter(charName, profileName); var profile = _engine.Profiles.LoadForCharacter(charName); _engine.ApplyProfile(profile); } private void PopulateFromProfile(CharacterProfile profile) { HasProfile = true; ProfileName = profile.Name; // Flask settings LifeFlaskThreshold = profile.Flasks.LifeFlaskThreshold; ManaFlaskThreshold = profile.Flasks.ManaFlaskThreshold; FlaskCooldownMs = profile.Flasks.FlaskCooldownMs; // Combat settings GlobalCooldownMs = profile.Combat.GlobalCooldownMs; AttackRange = profile.Combat.AttackRange; SafeRange = profile.Combat.SafeRange; KiteEnabled = profile.Combat.KiteEnabled; KiteRange = profile.Combat.KiteRange; KiteDelayMs = profile.Combat.KiteDelayMs; // Skills SkillProfiles.Clear(); foreach (var skill in profile.Skills) SkillProfiles.Add(new SkillProfileViewModel(skill)); RefreshAvailableProfiles(); _suppressProfileSwitch = true; SelectedProfile = profile.Name; _suppressProfileSwitch = false; } private void RefreshAvailableProfiles() { AvailableProfiles.Clear(); foreach (var name in _engine.Profiles.ListProfiles()) AvailableProfiles.Add(name); } private void OnStateUpdated() { // Emergency stop: END key var endDown = (GetAsyncKeyState(VK_END) & 0x8000) != 0; if (endDown && !_endWasDown) { Serilog.Log.Warning("END pressed — emergency stop"); Avalonia.Threading.Dispatcher.UIThread.Post(Stop); } _endWasDown = endDown; // Throttle UI updates to ~10Hz var now = Environment.TickCount64; if (now - _lastUiUpdate < 100) return; _lastUiUpdate = now; var state = _engine.CurrentState; if (state is null) return; Avalonia.Threading.Dispatcher.UIThread.Post(() => UpdateFromState(state)); } private void UpdateFromState(GameState state) { var p = state.Player; PlayerPosition = p.HasPosition ? $"({p.Position.X:F1}, {p.Position.Y:F1})" : "—"; PlayerLife = p.LifeTotal > 0 ? $"{p.LifeCurrent}/{p.LifeTotal} ({p.LifePercent:F0}%)" : "—"; PlayerMana = p.ManaTotal > 0 ? $"{p.ManaCurrent}/{p.ManaTotal} ({p.ManaPercent:F0}%)" : "—"; PlayerEs = p.EsTotal > 0 ? $"{p.EsCurrent}/{p.EsTotal} ({p.EsPercent:F0}%)" : "—"; AreaInfo = state.AreaHash != 0 ? $"Level {state.AreaLevel} (0x{state.AreaHash:X8})" : "—"; DangerLevel = state.Danger.ToString(); EntityCount = $"{state.Entities.Count} total"; HostileCount = $"{state.HostileMonsters.Count} hostile"; TickInfo = $"Tick {state.TickNumber}, dt={state.DeltaTime * 1000:F0}ms"; ApmInfo = $"{_engine.CurrentApm} / {250}"; // Systems status var systems = _engine.Systems; var enabled = systems.Count(s => s.IsEnabled); SystemsInfo = $"{enabled}/{systems.Count} active"; // Navigation NavMode = _engine.Nav.Mode.ToString(); NavStatus = _engine.Nav.Status; // Character name if (p.CharacterName is { Length: > 0 }) CharacterName = p.CharacterName; // Populate available skill names from memory (stripped of "Player" suffix) if (p.Skills.Count > 0) { var names = p.Skills .Where(s => s.Name is { Length: > 0 }) .Select(s => SkillProfileViewModel.CleanSkillName(s.Name)) .Distinct() .OrderBy(n => n) .ToList(); foreach (var vm in SkillProfiles) { var current = vm.AvailableSkillNames; if (current.Count != names.Count || !current.SequenceEqual(names)) { current.Clear(); foreach (var n in names) current.Add(n); } } } // Terrain minimap UpdateTerrainMinimap(state); // Entity list UpdateEntityList(state); } private void UpdateEntityList(GameState state) { // Build lookup of currently checked IDs var checkedIds = new HashSet(); foreach (var item in Entities) if (item.IsChecked) checkedIds.Add(item.Id); // Rebuild the list Entities.Clear(); foreach (var e in state.Entities) { var shortLabel = e.Category == EntityCategory.AreaTransition && e.TransitionName is not null ? $"AreaTransition — {e.TransitionName}" : GetShortLabel(e.Path); var item = new EntityListItem(e.Id, shortLabel, e.Category.ToString(), e.DistanceToPlayer, e.Position.X, e.Position.Y); if (checkedIds.Contains(e.Id)) item.IsChecked = true; Entities.Add(item); } // Build overlay snapshot from checked (or all) entities var showAll = ShowAllEntities; var overlayEntries = new List(); foreach (var item in Entities) { if (!showAll && !item.IsChecked) continue; overlayEntries.Add(new EntityOverlayEntry(item.X, item.Y, item.Label)); } if (overlayEntries.Count > 0) { OverlayData = new EntityOverlayData { SnapshotPlayerPosition = state.Player.Position, SnapshotPlayerZ = state.Player.Z, Entries = overlayEntries.ToArray(), }; } else { OverlayData = null; } } private void UpdateTerrainMinimap(GameState state) { if (state.IsLoading || state.Terrain is null) { _terrainBasePixels = null; _terrainAreaHash = 0; var old = TerrainImage; TerrainImage = null; old?.Dispose(); return; } var terrain = state.Terrain; var w = terrain.Width; var h = terrain.Height; // Invalidate cache on area change or terrain resize if ((state.AreaHash != 0 && state.AreaHash != _terrainAreaHash) || w != _terrainWidth || h != _terrainHeight) { _terrainBasePixels = null; _terrainAreaHash = 0; var old = TerrainImage; TerrainImage = null; old?.Dispose(); } // Build base pixels from terrain (walkable/blocked only — explored overlay applied per frame) if (_terrainBasePixels is null) { _terrainWidth = w; _terrainHeight = h; _terrainAreaHash = state.AreaHash; _terrainBasePixels = new byte[w * h * 4]; } // Rebuild base pixels each frame to include explored overlay var basePixels = _terrainBasePixels; var exploredGrid = _engine.Nav.ExploredGrid; var ew = _engine.Nav.ExploredWidth; var eh = _engine.Nav.ExploredHeight; for (var y = 0; y < h; y++) { var srcY = h - 1 - y; // flip Y: game Y-up → bitmap Y-down for (var x = 0; x < w; x++) { var i = (y * w + x) * 4; if (terrain.IsWalkable(x, srcY)) { // Check explored status var isExplored = exploredGrid is not null && x < ew && srcY < eh && exploredGrid[srcY * ew + x]; var gray = isExplored ? (byte)0x80 : (byte)0x30; basePixels[i] = gray; // B basePixels[i + 1] = gray; // G basePixels[i + 2] = gray; // R basePixels[i + 3] = 0xFF; // A } else { basePixels[i] = 0x00; basePixels[i + 1] = 0x00; basePixels[i + 2] = 0x00; basePixels[i + 3] = 0xFF; } } } if (w < 2 || h < 2) return; // Player grid position — center of the output float pgx, pgy; if (state.Player.HasPosition) { pgx = state.Player.Position.X * WorldToGrid; pgy = h - 1 - state.Player.Position.Y * WorldToGrid; } else { pgx = w * 0.5f; pgy = h * 0.5f; } var bufSize = MinimapViewSize * MinimapViewSize * 4; if (_minimapBuffer is null || _minimapBuffer.Length != bufSize) _minimapBuffer = new byte[bufSize]; var buf = _minimapBuffer; Array.Clear(buf, 0, bufSize); var cx = MinimapViewSize * 0.5f; var cy = MinimapViewSize * 0.5f; // Sample terrain with -45° rotation (nearest-neighbor, unsafe) unsafe { fixed (byte* srcPtr = basePixels, dstPtr = buf) { var srcInt = (int*)srcPtr; var dstInt = (int*)dstPtr; for (var ry = 0; ry < MinimapViewSize; ry++) { var dy = ry - cy; var baseX = -dy * Sin45 + pgx; var baseY = dy * Cos45 + pgy; for (var rx = 0; rx < MinimapViewSize; rx++) { var dx = rx - cx; var sx = (int)(dx * Cos45 + baseX); var sy = (int)(dx * Sin45 + baseY); if ((uint)sx >= (uint)w || (uint)sy >= (uint)h) continue; dstInt[ry * MinimapViewSize + rx] = srcInt[sy * w + sx]; } } } } // Draw entity dots var stride = MinimapViewSize * 4; foreach (var e in state.Entities) { if (e.Position == Vector2.Zero) continue; byte db, dg, dr; int dotRadius; switch (e.Category) { case EntityCategory.Monster when e.IsAlive: db = 0x44; dg = 0x44; dr = 0xFF; dotRadius = 3; break; // red case EntityCategory.Npc: db = 0x00; dg = 0x8C; dr = 0xFF; dotRadius = 3; break; // orange case EntityCategory.AreaTransition: db = 0xFF; dg = 0xFF; dr = 0x00; dotRadius = 4; break; // cyan case EntityCategory.Chest when !e.IsAlive: // opened chests continue; case EntityCategory.Chest: db = 0x00; dg = 0xD4; dr = 0xFF; dotRadius = 3; break; // gold default: continue; } var edx = e.Position.X * WorldToGrid - pgx; var edy = (h - 1 - e.Position.Y * WorldToGrid) - pgy; var esx = (int)(edx * Cos45 + edy * Sin45 + cx); var esy = (int)(-edx * Sin45 + edy * Cos45 + cy); if (esx >= 0 && esx < MinimapViewSize && esy >= 0 && esy < MinimapViewSize) DrawDot(buf, stride, MinimapViewSize, MinimapViewSize, esx, esy, dotRadius, db, dg, dr); } // Draw nav path as green line var navPath = _engine.Nav.CurrentPath; if (navPath is { Count: > 1 }) { for (var i = 0; i < navPath.Count; i++) { var px = navPath[i].X * WorldToGrid - pgx; var py = (h - 1 - navPath[i].Y * WorldToGrid) - pgy; var ex = (int)(px * Cos45 + py * Sin45 + cx); var ey = (int)(-px * Sin45 + py * Cos45 + cy); if (ex >= 0 && ex < MinimapViewSize && ey >= 0 && ey < MinimapViewSize) DrawDot(buf, stride, MinimapViewSize, MinimapViewSize, ex, ey, 2, 0x50, 0xB9, 0x3F); } } // Draw player dot at center (white) if (state.Player.HasPosition) DrawDot(buf, stride, MinimapViewSize, MinimapViewSize, (int)cx, (int)cy, 4, 0xFF, 0xFF, 0xFF); // Create WriteableBitmap var bmp = new WriteableBitmap( new PixelSize(MinimapViewSize, MinimapViewSize), new Avalonia.Vector(96, 96), Avalonia.Platform.PixelFormat.Bgra8888, Avalonia.Platform.AlphaFormat.Premul); using (var fb = bmp.Lock()) { Marshal.Copy(buf, 0, fb.Address, buf.Length); } var old2 = TerrainImage; TerrainImage = bmp; old2?.Dispose(); } private static void DrawDot(byte[] buf, int stride, int bw, int bh, int cx, int cy, int radius, byte b, byte g, byte r) { var outer = radius + 1; for (var dy = -outer; dy <= outer; dy++) { for (var dx = -outer; dx <= outer; dx++) { var sx = cx + dx; var sy = cy + dy; if (sx < 0 || sx >= bw || sy < 0 || sy >= bh) continue; var dist = MathF.Sqrt(dx * dx + dy * dy); if (dist > radius + 0.5f) continue; var alpha = Math.Clamp(radius + 0.5f - dist, 0f, 1f); var i = sy * stride + sx * 4; buf[i] = (byte)(b * alpha + buf[i] * (1 - alpha)); buf[i + 1] = (byte)(g * alpha + buf[i + 1] * (1 - alpha)); buf[i + 2] = (byte)(r * alpha + buf[i + 2] * (1 - alpha)); buf[i + 3] = (byte)(Math.Max(alpha, buf[i + 3] / 255f) * 255); } } } private static string GetShortLabel(string? path) { if (path is null) return "?"; // Strip @N instance suffix var atIdx = path.LastIndexOf('@'); if (atIdx > 0) path = path[..atIdx]; // Take last segment var slashIdx = path.LastIndexOf('/'); return slashIdx >= 0 ? path[(slashIdx + 1)..] : path; } public void Shutdown() { _engine.Stop(); } public void Dispose() { if (_disposed) return; _disposed = true; _engine.Dispose(); } }