poe2-bot/src/Automata.Ui/ViewModels/RobotoViewModel.cs
2026-03-05 11:26:30 -05:00

729 lines
24 KiB
C#

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;
/// <summary>
/// Thread-safe snapshot read by the overlay layer each frame.
/// </summary>
public sealed class EntityOverlayData
{
/// <summary>Player position at time the entity list was built (cold tick, 10Hz).</summary>
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;
}
}
/// <summary>
/// View model item for entity checkbox list.
/// </summary>
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<EntityListItem> Entities { get; } = [];
// ── Profile Editor ──
[ObservableProperty] private string _characterName = "—";
[ObservableProperty] private bool _hasProfile;
[ObservableProperty] private string _profileName = "—";
[ObservableProperty] private string? _selectedProfile;
public ObservableCollection<string> AvailableProfiles { get; } = [];
public ObservableCollection<SkillProfileViewModel> 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;
/// <summary>
/// Thread-safe snapshot for the overlay layer (written on UI thread, read on overlay thread).
/// </summary>
public static volatile EntityOverlayData? OverlayData;
/// <summary>
/// Shared GameDataCache for the overlay layer to read camera/player data directly.
/// </summary>
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<uint>();
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<EntityOverlayEntry>();
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();
}
}