This commit is contained in:
Boki 2026-03-03 12:54:30 -05:00
parent a8341e8232
commit a8c43ba7e2
43 changed files with 2618 additions and 48 deletions

View file

@ -1,8 +1,12 @@
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;
@ -62,6 +66,11 @@ public partial class EntityListItem : ObservableObject
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;
@ -84,15 +93,48 @@ public partial class RobotoViewModel : ObservableObject, IDisposable
// 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>
@ -103,14 +145,14 @@ public partial class RobotoViewModel : ObservableObject, IDisposable
/// </summary>
public static volatile GameDataCache? SharedCache;
public RobotoViewModel()
public RobotoViewModel(IClientLogWatcher logWatcher)
{
var config = new BotConfig();
var reader = new GameMemoryReader();
var humanizer = new Humanizer(config);
var input = new InterceptionInputController(humanizer);
_engine = new BotEngine(config, reader, input);
_engine = new BotEngine(config, reader, input, humanizer);
_engine.StatusChanged += status =>
{
@ -118,6 +160,17 @@ public partial class RobotoViewModel : ObservableObject, IDisposable
};
_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]
@ -147,6 +200,7 @@ public partial class RobotoViewModel : ObservableObject, IDisposable
EntityCount = "—";
HostileCount = "—";
TickInfo = "—";
ApmInfo = "0";
NavMode = "Idle";
NavStatus = "—";
Entities.Clear();
@ -167,8 +221,147 @@ public partial class RobotoViewModel : ObservableObject, IDisposable
_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;
@ -197,6 +390,7 @@ public partial class RobotoViewModel : ObservableObject, IDisposable
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;
@ -207,6 +401,35 @@ public partial class RobotoViewModel : ObservableObject, IDisposable
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);
}
@ -255,6 +478,220 @@ public partial class RobotoViewModel : ObservableObject, IDisposable
}
}
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 "?";