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

@ -14,9 +14,12 @@
"Metadata/Chests/LeagueIncursion/EncounterChest",
"Metadata/Chests/MossyChest11",
"Metadata/Chests/MossyChest13",
"Metadata/Chests/MossyChest14",
"Metadata/Chests/MossyChest20",
"Metadata/Chests/MossyChest21",
"Metadata/Chests/MossyChest26",
"Metadata/Chests/MuddyChest1",
"Metadata/Critters/BloodWorm/BloodWormBrown",
"Metadata/Critters/Chicken/Chicken_kingsmarch",
"Metadata/Critters/Crow/Crow",
"Metadata/Critters/Ferret/Ferret",
@ -64,6 +67,8 @@
"Metadata/Monsters/Hags/Objects/BossRoomMinimapIcon",
"Metadata/Monsters/Hags/UrchinHag1",
"Metadata/Monsters/Hags/UrchinHagBoss",
"Metadata/Monsters/HuhuGrub/HuhuGrubLarvaeEmerge1",
"Metadata/Monsters/HuhuGrub/HuhuGrubLarvaeRanged1",
"Metadata/Monsters/InvisibleFire/MDCarrionCroneWave",
"Metadata/Monsters/MonsterMods/OnDeathColdExplosionParent",
"Metadata/Monsters/Urchins/MeleeUrchin1",
@ -148,6 +153,7 @@
"Metadata/Terrain/Tools/AudioTools/G1_2/ForestEntrance",
"Metadata/Terrain/Tools/AudioTools/G1_2/HagArena",
"Metadata/Terrain/Tools/AudioTools/G1_2/RiverRapidsMedium",
"Metadata/Terrain/Tools/AudioTools/G1_3/TunnelA",
"Metadata/Terrain/Tools/AudioTools/G1_4/WitchHutIndoorAudio",
"Metadata/Terrain/Tools/AudioTools/G1_Town/FurnaceFireAudio",
"Metadata/Terrain/Tools/AudioTools/G1_Town/InsideWaterMillAudio"

View file

@ -0,0 +1,158 @@
{
"Name": "GooGoGaaGa_Default",
"CreatedAt": "2026-03-03T16:37:43.1785247Z",
"LastModified": "2026-03-03T16:37:43.1785248Z",
"Flasks": {
"LifeFlaskThreshold": 50,
"ManaFlaskThreshold": 50,
"FlaskCooldownMs": 4000,
"LifeFlaskScanCode": 2,
"ManaFlaskScanCode": 3
},
"Combat": {
"GlobalCooldownMs": 500,
"AttackRange": 600,
"SafeRange": 400,
"KiteEnabled": false,
"KiteRange": 300,
"KiteDelayMs": 200
},
"Skills": [
{
"SlotIndex": 0,
"Label": "LMB",
"InputType": "LeftClick",
"ScanCode": 0,
"Priority": 0,
"IsEnabled": true,
"CooldownMs": 300,
"RangeMin": 0,
"RangeMax": 600,
"TargetSelection": "Nearest",
"RequiresTarget": true,
"IsAura": false,
"IsMovementSkill": false,
"MinMonstersInRange": 1,
"MaintainPressed": false
},
{
"SlotIndex": 1,
"Label": "RMB",
"InputType": "RightClick",
"ScanCode": 0,
"Priority": 1,
"IsEnabled": true,
"CooldownMs": 300,
"RangeMin": 0,
"RangeMax": 600,
"TargetSelection": "Nearest",
"RequiresTarget": true,
"IsAura": false,
"IsMovementSkill": false,
"MinMonstersInRange": 1,
"MaintainPressed": false
},
{
"SlotIndex": 2,
"Label": "MMB",
"InputType": "MiddleClick",
"ScanCode": 0,
"Priority": 2,
"IsEnabled": true,
"CooldownMs": 300,
"RangeMin": 0,
"RangeMax": 600,
"TargetSelection": "Nearest",
"RequiresTarget": true,
"IsAura": false,
"IsMovementSkill": false,
"MinMonstersInRange": 1,
"MaintainPressed": false
},
{
"SlotIndex": 3,
"Label": "Q",
"InputType": "KeyPress",
"ScanCode": 16,
"Priority": 3,
"IsEnabled": true,
"CooldownMs": 300,
"RangeMin": 0,
"RangeMax": 600,
"TargetSelection": "Nearest",
"RequiresTarget": true,
"IsAura": false,
"IsMovementSkill": false,
"MinMonstersInRange": 1,
"MaintainPressed": false
},
{
"SlotIndex": 4,
"Label": "E",
"InputType": "KeyPress",
"ScanCode": 18,
"Priority": 4,
"IsEnabled": true,
"CooldownMs": 300,
"RangeMin": 0,
"RangeMax": 600,
"TargetSelection": "Nearest",
"RequiresTarget": true,
"IsAura": false,
"IsMovementSkill": false,
"MinMonstersInRange": 1,
"MaintainPressed": false
},
{
"SlotIndex": 5,
"Label": "R",
"InputType": "KeyPress",
"ScanCode": 19,
"Priority": 5,
"IsEnabled": true,
"CooldownMs": 300,
"RangeMin": 0,
"RangeMax": 600,
"TargetSelection": "Nearest",
"RequiresTarget": true,
"IsAura": false,
"IsMovementSkill": false,
"MinMonstersInRange": 1,
"MaintainPressed": false
},
{
"SlotIndex": 6,
"Label": "T",
"InputType": "KeyPress",
"ScanCode": 20,
"Priority": 6,
"IsEnabled": true,
"CooldownMs": 300,
"RangeMin": 0,
"RangeMax": 600,
"TargetSelection": "Nearest",
"RequiresTarget": true,
"IsAura": false,
"IsMovementSkill": false,
"MinMonstersInRange": 1,
"MaintainPressed": false
},
{
"SlotIndex": 7,
"Label": "F",
"InputType": "KeyPress",
"ScanCode": 33,
"Priority": 7,
"IsEnabled": true,
"CooldownMs": 300,
"RangeMin": 0,
"RangeMax": 600,
"TargetSelection": "Nearest",
"RequiresTarget": true,
"IsAura": false,
"IsMovementSkill": false,
"MinMonstersInRange": 1,
"MaintainPressed": false
}
]
}

View file

@ -0,0 +1,166 @@
{
"Name": "GooGoGaaGa_Default_Copy",
"CreatedAt": "2026-03-03T16:40:21.060139Z",
"LastModified": "2026-03-03T16:57:12.368683Z",
"Flasks": {
"LifeFlaskThreshold": 50,
"ManaFlaskThreshold": 50,
"FlaskCooldownMs": 4000,
"LifeFlaskScanCode": 2,
"ManaFlaskScanCode": 3
},
"Combat": {
"GlobalCooldownMs": 500,
"AttackRange": 600,
"SafeRange": 400,
"KiteEnabled": false,
"KiteRange": 300,
"KiteDelayMs": 200
},
"Skills": [
{
"SlotIndex": 0,
"Label": "LMB",
"SkillName": "MeleeSpearOffHand",
"InputType": "LeftClick",
"ScanCode": 0,
"Priority": 2,
"IsEnabled": true,
"CooldownMs": 300,
"RangeMin": 0,
"RangeMax": 10,
"TargetSelection": "Nearest",
"RequiresTarget": true,
"IsAura": false,
"IsMovementSkill": false,
"MinMonstersInRange": 1,
"MaintainPressed": false
},
{
"SlotIndex": 1,
"Label": "RMB",
"SkillName": "SpearThrow",
"InputType": "RightClick",
"ScanCode": 0,
"Priority": 1,
"IsEnabled": true,
"CooldownMs": 300,
"RangeMin": 0,
"RangeMax": 800,
"TargetSelection": "All",
"RequiresTarget": true,
"IsAura": false,
"IsMovementSkill": false,
"MinMonstersInRange": 1,
"MaintainPressed": false
},
{
"SlotIndex": 2,
"Label": "MMB",
"SkillName": "Twister",
"InputType": "MiddleClick",
"ScanCode": 0,
"Priority": 0,
"IsEnabled": true,
"CooldownMs": 300,
"RangeMin": 0,
"RangeMax": 1000,
"TargetSelection": "All",
"RequiresTarget": true,
"IsAura": false,
"IsMovementSkill": false,
"MinMonstersInRange": 1,
"MaintainPressed": false
},
{
"SlotIndex": 3,
"Label": "Q",
"SkillName": null,
"InputType": "KeyPress",
"ScanCode": 16,
"Priority": 3,
"IsEnabled": true,
"CooldownMs": 300,
"RangeMin": 0,
"RangeMax": 600,
"TargetSelection": "Nearest",
"RequiresTarget": true,
"IsAura": false,
"IsMovementSkill": false,
"MinMonstersInRange": 1,
"MaintainPressed": false
},
{
"SlotIndex": 4,
"Label": "E",
"SkillName": null,
"InputType": "KeyPress",
"ScanCode": 18,
"Priority": 4,
"IsEnabled": true,
"CooldownMs": 300,
"RangeMin": 0,
"RangeMax": 600,
"TargetSelection": "Nearest",
"RequiresTarget": true,
"IsAura": false,
"IsMovementSkill": false,
"MinMonstersInRange": 1,
"MaintainPressed": false
},
{
"SlotIndex": 5,
"Label": "R",
"SkillName": null,
"InputType": "KeyPress",
"ScanCode": 19,
"Priority": 5,
"IsEnabled": true,
"CooldownMs": 300,
"RangeMin": 0,
"RangeMax": 600,
"TargetSelection": "Nearest",
"RequiresTarget": true,
"IsAura": false,
"IsMovementSkill": false,
"MinMonstersInRange": 1,
"MaintainPressed": false
},
{
"SlotIndex": 6,
"Label": "T",
"SkillName": null,
"InputType": "KeyPress",
"ScanCode": 20,
"Priority": 6,
"IsEnabled": true,
"CooldownMs": 300,
"RangeMin": 0,
"RangeMax": 600,
"TargetSelection": "Nearest",
"RequiresTarget": true,
"IsAura": false,
"IsMovementSkill": false,
"MinMonstersInRange": 1,
"MaintainPressed": false
},
{
"SlotIndex": 7,
"Label": "F",
"SkillName": null,
"InputType": "KeyPress",
"ScanCode": 33,
"Priority": 7,
"IsEnabled": true,
"CooldownMs": 300,
"RangeMin": 0,
"RangeMax": 600,
"TargetSelection": "Nearest",
"RequiresTarget": true,
"IsAura": false,
"IsMovementSkill": false,
"MinMonstersInRange": 1,
"MaintainPressed": false
}
]
}

View file

@ -0,0 +1,3 @@
{
"GooGoGaaGa": "GooGoGaaGa_Default_Copy"
}

View file

@ -120,6 +120,9 @@ public class MinimapConfig
// Explored radius: pixels around player position to mark as explored on world map
public int ExploredRadius { get; set; } = 75;
// Beyond this canvas-pixel distance from the player, explored cells render as lighter gray
public int ExploredFarRadius { get; set; } = 120;
// Temporal smoothing: majority vote over ring buffer (walls only)
public int TemporalFrameCount { get; set; } = 5;
public int WallTemporalThreshold { get; set; } = 3;

View file

@ -483,13 +483,21 @@ public class WorldMap : IDisposable
var y0 = Math.Clamp(cy - half, 0, _canvasSize - viewSize);
var roi = new Mat(_canvas, new Rect(x0, y0, viewSize, viewSize));
var farR2 = _config.ExploredFarRadius * _config.ExploredFarRadius;
var nearColor = new Vec3b(104, 64, 31);
var farColor = new Vec3b(90, 90, 90);
using var colored = new Mat(viewSize, viewSize, MatType.CV_8UC3, new Scalar(23, 17, 13));
for (var r = 0; r < viewSize; r++)
for (var c = 0; c < viewSize; c++)
{
var v = roi.At<byte>(r, c);
if (v == (byte)MapCell.Explored)
colored.Set(r, c, new Vec3b(104, 64, 31));
{
var dx = c - half;
var dy = r - half;
colored.Set(r, c, dx * dx + dy * dy > farR2 ? farColor : nearColor);
}
else if (v == (byte)MapCell.Wall)
colored.Set(r, c, new Vec3b(26, 45, 61));
else if (v == (byte)MapCell.Fog)

View file

@ -859,6 +859,13 @@ public partial class MemoryViewModel : ObservableObject
if (e.HasPosition)
parts.Add($"({e.X:F0},{e.Y:F0})");
if (e.TransitionName is not null)
{
parts.Add(e.IsTargetable ? "targetable" : "NOT targetable");
if (e.TransitionState >= 0)
parts.Add($"state:{e.TransitionState}");
}
if (e.HasVitals)
parts.Add($"HP:{e.LifeCurrent}/{e.LifeTotal}");
@ -882,7 +889,11 @@ public partial class MemoryViewModel : ObservableObject
needed.Add(("Path:", e.Path, true));
if (e.TransitionName is not null)
{
needed.Add(("Destination:", e.TransitionName, true));
needed.Add(("TransitionState:", e.TransitionState.ToString(), e.TransitionState >= 0));
needed.Add(("IsTargetable:", e.IsTargetable.ToString(), e.IsTargetable));
}
if (e.HasPosition)
needed.Add(("Pos:", $"({e.X:F1}, {e.Y:F1}, {e.Z:F1})", true));
@ -1197,4 +1208,16 @@ public partial class MemoryViewModel : ObservableObject
ScanResult = _reader.Diagnostics!.ScanActorDiff();
}
[RelayCommand]
private void ScanQuestFlagsExecute()
{
if (_reader is null || !_reader.IsAttached)
{
ScanResult = "Error: not attached";
return;
}
ScanResult = _reader.Diagnostics!.ScanQuestFlags();
}
}

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 "?";

View file

@ -0,0 +1,92 @@
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using Roboto.Core;
namespace Automata.Ui.ViewModels;
public partial class SkillProfileViewModel : ObservableObject
{
private readonly SkillProfile _model;
public SkillProfileViewModel(SkillProfile model)
{
_model = model;
_slotIndex = model.SlotIndex;
_label = model.Label;
_skillName = model.SkillName;
_inputType = model.InputType;
_scanCode = model.ScanCode;
_priority = model.Priority;
_isEnabled = model.IsEnabled;
_cooldownMs = model.CooldownMs;
_rangeMin = model.RangeMin;
_rangeMax = model.RangeMax;
_targetSelection = model.TargetSelection;
_requiresTarget = model.RequiresTarget;
_isAura = model.IsAura;
_isMovementSkill = model.IsMovementSkill;
_minMonstersInRange = model.MinMonstersInRange;
_maintainPressed = model.MaintainPressed;
}
public SkillProfile Model => _model;
[ObservableProperty] private int _slotIndex;
[ObservableProperty] private string _label;
[ObservableProperty] private string? _skillName;
[ObservableProperty] private SkillInputType _inputType;
[ObservableProperty] private ushort _scanCode;
[ObservableProperty] private int _priority;
[ObservableProperty] private bool _isEnabled;
[ObservableProperty] private int _cooldownMs;
[ObservableProperty] private float _rangeMin;
[ObservableProperty] private float _rangeMax;
[ObservableProperty] private TargetSelection _targetSelection;
[ObservableProperty] private bool _requiresTarget;
[ObservableProperty] private bool _isAura;
[ObservableProperty] private bool _isMovementSkill;
[ObservableProperty] private int _minMonstersInRange;
[ObservableProperty] private bool _maintainPressed;
// Available skill names from memory (shared, set by parent VM)
public ObservableCollection<string> AvailableSkillNames { get; } = [];
// UI expand/collapse
[ObservableProperty] private bool _isExpanded;
// Write-through on property changes
partial void OnLabelChanged(string value) => _model.Label = value;
partial void OnSkillNameChanged(string? value) => _model.SkillName = value;
partial void OnInputTypeChanged(SkillInputType value) => _model.InputType = value;
partial void OnScanCodeChanged(ushort value) => _model.ScanCode = value;
partial void OnPriorityChanged(int value) => _model.Priority = value;
partial void OnIsEnabledChanged(bool value) => _model.IsEnabled = value;
partial void OnCooldownMsChanged(int value) => _model.CooldownMs = value;
partial void OnRangeMinChanged(float value) => _model.RangeMin = value;
partial void OnRangeMaxChanged(float value) => _model.RangeMax = value;
partial void OnTargetSelectionChanged(TargetSelection value) => _model.TargetSelection = value;
partial void OnRequiresTargetChanged(bool value) => _model.RequiresTarget = value;
partial void OnIsAuraChanged(bool value) => _model.IsAura = value;
partial void OnIsMovementSkillChanged(bool value) => _model.IsMovementSkill = value;
partial void OnMinMonstersInRangeChanged(int value) => _model.MinMonstersInRange = value;
partial void OnMaintainPressedChanged(bool value) => _model.MaintainPressed = value;
// ComboBox binding sources
public static SkillInputType[] InputTypes { get; } =
Enum.GetValues<SkillInputType>();
public static TargetSelection[] TargetSelections { get; } =
Enum.GetValues<TargetSelection>();
/// <summary>
/// Strips "Player" suffix from a raw memory skill name for display.
/// e.g. "SpearThrowPlayer" → "SpearThrow", "WhirlingSlashPlayer" → "WhirlingSlash"
/// </summary>
public static string CleanSkillName(string? raw)
{
if (raw is null) return "";
if (raw.EndsWith("Player", StringComparison.Ordinal))
return raw[..^6];
return raw;
}
}

View file

@ -776,6 +776,8 @@
Padding="10,4" FontWeight="Bold" Margin="0,0,6,4" />
<Button Content="Actor Diff" Command="{Binding ScanActorDiffExecuteCommand}"
Padding="10,4" FontWeight="Bold" Margin="0,0,6,4" />
<Button Content="Quest Flags" Command="{Binding ScanQuestFlagsExecuteCommand}"
Padding="10,4" FontWeight="Bold" Margin="0,0,6,4" />
</WrapPanel>
<TextBox Text="{Binding ScanResult, Mode=OneWay}" FontFamily="Consolas"
FontSize="10" Foreground="#e6edf3" Background="#0d1117"
@ -832,6 +834,116 @@
</StackPanel>
</Border>
<!-- Character Profile -->
<Border Background="#161b22" BorderBrush="#30363d" BorderThickness="1"
CornerRadius="8" Padding="8"
IsVisible="{Binding HasProfile}">
<StackPanel Spacing="6">
<!-- Header: Character name + profile selector + duplicate -->
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock Text="CHARACTER PROFILE" FontSize="11" FontWeight="SemiBold"
Foreground="#8b949e" VerticalAlignment="Center" />
<TextBlock Text="{Binding CharacterName}" Foreground="#58a6ff"
FontWeight="Bold" FontSize="12" VerticalAlignment="Center" />
</StackPanel>
<StackPanel Orientation="Horizontal" Spacing="8">
<ComboBox ItemsSource="{Binding AvailableProfiles}"
SelectedItem="{Binding SelectedProfile}"
MinWidth="200" FontSize="11" />
<Button Content="Duplicate" Command="{Binding DuplicateProfileCommand}"
Padding="12,4" FontSize="11" />
<Button Content="Delete" Command="{Binding DeleteProfileCommand}"
Padding="12,4" FontSize="11" Foreground="#f85149" />
<Button Content="Save" Command="{Binding SaveProfileCommand}"
Padding="12,4" FontSize="11" FontWeight="Bold" />
</StackPanel>
<!-- Flask Settings -->
<Expander Header="Flask Settings" Foreground="#8b949e" FontSize="11" Padding="0">
<Grid ColumnDefinitions="140,120,20,140,120" RowDefinitions="Auto,Auto" Margin="4">
<TextBlock Grid.Row="0" Grid.Column="0" Text="Life Threshold %:" Foreground="#8b949e" FontSize="11" VerticalAlignment="Center" />
<NumericUpDown Grid.Row="0" Grid.Column="1" Value="{Binding LifeFlaskThreshold}" Minimum="0" Maximum="100" Increment="5" FontSize="11" />
<TextBlock Grid.Row="0" Grid.Column="3" Text="Mana Threshold %:" Foreground="#8b949e" FontSize="11" VerticalAlignment="Center" />
<NumericUpDown Grid.Row="0" Grid.Column="4" Value="{Binding ManaFlaskThreshold}" Minimum="0" Maximum="100" Increment="5" FontSize="11" />
<TextBlock Grid.Row="1" Grid.Column="0" Text="Cooldown (ms):" Foreground="#8b949e" FontSize="11" VerticalAlignment="Center" />
<NumericUpDown Grid.Row="1" Grid.Column="1" Value="{Binding FlaskCooldownMs}" Minimum="0" Maximum="30000" Increment="500" FontSize="11" />
</Grid>
</Expander>
<!-- Combat Settings -->
<Expander Header="Combat Settings" Foreground="#8b949e" FontSize="11" Padding="0">
<Grid ColumnDefinitions="140,120,20,140,120" RowDefinitions="Auto,Auto,Auto" Margin="4">
<TextBlock Grid.Row="0" Grid.Column="0" Text="Global CD (ms):" Foreground="#8b949e" FontSize="11" VerticalAlignment="Center" />
<NumericUpDown Grid.Row="0" Grid.Column="1" Value="{Binding GlobalCooldownMs}" Minimum="0" Maximum="5000" Increment="50" FontSize="11" />
<TextBlock Grid.Row="0" Grid.Column="3" Text="Attack Range:" Foreground="#8b949e" FontSize="11" VerticalAlignment="Center" />
<NumericUpDown Grid.Row="0" Grid.Column="4" Value="{Binding AttackRange}" Minimum="0" Maximum="3000" Increment="50" FontSize="11" />
<TextBlock Grid.Row="1" Grid.Column="0" Text="Safe Range:" Foreground="#8b949e" FontSize="11" VerticalAlignment="Center" />
<NumericUpDown Grid.Row="1" Grid.Column="1" Value="{Binding SafeRange}" Minimum="0" Maximum="3000" Increment="50" FontSize="11" />
<TextBlock Grid.Row="1" Grid.Column="3" Text="Kite Range:" Foreground="#8b949e" FontSize="11" VerticalAlignment="Center" />
<NumericUpDown Grid.Row="1" Grid.Column="4" Value="{Binding KiteRange}" Minimum="0" Maximum="3000" Increment="50" FontSize="11" />
<CheckBox Grid.Row="2" Grid.Column="0" Grid.ColumnSpan="2" IsChecked="{Binding KiteEnabled}"
Content="Enable Kiting" Foreground="#8b949e" FontSize="11" />
<TextBlock Grid.Row="2" Grid.Column="3" Text="Kite Delay (ms):" Foreground="#8b949e" FontSize="11" VerticalAlignment="Center" />
<NumericUpDown Grid.Row="2" Grid.Column="4" Value="{Binding KiteDelayMs}" Minimum="0" Maximum="5000" Increment="50" FontSize="11" />
</Grid>
</Expander>
<!-- Skills -->
<Expander Header="Skills" Foreground="#8b949e" FontSize="11" Padding="0" IsExpanded="True">
<ItemsControl ItemsSource="{Binding SkillProfiles}" Margin="4,0">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:SkillProfileViewModel">
<Border BorderBrush="#21262d" BorderThickness="0,0,0,1" Padding="0,4">
<StackPanel Spacing="4">
<!-- Summary row -->
<StackPanel Orientation="Horizontal" Spacing="6">
<CheckBox IsChecked="{Binding IsEnabled}" VerticalAlignment="Center"
MinWidth="0" Padding="0" />
<TextBlock Text="{Binding Label}" Foreground="#e6edf3"
FontFamily="Consolas" FontSize="11" FontWeight="Bold"
VerticalAlignment="Center" Width="40" />
<ComboBox ItemsSource="{Binding AvailableSkillNames}"
SelectedItem="{Binding SkillName}"
MinWidth="180" FontSize="10"
PlaceholderText="(pick skill)" />
<ComboBox ItemsSource="{Binding TargetSelections}"
SelectedItem="{Binding TargetSelection}"
MinWidth="100" FontSize="10" />
<ToggleButton IsChecked="{Binding IsExpanded}" Content="..."
Padding="8,2" FontSize="10" />
</StackPanel>
<!-- Expanded detail -->
<Grid ColumnDefinitions="120,100,20,120,100" RowDefinitions="Auto,Auto,Auto,Auto"
Margin="28,0,0,0"
IsVisible="{Binding IsExpanded}">
<TextBlock Grid.Row="0" Grid.Column="0" Text="Priority:" Foreground="#8b949e" FontSize="10" VerticalAlignment="Center" />
<NumericUpDown Grid.Row="0" Grid.Column="1" Value="{Binding Priority}" Minimum="0" Maximum="99" FontSize="10" />
<TextBlock Grid.Row="0" Grid.Column="3" Text="Input Type:" Foreground="#8b949e" FontSize="10" VerticalAlignment="Center" />
<ComboBox Grid.Row="0" Grid.Column="4" ItemsSource="{Binding InputTypes}" SelectedItem="{Binding InputType}" FontSize="10" />
<TextBlock Grid.Row="1" Grid.Column="0" Text="Cooldown (ms):" Foreground="#8b949e" FontSize="10" VerticalAlignment="Center" />
<NumericUpDown Grid.Row="1" Grid.Column="1" Value="{Binding CooldownMs}" Minimum="0" Maximum="30000" Increment="100" FontSize="10" />
<TextBlock Grid.Row="1" Grid.Column="3" Text="Min Range:" Foreground="#8b949e" FontSize="10" VerticalAlignment="Center" />
<NumericUpDown Grid.Row="1" Grid.Column="4" Value="{Binding RangeMin}" Minimum="0" Maximum="3000" Increment="50" FontSize="10" />
<TextBlock Grid.Row="2" Grid.Column="0" Text="Max Range:" Foreground="#8b949e" FontSize="10" VerticalAlignment="Center" />
<NumericUpDown Grid.Row="2" Grid.Column="1" Value="{Binding RangeMax}" Minimum="0" Maximum="3000" Increment="50" FontSize="10" />
<TextBlock Grid.Row="2" Grid.Column="3" Text="Min Monsters:" Foreground="#8b949e" FontSize="10" VerticalAlignment="Center" />
<NumericUpDown Grid.Row="2" Grid.Column="4" Value="{Binding MinMonstersInRange}" Minimum="0" Maximum="50" FontSize="10" />
<StackPanel Grid.Row="3" Grid.Column="0" Grid.ColumnSpan="5" Orientation="Horizontal" Spacing="12" Margin="0,4,0,0">
<CheckBox IsChecked="{Binding IsAura}" Content="Aura" Foreground="#8b949e" FontSize="10" MinWidth="0" Padding="4,0,0,0" />
<CheckBox IsChecked="{Binding IsMovementSkill}" Content="Movement" Foreground="#8b949e" FontSize="10" MinWidth="0" Padding="4,0,0,0" />
<CheckBox IsChecked="{Binding MaintainPressed}" Content="Hold Key" Foreground="#8b949e" FontSize="10" MinWidth="0" Padding="4,0,0,0" />
<CheckBox IsChecked="{Binding RequiresTarget}" Content="Needs Target" Foreground="#8b949e" FontSize="10" MinWidth="0" Padding="4,0,0,0" />
</StackPanel>
</Grid>
</StackPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</Expander>
</StackPanel>
</Border>
<!-- Player State -->
<Border Background="#161b22" BorderBrush="#30363d" BorderThickness="1"
CornerRadius="8" Padding="8">
@ -857,7 +969,7 @@
<StackPanel Spacing="4">
<TextBlock Text="GAME STATE" FontSize="11" FontWeight="SemiBold"
Foreground="#8b949e" />
<Grid ColumnDefinitions="100,*" RowDefinitions="Auto,Auto,Auto,Auto,Auto,Auto" Margin="0,4,0,0">
<Grid ColumnDefinitions="100,*" RowDefinitions="Auto,Auto,Auto,Auto,Auto,Auto,Auto" Margin="0,4,0,0">
<TextBlock Grid.Row="0" Grid.Column="0" Text="Area:" Foreground="#8b949e" FontSize="12" />
<TextBlock Grid.Row="0" Grid.Column="1" Text="{Binding AreaInfo}" Foreground="#e6edf3" FontFamily="Consolas" FontSize="12" />
<TextBlock Grid.Row="1" Grid.Column="0" Text="Danger:" Foreground="#8b949e" FontSize="12" />
@ -866,10 +978,12 @@
<TextBlock Grid.Row="2" Grid.Column="1" Text="{Binding EntityCount}" Foreground="#e6edf3" FontFamily="Consolas" FontSize="12" />
<TextBlock Grid.Row="3" Grid.Column="0" Text="Hostiles:" Foreground="#8b949e" FontSize="12" />
<TextBlock Grid.Row="3" Grid.Column="1" Text="{Binding HostileCount}" Foreground="#ff4444" FontFamily="Consolas" FontSize="12" />
<TextBlock Grid.Row="4" Grid.Column="0" Text="Systems:" Foreground="#8b949e" FontSize="12" />
<TextBlock Grid.Row="4" Grid.Column="1" Text="{Binding SystemsInfo}" Foreground="#e6edf3" FontFamily="Consolas" FontSize="12" />
<TextBlock Grid.Row="5" Grid.Column="0" Text="Tick:" Foreground="#8b949e" FontSize="12" />
<TextBlock Grid.Row="5" Grid.Column="1" Text="{Binding TickInfo}" Foreground="#484f58" FontFamily="Consolas" FontSize="12" />
<TextBlock Grid.Row="4" Grid.Column="0" Text="APM:" Foreground="#8b949e" FontSize="12" />
<TextBlock Grid.Row="4" Grid.Column="1" Text="{Binding ApmInfo}" Foreground="#58a6ff" FontFamily="Consolas" FontSize="12" />
<TextBlock Grid.Row="5" Grid.Column="0" Text="Systems:" Foreground="#8b949e" FontSize="12" />
<TextBlock Grid.Row="5" Grid.Column="1" Text="{Binding SystemsInfo}" Foreground="#e6edf3" FontFamily="Consolas" FontSize="12" />
<TextBlock Grid.Row="6" Grid.Column="0" Text="Tick:" Foreground="#8b949e" FontSize="12" />
<TextBlock Grid.Row="6" Grid.Column="1" Text="{Binding TickInfo}" Foreground="#484f58" FontFamily="Consolas" FontSize="12" />
</Grid>
</StackPanel>
</Border>
@ -892,6 +1006,8 @@
<TextBlock Grid.Row="1" Grid.Column="0" Text="Status:" Foreground="#8b949e" FontSize="12" />
<TextBlock Grid.Row="1" Grid.Column="1" Text="{Binding NavStatus}" Foreground="#e6edf3" FontFamily="Consolas" FontSize="12" />
</Grid>
<Image Source="{Binding TerrainImage}" Width="400" Height="400"
Stretch="Uniform" Margin="0,4,0,0" />
</StackPanel>
</Border>

View file

@ -2,7 +2,7 @@ using System.Numerics;
namespace Roboto.Core;
public enum ClickType { Left, Right }
public enum ClickType { Left, Right, Middle }
public enum KeyActionType { Press, Down, Up }
public abstract record BotAction(int Priority);

View file

@ -14,10 +14,6 @@ public class BotConfig
// Navigation
public float WorldToGrid { get; set; } = 23f / 250f;
// Combat
public float CriticalHpPercent { get; set; } = 30f;
public float LowHpPercent { get; set; } = 50f;
// Loot
public float LootPickupRange { get; set; } = 600f;
@ -30,11 +26,4 @@ public class BotConfig
// Anti-detection
public float PollIntervalJitter { get; set; } = 0.2f;
// Flasks
public float LifeFlaskThreshold { get; set; } = 50f;
public float ManaFlaskThreshold { get; set; } = 50f;
public int FlaskCooldownMs { get; set; } = 4000;
public ushort LifeFlaskScanCode { get; set; } = 0x02; // Key1
public ushort ManaFlaskScanCode { get; set; } = 0x03; // Key2
}

View file

@ -0,0 +1,24 @@
namespace Roboto.Core;
public class CharacterProfile
{
public string Name { get; set; } = "Default";
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime LastModified { get; set; } = DateTime.UtcNow;
public FlaskSettings Flasks { get; set; } = new();
public CombatSettings Combat { get; set; } = new();
public List<SkillProfile> Skills { get; set; } = DefaultSkills();
public static List<SkillProfile> DefaultSkills() =>
[
new() { SlotIndex = 0, Label = "LMB", InputType = SkillInputType.LeftClick, Priority = 0 },
new() { SlotIndex = 1, Label = "RMB", InputType = SkillInputType.RightClick, Priority = 1 },
new() { SlotIndex = 2, Label = "MMB", InputType = SkillInputType.MiddleClick, Priority = 2 },
new() { SlotIndex = 3, Label = "Q", InputType = SkillInputType.KeyPress, ScanCode = 0x10, Priority = 3 },
new() { SlotIndex = 4, Label = "E", InputType = SkillInputType.KeyPress, ScanCode = 0x12, Priority = 4 },
new() { SlotIndex = 5, Label = "R", InputType = SkillInputType.KeyPress, ScanCode = 0x13, Priority = 5 },
new() { SlotIndex = 6, Label = "T", InputType = SkillInputType.KeyPress, ScanCode = 0x14, Priority = 6 },
new() { SlotIndex = 7, Label = "F", InputType = SkillInputType.KeyPress, ScanCode = 0x21, Priority = 7 },
];
}

View file

@ -0,0 +1,11 @@
namespace Roboto.Core;
public class CombatSettings
{
public int GlobalCooldownMs { get; set; } = 500;
public float AttackRange { get; set; } = 600f;
public float SafeRange { get; set; } = 400f;
public bool KiteEnabled { get; set; }
public float KiteRange { get; set; } = 300f;
public int KiteDelayMs { get; set; } = 200;
}

View file

@ -58,6 +58,7 @@ public record EntitySnapshot
public MonsterRarity Rarity { get; init; }
public List<string>? ModNames { get; init; }
public string? TransitionName { get; init; }
public int TransitionState { get; init; } = -1;
public string? Metadata { get; init; }
// Action state (from Actor component)

View file

@ -0,0 +1,10 @@
namespace Roboto.Core;
public class FlaskSettings
{
public float LifeFlaskThreshold { get; set; } = 50f;
public float ManaFlaskThreshold { get; set; } = 50f;
public int FlaskCooldownMs { get; set; } = 4000;
public ushort LifeFlaskScanCode { get; set; } = 0x02; // Key1
public ushort ManaFlaskScanCode { get; set; } = 0x03; // Key2
}

View file

@ -16,10 +16,12 @@ public class GameState
public uint AreaHash { get; set; }
public int AreaLevel { get; set; }
public string? CurrentAreaName { get; set; }
public bool IsLoading { get; set; }
public bool IsEscapeOpen { get; set; }
public DangerLevel Danger { get; set; }
public Matrix4x4? CameraMatrix { get; set; }
public IReadOnlyList<QuestProgress> ActiveQuests { get; set; } = [];
// Derived (computed once per tick by GameStateEnricher)
public ThreatMap Threats { get; set; } = new();

View file

@ -7,9 +7,11 @@ public interface IInputController
void KeyUp(ushort scanCode);
void KeyPress(ushort scanCode, int holdMs = 50);
void MouseMoveTo(int x, int y);
void SmoothMoveTo(int x, int y);
void MouseMoveBy(int dx, int dy);
void LeftClick(int x, int y);
void RightClick(int x, int y);
void MiddleClick(int x, int y);
void LeftDown();
void LeftUp();
void RightDown();

View file

@ -4,6 +4,7 @@ namespace Roboto.Core;
public record PlayerState
{
public string? CharacterName { get; init; }
public Vector2 Position { get; init; }
public float Z { get; init; }
public bool HasPosition { get; init; }
@ -19,6 +20,9 @@ public record PlayerState
public float ManaPercent => ManaTotal > 0 ? (float)ManaCurrent / ManaTotal * 100f : 0f;
public float EsPercent => EsTotal > 0 ? (float)EsCurrent / EsTotal * 100f : 0f;
// Action state (from Actor component)
public short ActionId { get; init; }
// Flask state (populated by memory when available)
public IReadOnlyList<FlaskState> Flasks { get; init; } = [];

View file

@ -0,0 +1,174 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Roboto.Core;
public sealed class ProfileManager
{
private static readonly JsonSerializerOptions JsonOpts = new()
{
WriteIndented = true,
Converters = { new JsonStringEnumConverter() },
};
private readonly string _profilesDir;
private readonly string _assignmentsFile;
private readonly object _lock = new();
private Dictionary<string, string> _assignments = new(); // charName → profileName
public ProfileManager(string profilesDir = "profiles")
{
_profilesDir = Path.GetFullPath(profilesDir);
_assignmentsFile = Path.Combine(_profilesDir, "_assignments.json");
EnsureDirectory();
LoadAssignments();
}
public CharacterProfile LoadForCharacter(string charName)
{
lock (_lock)
{
if (_assignments.TryGetValue(charName, out var profileName))
{
var profile = LoadProfile(profileName);
if (profile is not null)
return profile;
}
// Create default profile for this character
var defaultName = $"{charName}_Default";
var defaultProfile = new CharacterProfile { Name = defaultName };
SaveProfile(defaultProfile);
_assignments[charName] = defaultName;
SaveAssignments();
return defaultProfile;
}
}
public void Save(CharacterProfile profile)
{
lock (_lock)
{
profile.LastModified = DateTime.UtcNow;
SaveProfile(profile);
}
}
public CharacterProfile? Duplicate(string profileName, string newName)
{
lock (_lock)
{
var source = LoadProfile(profileName);
if (source is null) return null;
source.Name = newName;
source.CreatedAt = DateTime.UtcNow;
source.LastModified = DateTime.UtcNow;
SaveProfile(source);
return source;
}
}
public void AssignToCharacter(string charName, string profileName)
{
lock (_lock)
{
_assignments[charName] = profileName;
SaveAssignments();
}
}
public void Delete(string profileName)
{
lock (_lock)
{
var path = ProfilePath(profileName);
if (File.Exists(path))
File.Delete(path);
// Remove any assignments pointing to this profile
var toRemove = _assignments
.Where(kv => kv.Value == profileName)
.Select(kv => kv.Key)
.ToList();
foreach (var key in toRemove)
_assignments.Remove(key);
if (toRemove.Count > 0)
SaveAssignments();
}
}
public List<string> ListProfiles()
{
lock (_lock)
{
EnsureDirectory();
return Directory.GetFiles(_profilesDir, "*.json")
.Select(Path.GetFileNameWithoutExtension)
.Where(n => n is not null && !n.StartsWith("_"))
.Cast<string>()
.OrderBy(n => n)
.ToList();
}
}
public string? GetAssignment(string charName)
{
lock (_lock)
{
return _assignments.GetValueOrDefault(charName);
}
}
private CharacterProfile? LoadProfile(string name)
{
var path = ProfilePath(name);
if (!File.Exists(path)) return null;
try
{
var json = File.ReadAllText(path);
return JsonSerializer.Deserialize<CharacterProfile>(json, JsonOpts);
}
catch
{
return null;
}
}
private void SaveProfile(CharacterProfile profile)
{
EnsureDirectory();
var json = JsonSerializer.Serialize(profile, JsonOpts);
File.WriteAllText(ProfilePath(profile.Name), json);
}
private void LoadAssignments()
{
if (!File.Exists(_assignmentsFile)) return;
try
{
var json = File.ReadAllText(_assignmentsFile);
_assignments = JsonSerializer.Deserialize<Dictionary<string, string>>(json, JsonOpts) ?? new();
}
catch
{
_assignments = new();
}
}
private void SaveAssignments()
{
EnsureDirectory();
var json = JsonSerializer.Serialize(_assignments, JsonOpts);
File.WriteAllText(_assignmentsFile, json);
}
private void EnsureDirectory()
{
if (!Directory.Exists(_profilesDir))
Directory.CreateDirectory(_profilesDir);
}
private string ProfilePath(string name) => Path.Combine(_profilesDir, $"{name}.json");
}

View file

@ -0,0 +1,9 @@
namespace Roboto.Core;
public record QuestProgress
{
public string? QuestName { get; init; }
public byte StateId { get; init; }
public string? StateText { get; init; }
public string? ProgressText { get; init; }
}

View file

@ -0,0 +1,25 @@
namespace Roboto.Core;
public enum SkillInputType { KeyPress, LeftClick, RightClick, MiddleClick }
public class SkillProfile
{
public int SlotIndex { get; set; }
public string Label { get; set; } = "";
public string? SkillName { get; set; }
public SkillInputType InputType { get; set; }
public ushort ScanCode { get; set; }
public int Priority { get; set; }
public bool IsEnabled { get; set; } = true;
public int CooldownMs { get; set; } = 300;
public float RangeMin { get; set; }
public float RangeMax { get; set; } = 600f;
public TargetSelection TargetSelection { get; set; } = TargetSelection.Nearest;
public bool RequiresTarget { get; set; } = true;
public bool IsAura { get; set; }
public bool IsMovementSkill { get; set; }
public int MinMonstersInRange { get; set; } = 1;
public bool MaintainPressed { get; set; }
}

View file

@ -0,0 +1,11 @@
namespace Roboto.Core;
public enum TargetSelection
{
Nearest,
All,
Rarest,
MagicPlus,
RarePlus,
UniqueOnly,
}

View file

@ -0,0 +1,33 @@
using System.Numerics;
namespace Roboto.Core;
public static class WorldToScreen
{
private const float HalfW = 1280f; // 2560 / 2
private const float HalfH = 720f; // 1440 / 2
/// <summary>
/// Projects a world position to screen coordinates using the camera's view-projection matrix.
/// Returns null if the point is behind the camera or off screen.
/// </summary>
public static Vector2? Project(Vector2 worldPos, float z, Matrix4x4 cameraMatrix)
{
var world = new Vector4(worldPos.X, worldPos.Y, z, 1f);
var clip = Vector4.Transform(world, cameraMatrix);
if (clip.W is 0f or float.NaN)
return null;
clip = Vector4.Divide(clip, clip.W);
var sx = (clip.X + 1f) * HalfW;
var sy = (1f - clip.Y) * HalfH;
// Off-screen check
if (sx < 0 || sx > 2560 || sy < 0 || sy > 1440)
return null;
return new Vector2(sx, sy);
}
}

View file

@ -0,0 +1,201 @@
using System.Text.Json;
namespace Roboto.Data;
public record AreaNode(
string Id,
string Name,
int Act,
int Level,
int Order,
bool HasWaypoint,
bool IsTown,
List<string> ConnectsTo);
/// <summary>
/// Graph of game areas loaded from data/poe2/areas.json.
/// Supports progression ordering, adjacency queries, and BFS pathfinding.
/// </summary>
public sealed class AreaGraph
{
private readonly Dictionary<string, AreaNode> _byId;
private readonly Dictionary<string, AreaNode> _byName;
private readonly List<AreaNode> _allByOrder;
private AreaGraph(Dictionary<string, AreaNode> byId, Dictionary<string, AreaNode> byName, List<AreaNode> allByOrder)
{
_byId = byId;
_byName = byName;
_allByOrder = allByOrder;
}
public AreaNode? GetById(string id)
=> _byId.GetValueOrDefault(id);
public AreaNode? GetByName(string name)
=> _byName.GetValueOrDefault(name);
public List<AreaNode> GetConnections(string id)
{
if (!_byId.TryGetValue(id, out var node)) return [];
var result = new List<AreaNode>(node.ConnectsTo.Count);
foreach (var cid in node.ConnectsTo)
{
if (_byId.TryGetValue(cid, out var connected))
result.Add(connected);
}
return result;
}
/// <summary>
/// BFS from currentId to find the lowest-order unvisited reachable area.
/// Returns the area ID to target next, or null if progression is complete.
/// </summary>
public string? FindNextTarget(string currentId, HashSet<string> visited)
{
if (!_byId.ContainsKey(currentId)) return null;
// BFS to find all reachable areas
var reachable = new HashSet<string>();
var queue = new Queue<string>();
queue.Enqueue(currentId);
reachable.Add(currentId);
while (queue.Count > 0)
{
var id = queue.Dequeue();
if (!_byId.TryGetValue(id, out var node)) continue;
foreach (var cid in node.ConnectsTo)
{
if (reachable.Add(cid))
queue.Enqueue(cid);
}
}
// Find the lowest-order unvisited reachable area
AreaNode? best = null;
foreach (var rid in reachable)
{
if (visited.Contains(rid)) continue;
if (!_byId.TryGetValue(rid, out var node)) continue;
if (best is null || CompareOrder(node, best) < 0)
best = node;
}
return best?.Id;
}
/// <summary>
/// BFS shortest path from fromId to toId through the area graph.
/// Returns the sequence of area IDs, or null if unreachable.
/// </summary>
public List<string>? FindAreaPath(string fromId, string toId)
{
if (fromId == toId) return [fromId];
if (!_byId.ContainsKey(fromId) || !_byId.ContainsKey(toId)) return null;
var prev = new Dictionary<string, string>();
var queue = new Queue<string>();
queue.Enqueue(fromId);
prev[fromId] = fromId;
while (queue.Count > 0)
{
var id = queue.Dequeue();
if (id == toId)
{
// Reconstruct path
var path = new List<string>();
var cur = toId;
while (cur != fromId)
{
path.Add(cur);
cur = prev[cur];
}
path.Add(fromId);
path.Reverse();
return path;
}
if (!_byId.TryGetValue(id, out var node)) continue;
foreach (var cid in node.ConnectsTo)
{
if (!prev.ContainsKey(cid))
{
prev[cid] = id;
queue.Enqueue(cid);
}
}
}
return null; // unreachable
}
/// <summary>
/// Compare two nodes by (act, order) for progression ordering.
/// </summary>
private static int CompareOrder(AreaNode a, AreaNode b)
{
var actCmp = a.Act.CompareTo(b.Act);
return actCmp != 0 ? actCmp : a.Order.CompareTo(b.Order);
}
/// <summary>
/// Returns all area IDs with order less than or equal to the given area's order (same act).
/// Used to auto-mark earlier areas as visited so progression always moves forward.
/// </summary>
public List<string> GetEarlierAreas(string areaId)
{
if (!_byId.TryGetValue(areaId, out var current)) return [];
var result = new List<string>();
foreach (var node in _allByOrder)
{
if (node.Act == current.Act && node.Order < current.Order)
result.Add(node.Id);
}
return result;
}
public static AreaGraph Load()
{
var byId = new Dictionary<string, AreaNode>(StringComparer.OrdinalIgnoreCase);
var byName = new Dictionary<string, AreaNode>(StringComparer.OrdinalIgnoreCase);
var allByOrder = new List<AreaNode>();
try
{
var path = Path.Combine("data", "poe2", "areas.json");
if (!File.Exists(path)) return new AreaGraph(byId, byName, allByOrder);
var json = File.ReadAllText(path);
using var doc = JsonDocument.Parse(json);
foreach (var actElement in doc.RootElement.EnumerateArray())
{
var act = actElement.GetProperty("act").GetInt32();
foreach (var area in actElement.GetProperty("areas").EnumerateArray())
{
var id = area.GetProperty("id").GetString()!;
var name = area.GetProperty("name").GetString()!;
var level = area.GetProperty("level").GetInt32();
var order = area.GetProperty("order").GetInt32();
var wp = area.GetProperty("wp").GetBoolean();
var town = area.TryGetProperty("town", out var townProp) && townProp.GetBoolean();
var connects = new List<string>();
foreach (var c in area.GetProperty("connects").EnumerateArray())
connects.Add(c.GetString()!);
var node = new AreaNode(id, name, act, level, order, wp, town, connects);
byId[id] = node;
byName[name] = node;
allByOrder.Add(node);
}
}
}
catch { /* non-critical */ }
allByOrder.Sort((a, b) => CompareOrder(a, b));
return new AreaGraph(byId, byName, allByOrder);
}
}

View file

@ -33,6 +33,7 @@ public static class EntityMapper
Components = e.Components,
ModNames = e.ModNames,
TransitionName = AreaNameLookup.Resolve(e.TransitionName) ?? e.TransitionName,
TransitionState = e.TransitionState,
ActionId = e.ActionId,
IsAttacking = e.IsAttacking,
IsMoving = e.IsMoving,

View file

@ -86,6 +86,8 @@ public sealed class GameDataCache
public volatile WalkabilitySnapshot? Terrain;
public volatile uint AreaHash;
public volatile int AreaLevel;
public volatile string? CurrentAreaName;
public volatile string? CharacterName;
// ── Full GameState (updated at 10Hz) — for systems that need the complete object ──
public volatile GameState? LatestState;

View file

@ -142,6 +142,7 @@ public sealed class MemoryPoller : IDisposable
_cache.Terrain = state.Terrain;
_cache.AreaHash = state.AreaHash;
_cache.AreaLevel = state.AreaLevel;
_cache.CharacterName = state.Player.CharacterName;
_cache.LatestState = state;
_cache.ColdTickTimestamp = Environment.TickCount64;
@ -258,12 +259,14 @@ public sealed class MemoryPoller : IDisposable
state.AreaHash = snap.AreaHash;
state.AreaLevel = snap.AreaLevel;
state.CurrentAreaName = _cache.CurrentAreaName;
state.IsLoading = snap.IsLoading;
state.IsEscapeOpen = snap.IsEscapeOpen;
state.CameraMatrix = snap.CameraMatrix;
state.Player = new PlayerState
{
CharacterName = snap.CharacterName,
HasPosition = snap.HasPosition,
Position = snap.HasPosition ? new Vector2(snap.PlayerX, snap.PlayerY) : Vector2.Zero,
Z = snap.PlayerZ,
@ -273,10 +276,24 @@ public sealed class MemoryPoller : IDisposable
ManaTotal = snap.ManaTotal,
EsCurrent = snap.EsCurrent,
EsTotal = snap.EsTotal,
Skills = snap.PlayerSkills?.Select((s, i) => new SkillState
{
SlotIndex = i,
Name = s.Name,
CanBeUsed = s.CanBeUsed,
CooldownRemaining = s.ActiveCooldowns > 0 ? s.CooldownTimeMs : 0,
}).ToList() ?? [],
};
if (snap.Entities is { Count: > 0 })
{
// Extract player action state before filtering
var playerEntity = snap.Entities.FirstOrDefault(e => e.Address == snap.LocalPlayerPtr);
if (playerEntity is not null)
{
state.Player = state.Player with { ActionId = playerEntity.ActionId };
}
var playerPos = state.Player.Position;
var allEntities = new List<EntitySnapshot>(snap.Entities.Count);
var hostiles = new List<EntitySnapshot>();
@ -300,6 +317,17 @@ public sealed class MemoryPoller : IDisposable
state.NearbyLoot = loot;
}
if (snap.QuestFlags is { Count: > 0 })
{
state.ActiveQuests = snap.QuestFlags.Select(q => new QuestProgress
{
QuestName = q.QuestName,
StateId = q.StateId,
StateText = q.StateText,
ProgressText = q.ProgressText,
}).ToList();
}
if (snap.Terrain is not null)
{
state.Terrain = new WalkabilitySnapshot

View file

@ -60,6 +60,20 @@ public sealed class Humanizer
_actionTimestamps.Enqueue(Environment.TickCount64);
}
/// <summary>
/// Current actions per minute (rolling 60s window).
/// </summary>
public int CurrentApm
{
get
{
var now = Environment.TickCount64;
while (_actionTimestamps.Count > 0 && now - _actionTimestamps.Peek() > 60_000)
_actionTimestamps.Dequeue();
return _actionTimestamps.Count;
}
}
/// <summary>
/// Returns base interval ± jitter% for poll/tick randomization.
/// </summary>

View file

@ -1,16 +1,26 @@
using System.Runtime.InteropServices;
using InputInterceptorNS;
using Roboto.Core;
using Serilog;
namespace Roboto.Input;
public sealed class InterceptionInputController : IInputController, IDisposable
public sealed partial class InterceptionInputController : IInputController, IDisposable
{
private static readonly Random Rng = new();
private readonly Humanizer? _humanizer;
private KeyboardHook? _keyboard;
private MouseHook? _mouse;
private bool _disposed;
[StructLayout(LayoutKind.Sequential)]
private struct POINT { public int X; public int Y; }
[LibraryImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
private static partial bool GetCursorPos(out POINT lpPoint);
public bool IsInitialized => _keyboard is not null && _mouse is not null;
public InterceptionInputController(Humanizer? humanizer = null)
@ -79,6 +89,56 @@ public sealed class InterceptionInputController : IInputController, IDisposable
_mouse?.MoveCursorBy(dx, dy, false);
}
public void SmoothMoveTo(int x, int y)
{
if (!GetCursorPos(out var pt)) { MouseMoveTo(x, y); return; }
var dx = (double)(x - pt.X);
var dy = (double)(y - pt.Y);
var distance = Math.Sqrt(dx * dx + dy * dy);
if (distance < 15) { MouseMoveTo(x, y); return; }
var perpX = -dy / distance;
var perpY = dx / distance;
var spread = distance * 0.15;
var cp1X = pt.X + dx * 0.3 + perpX * (Rng.NextDouble() - 0.5) * spread;
var cp1Y = pt.Y + dy * 0.3 + perpY * (Rng.NextDouble() - 0.5) * spread;
var cp2X = pt.X + dx * 0.7 + perpX * (Rng.NextDouble() - 0.5) * spread;
var cp2Y = pt.Y + dy * 0.7 + perpY * (Rng.NextDouble() - 0.5) * spread;
var steps = Math.Clamp((int)Math.Round(distance / 15), 10, 40);
for (var i = 1; i <= steps; i++)
{
var t = EaseInOutQuad((double)i / steps);
var (bx, by) = CubicBezier(t, pt.X, pt.Y, cp1X, cp1Y, cp2X, cp2Y, x, y);
MouseMoveTo((int)Math.Round(bx), (int)Math.Round(by));
Thread.Sleep(2 + Rng.Next(3));
}
MouseMoveTo(x, y);
}
private static double EaseInOutQuad(double t) =>
t < 0.5 ? 2 * t * t : 1 - Math.Pow(-2 * t + 2, 2) / 2;
private static (double X, double Y) CubicBezier(double t,
double p0x, double p0y, double p1x, double p1y,
double p2x, double p2y, double p3x, double p3y)
{
var u = 1 - t;
var u2 = u * u;
var u3 = u2 * u;
var t2 = t * t;
var t3 = t2 * t;
return (
u3 * p0x + 3 * u2 * t * p1x + 3 * u * t2 * p2x + t3 * p3x,
u3 * p0y + 3 * u2 * t * p1y + 3 * u * t2 * p2y + t3 * p3y
);
}
public void LeftClick(int x, int y)
{
if (_humanizer is not null)
@ -88,7 +148,7 @@ public sealed class InterceptionInputController : IInputController, IDisposable
Thread.Sleep(_humanizer.GaussianDelay(10));
_humanizer.RecordAction();
}
MouseMoveTo(x, y);
SmoothMoveTo(x, y);
Thread.Sleep(_humanizer is not null ? _humanizer.GaussianDelay(10) : 10);
_mouse?.SimulateLeftButtonClick(_humanizer?.GaussianDelay(50) ?? 50);
}
@ -102,11 +162,25 @@ public sealed class InterceptionInputController : IInputController, IDisposable
Thread.Sleep(_humanizer.GaussianDelay(10));
_humanizer.RecordAction();
}
MouseMoveTo(x, y);
SmoothMoveTo(x, y);
Thread.Sleep(_humanizer is not null ? _humanizer.GaussianDelay(10) : 10);
_mouse?.SimulateRightButtonClick(_humanizer?.GaussianDelay(50) ?? 50);
}
public void MiddleClick(int x, int y)
{
if (_humanizer is not null)
{
if (_humanizer.ShouldThrottle()) return;
(x, y) = _humanizer.JitterPosition(x, y);
Thread.Sleep(_humanizer.GaussianDelay(10));
_humanizer.RecordAction();
}
SmoothMoveTo(x, y);
Thread.Sleep(_humanizer is not null ? _humanizer.GaussianDelay(10) : 10);
_mouse?.SimulateMiddleButtonClick(_humanizer?.GaussianDelay(50) ?? 50);
}
public void LeftDown()
{
_mouse?.SimulateLeftButtonDown();

View file

@ -3,6 +3,7 @@
<TargetFramework>net8.0-windows10.0.19041.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="InputInterceptor" Version="2.2.1" />

View file

@ -291,6 +291,19 @@ public sealed class ComponentReader
return true;
}
/// <summary>
/// Reads the player character name from the Player component.
/// </summary>
public string? ReadPlayerName(nint localPlayerEntity)
{
if (localPlayerEntity == 0) return null;
var playerComp = GetComponentAddress(localPlayerEntity, "Player");
if (playerComp == 0) return null;
return _strings.ReadMsvcWString(playerComp + 0x1B0);
}
/// <summary>
/// Resolves EntityDetails pointer for an entity, handling ECS inner entity wrapper.
/// </summary>

View file

@ -62,6 +62,7 @@ public class Entity
// AreaTransition destination (raw area ID, e.g. "G1_4")
public string? TransitionName { get; internal set; }
public int TransitionState { get; internal set; } = -1;
// Action state (from Actor component)
public short ActionId { get; internal set; }

View file

@ -80,6 +80,17 @@ public sealed class EntityReader
var (compFirst, compCount) = _components.FindComponentList(entityPtr);
// Read Targetable for any entity that has it
if (lookup.TryGetValue("Targetable", out var targetIdx) && targetIdx >= 0 && targetIdx < compCount)
{
var targetComp = mem.ReadPointer(compFirst + targetIdx * 8);
if (targetComp != 0)
{
var targetable = mem.Read<Targetable>(targetComp);
entity.IsTargetable = targetable.IsTargetable != 0;
}
}
// Read HP/Actor/Mods for monsters
if (entity.Components.Contains("Monster"))
{
@ -119,7 +130,7 @@ public sealed class EntityReader
}
}
// Read AreaTransition destination name
// Read AreaTransition destination + Transitionable state
if (entity.Components.Contains("AreaTransition") &&
lookup.TryGetValue("AreaTransition", out var atIdx) && atIdx >= 0 && atIdx < compCount)
{
@ -127,6 +138,17 @@ public sealed class EntityReader
if (atComp != 0)
entity.TransitionName = ReadAreaTransitionName(atComp);
}
if (entity.Components.Contains("Transitionable") &&
lookup.TryGetValue("Transitionable", out var trIdx) && trIdx >= 0 && trIdx < compCount)
{
var trComp = mem.ReadPointer(compFirst + trIdx * 8);
if (trComp != 0)
{
var tr = mem.Read<Transitionable>(trComp);
entity.TransitionState = tr.CurrentStateEnum;
}
}
}
}

View file

@ -40,6 +40,7 @@ public class GameMemoryReader : IDisposable
private MsvcStringReader? _strings;
private RttiResolver? _rtti;
private SkillReader? _skills;
private QuestReader? _quests;
public ObjectRegistry Registry => _registry;
public MemoryDiagnostics? Diagnostics { get; private set; }
@ -99,6 +100,7 @@ public class GameMemoryReader : IDisposable
_entities = new EntityReader(_ctx, _components, _strings);
_terrain = new TerrainReader(_ctx);
_skills = new SkillReader(_ctx, _components, _strings);
_quests = new QuestReader(_ctx, _strings);
Diagnostics = new MemoryDiagnostics(_ctx, _stateReader, _components, _entities, _strings, _rtti);
return true;
@ -116,6 +118,7 @@ public class GameMemoryReader : IDisposable
_strings = null;
_rtti = null;
_skills = null;
_quests = null;
Diagnostics = null;
}
@ -219,7 +222,9 @@ public class GameMemoryReader : IDisposable
_components.InvalidateCaches(snap.LocalPlayerPtr);
_components.ReadPlayerVitals(snap);
_components.ReadPlayerPosition(snap);
snap.CharacterName = _components.ReadPlayerName(snap.LocalPlayerPtr);
snap.PlayerSkills = _skills!.ReadPlayerSkills(snap.LocalPlayerPtr);
snap.QuestFlags = _quests!.ReadQuestFlags(snap.ServerDataPtr);
}
// Read state flag bytes

View file

@ -90,6 +90,24 @@ public sealed class GameOffsets
// ServerData → fields
/// <summary>ServerData → LocalPlayer entity pointer (fallback if LocalPlayerDirectOffset is 0).</summary>
public int LocalPlayerOffset { get; set; } = 0x20;
/// <summary>ServerData → PlayerServerData pointer (PerPlayerServerData struct).</summary>
public int PlayerServerDataOffset { get; set; } = 0x50;
/// <summary>PlayerServerData → QuestFlags container offset (PerPlayerServerDataOffsets: 0x230 = 560).</summary>
public int QuestFlagsOffset { get; set; } = 0x230;
/// <summary>Size of each quest flag entry in bytes. 0 = disabled (offsets not yet discovered via CE).</summary>
public int QuestFlagEntrySize { get; set; } = 0;
/// <summary>Offset within each quest entry to the Quest.dat row pointer.</summary>
public int QuestEntryQuestPtrOffset { get; set; } = 0;
/// <summary>Offset within each quest entry to the byte state ID.</summary>
public int QuestEntryStateIdOffset { get; set; } = 0;
/// <summary>Offset within each quest entry to the wchar* state text pointer.</summary>
public int QuestEntryStateTextOffset { get; set; } = 0;
/// <summary>Offset within each quest entry to the wchar* progress text pointer.</summary>
public int QuestEntryProgressTextOffset { get; set; } = 0;
/// <summary>Container type for quest flags: "vector" or "map".</summary>
public string QuestFlagsContainerType { get; set; } = "vector";
/// <summary>Maximum number of quest entries to read (sanity limit).</summary>
public int QuestFlagsMaxEntries { get; set; } = 128;
// ── Entity / Component ──
public int ComponentListOffset { get; set; } = 0x10;

View file

@ -29,6 +29,9 @@ public class GameStateSnapshot
public int AreaLevel;
public uint AreaHash;
// Player
public string? CharacterName;
// Player position (Render component)
public bool HasPosition;
public float PlayerX, PlayerY, PlayerZ;
@ -63,6 +66,9 @@ public class GameStateSnapshot
// Player skills (from Actor component)
public List<SkillSnapshot>? PlayerSkills;
// Quest flags (from ServerData → PlayerServerData)
public List<QuestSnapshot>? QuestFlags;
// Camera
public Matrix4x4? CameraMatrix;

View file

@ -3834,4 +3834,288 @@ public sealed class MemoryDiagnostics
result.AppendLine("Click again to capture a new baseline.");
return result.ToString();
}
/// <summary>
/// CE discovery diagnostic for quest flags. Follows the pointer chain:
/// AreaInstance → ServerData → PlayerServerData (StdVector) → QuestFlags.
/// When ServerData is null, dumps hex around the configured offset to help
/// discover the correct one. PlayerServerDataPtr is a StdVector of pointers,
/// so an extra dereference is needed.
/// </summary>
public string ScanQuestFlags()
{
if (_ctx.Memory is null) return "Error: not attached";
if (_ctx.GameStateBase == 0) return "Error: GameState base not resolved";
var snap = new GameStateSnapshot();
var inGameState = _stateReader.ResolveInGameState(snap);
if (inGameState == 0) return "Error: InGameState not resolved";
var ingameData = _ctx.Memory.ReadPointer(inGameState + _ctx.Offsets.IngameDataFromStateOffset);
if (ingameData == 0) return "Error: AreaInstance not resolved";
var mem = _ctx.Memory;
var offsets = _ctx.Offsets;
var sb = new StringBuilder();
sb.AppendLine($"AreaInstance: 0x{ingameData:X}");
// Verify AreaInstance is valid by checking LocalPlayer
var localPlayer = mem.ReadPointer(ingameData + offsets.LocalPlayerDirectOffset);
sb.AppendLine($"LocalPlayer (+0x{offsets.LocalPlayerDirectOffset:X}): 0x{localPlayer:X} {(localPlayer != 0 ? "OK" : "NULL")}");
// ServerData pointer
var serverData = mem.ReadPointer(ingameData + offsets.ServerDataOffset);
sb.AppendLine($"ServerData (+0x{offsets.ServerDataOffset:X}): 0x{serverData:X}");
if (serverData == 0)
{
sb.AppendLine();
sb.AppendLine("ServerData is null at configured offset.");
if (localPlayer != 0)
sb.AppendLine("LocalPlayer IS valid — offset 0x9F0 may have shifted.");
// Scan AreaInstance around the expected region for heap pointers
sb.AppendLine();
sb.AppendLine("Scanning AreaInstance 0x980..0xA30 for heap pointers:");
sb.AppendLine(new string('─', 80));
const int scanStart = 0x980;
const int scanLen = 0xB0;
var scanData = mem.ReadBytes(ingameData + scanStart, scanLen);
if (scanData is not null)
{
for (var off = 0; off + 8 <= scanData.Length; off += 8)
{
var val = (nint)BitConverter.ToInt64(scanData, off);
var absOff = scanStart + off;
var hexBytes = BitConverter.ToString(scanData, off, 8).Replace("-", " ");
var annotation = "";
if (val != 0)
{
var high = (ulong)val >> 32;
if (high is > 0 and < 0x7FFF && (val & 0x3) == 0)
{
annotation = " [ptr]";
// Check if target has a vtable
var targetVtable = mem.ReadPointer(val);
if (targetVtable != 0 && _ctx.IsModuleAddress(targetVtable))
{
var rtti = _rtti.ResolveRttiName(targetVtable);
annotation = rtti is not null ? $" → vtable: {rtti}" : " → vtable (no RTTI)";
}
else if (targetVtable != 0)
{
// Check if target+0x50 looks like a StdVector (ServerData candidate)
var t50_0 = mem.ReadPointer(val + 0x50);
var t50_1 = mem.ReadPointer(val + 0x58);
if (t50_0 != 0 && t50_1 > t50_0)
annotation += $" → has StdVector at +0x50 (begin=0x{t50_0:X}, size={(int)(t50_1-t50_0)})";
}
}
else if (val == localPlayer)
{
annotation = " = LocalPlayer";
}
}
var marker = absOff == offsets.ServerDataOffset ? " ◄ configured" : "";
sb.AppendLine($" +0x{absOff:X3}: {hexBytes} (0x{val:X16}){annotation}{marker}");
}
}
sb.AppendLine();
sb.AppendLine("Look for a heap pointer with a StdVector at +0x50 — that's likely ServerData.");
sb.AppendLine("Update ServerDataOffset in offsets.json and re-run.");
return sb.ToString();
}
sb.AppendLine(new string('═', 80));
// PlayerServerData — ExileCore: ServerData+0x50 is StdVector (begin/end/cap)
// The vector contains pointers to PerPlayerServerData structs
var psdVecBegin = mem.ReadPointer(serverData + offsets.PlayerServerDataOffset);
var psdVecEnd = mem.ReadPointer(serverData + offsets.PlayerServerDataOffset + 8);
sb.AppendLine($"PlayerServerData StdVector (+0x{offsets.PlayerServerDataOffset:X}):");
sb.AppendLine($" begin: 0x{psdVecBegin:X}");
sb.AppendLine($" end: 0x{psdVecEnd:X}");
if (psdVecBegin == 0)
{
sb.AppendLine("Error: PlayerServerData vector begin is null");
// Dump hex around ServerData+0x50 for discovery
sb.AppendLine();
sb.AppendLine("Hex dump at ServerData+0x40..0x70:");
DumpHexRegion(sb, mem, serverData + 0x40, 0x30, 0x40);
return sb.ToString();
}
// Dereference: vector entry is a pointer to PerPlayerServerData
var playerServerData = mem.ReadPointer(psdVecBegin);
var vecEntries = psdVecEnd > psdVecBegin ? (int)(psdVecEnd - psdVecBegin) / 8 : 0;
sb.AppendLine($" entries: {vecEntries} (vector of pointers, 8 bytes each)");
sb.AppendLine($" [0] → PlayerServerData: 0x{playerServerData:X}");
if (playerServerData == 0)
{
sb.AppendLine("Error: PlayerServerData[0] pointer is null");
return sb.ToString();
}
// QuestFlags region at PlayerServerData + 0x230
var questFlagsAddr = playerServerData + offsets.QuestFlagsOffset;
sb.AppendLine($"QuestFlags addr (+0x{offsets.QuestFlagsOffset:X}): 0x{questFlagsAddr:X}");
sb.AppendLine(new string('═', 80));
// Dump 256 bytes at QuestFlags address
const int dumpSize = 256;
var regionData = mem.ReadBytes(questFlagsAddr, dumpSize);
if (regionData is null)
{
sb.AppendLine("Error: failed to read QuestFlags region");
return sb.ToString();
}
sb.AppendLine($"\nHex dump at QuestFlags (0x{questFlagsAddr:X}), {dumpSize} bytes:");
sb.AppendLine(new string('─', 80));
// Check for StdVector pattern (three ascending pointers at +0x00/+0x08/+0x10)
nint vecBegin = 0, vecEnd = 0, vecCap = 0;
bool isVector = false;
if (regionData.Length >= 24)
{
vecBegin = (nint)BitConverter.ToInt64(regionData, 0);
vecEnd = (nint)BitConverter.ToInt64(regionData, 8);
vecCap = (nint)BitConverter.ToInt64(regionData, 16);
if (vecBegin != 0 && vecEnd > vecBegin && vecCap >= vecEnd)
{
var high1 = (ulong)vecBegin >> 32;
var high2 = (ulong)vecEnd >> 32;
if (high1 is > 0 and < 0x7FFF && high2 is > 0 and < 0x7FFF)
isVector = true;
}
}
// Annotate each qword
for (var off = 0; off + 8 <= regionData.Length; off += 8)
{
var val = (nint)BitConverter.ToInt64(regionData, off);
var hexBytes = BitConverter.ToString(regionData, off, 8).Replace("-", " ");
var annotation = "";
if (off == 0 && isVector) annotation = " ← vector.begin";
else if (off == 8 && isVector) annotation = " ← vector.end";
else if (off == 16 && isVector) annotation = " ← vector.capacity";
else if (val != 0)
{
var high = (ulong)val >> 32;
if (high is > 0 and < 0x7FFF && (val & 0x3) == 0)
{
annotation = " [heap ptr]";
var targetVal = mem.ReadPointer(val);
if (targetVal != 0 && _ctx.IsModuleAddress(targetVal))
annotation += " → vtable";
else
{
var str = _strings.ReadNullTermWString(val);
if (str is not null)
annotation += $" → \"{str}\"";
}
}
}
sb.AppendLine($" +0x{off:X3}: {hexBytes} (0x{val:X16}){annotation}");
}
// If vector pattern found, dump vector content
if (isVector)
{
var vecSize = (int)(vecEnd - vecBegin);
sb.AppendLine();
sb.AppendLine(new string('═', 80));
sb.AppendLine($"StdVector detected: begin=0x{vecBegin:X} end=0x{vecEnd:X} size={vecSize} bytes");
// Try common entry sizes
foreach (var trySize in new[] { 8, 16, 24, 32, 40, 48, 56, 64 })
{
if (vecSize % trySize == 0)
sb.AppendLine($" Divides evenly by {trySize}: {vecSize / trySize} entries");
}
// Dump first 1024 bytes of vector content
var contentSize = Math.Min(vecSize, 1024);
var content = mem.ReadBytes(vecBegin, contentSize);
if (content is not null)
{
sb.AppendLine();
sb.AppendLine($"Vector content (first {contentSize} bytes):");
sb.AppendLine(new string('─', 80));
for (var off = 0; off + 8 <= content.Length; off += 8)
{
var val = (nint)BitConverter.ToInt64(content, off);
var hexBytes = BitConverter.ToString(content, off, 8).Replace("-", " ");
var annotation = "";
if (val != 0)
{
var high = (ulong)val >> 32;
if (high is > 0 and < 0x7FFF && (val & 0x3) == 0)
{
annotation = " [ptr]";
var str = _strings.ReadNullTermWString(val);
if (str is not null)
annotation = $" → \"{str}\"";
else
{
var namePtr = mem.ReadPointer(val);
if (namePtr != 0)
{
var name = _strings.ReadNullTermWString(namePtr);
if (name is not null)
annotation = $" → dat? → \"{name}\"";
}
}
}
else if (val > 0 && val < 256)
{
annotation = $" [byte-range: {(byte)val}]";
}
}
sb.AppendLine($" vec+0x{off:X3}: {hexBytes} (0x{val:X16}){annotation}");
}
}
}
else
{
sb.AppendLine();
sb.AppendLine("No StdVector pattern detected at +0x00. Container may be a map or different layout.");
sb.AppendLine("Try adjusting QuestFlagsOffset in offsets.json and re-running.");
}
sb.AppendLine();
sb.AppendLine(new string('═', 80));
sb.AppendLine("Next steps:");
sb.AppendLine("1. Accept a quest in-game, re-run this scan, compare vector size → derive entry size");
sb.AppendLine("2. Look for heap pointers (Quest.dat row), byte values (state ID), wchar* (text)");
sb.AppendLine("3. Update QuestFlagEntrySize and other quest offsets in offsets.json");
return sb.ToString();
}
private void DumpHexRegion(StringBuilder sb, ProcessMemory mem, nint addr, int size, int baseOffset)
{
var data = mem.ReadBytes(addr, size);
if (data is null) { sb.AppendLine(" (read failed)"); return; }
for (var off = 0; off + 8 <= data.Length; off += 8)
{
var val = (nint)BitConverter.ToInt64(data, off);
var hexBytes = BitConverter.ToString(data, off, 8).Replace("-", " ");
sb.AppendLine($" +0x{baseOffset + off:X3}: {hexBytes} (0x{val:X16})");
}
}
}

View file

@ -0,0 +1,183 @@
using Serilog;
namespace Roboto.Memory;
/// <summary>
/// Lightweight quest data from ServerData quest flags.
/// Stored in GameStateSnapshot; mapped to Roboto.Core.QuestProgress in the Data layer.
/// </summary>
public sealed class QuestSnapshot
{
public nint QuestDatPtr { get; init; }
public string? QuestName { get; init; }
public byte StateId { get; init; }
public string? StateText { get; init; }
public string? ProgressText { get; init; }
}
/// <summary>
/// Reads quest flags from ServerData → PlayerServerData → QuestFlags vector.
/// Follows the same pattern as SkillReader: bulk-reads vector data, resolves
/// quest names by following dat row pointers, caches results.
/// When QuestFlagEntrySize == 0 (offsets not yet discovered), gracefully returns null.
/// </summary>
public sealed class QuestReader
{
private readonly MemoryContext _ctx;
private readonly MsvcStringReader _strings;
// Name cache — quest names are static, only refresh on ServerData change
private readonly Dictionary<nint, string?> _nameCache = new();
private nint _lastServerData;
public QuestReader(MemoryContext ctx, MsvcStringReader strings)
{
_ctx = ctx;
_strings = strings;
}
/// <summary>
/// Reads quest flags from the ServerData pointer chain.
/// Returns null if offsets are not configured (EntrySize == 0) or data is unavailable.
/// </summary>
public List<QuestSnapshot>? ReadQuestFlags(nint serverDataPtr)
{
if (serverDataPtr == 0) return null;
var offsets = _ctx.Offsets;
// Guard: entry size 0 means offsets not yet discovered via CE
if (offsets.QuestFlagEntrySize <= 0) return null;
var mem = _ctx.Memory;
// ServerData+0x50 is a StdVector of pointers to PerPlayerServerData structs.
// Read vector begin, then dereference to get the first entry.
var psdVecBegin = mem.ReadPointer(serverDataPtr + offsets.PlayerServerDataOffset);
if (psdVecBegin == 0) return null;
// Dereference: vector[0] is a pointer to the actual PerPlayerServerData struct
var playerServerData = mem.ReadPointer(psdVecBegin);
if (playerServerData == 0) return null;
// Invalidate name cache on ServerData change (area transition)
if (playerServerData != _lastServerData)
{
_nameCache.Clear();
_lastServerData = playerServerData;
}
// PerPlayerServerData → QuestFlags (+0x230)
var questFlagsAddr = playerServerData + offsets.QuestFlagsOffset;
if (offsets.QuestFlagsContainerType == "vector")
return ReadVectorQuests(questFlagsAddr, offsets);
// Future: "map" container type
return null;
}
private List<QuestSnapshot>? ReadVectorQuests(nint questFlagsAddr, GameOffsets offsets)
{
var mem = _ctx.Memory;
// StdVector: begin, end, capacity (3 pointers)
var vecBegin = mem.ReadPointer(questFlagsAddr);
var vecEnd = mem.ReadPointer(questFlagsAddr + 8);
if (vecBegin == 0 || vecEnd <= vecBegin) return null;
var totalBytes = (int)(vecEnd - vecBegin);
var entrySize = offsets.QuestFlagEntrySize;
var entryCount = totalBytes / entrySize;
if (entryCount <= 0 || entryCount > offsets.QuestFlagsMaxEntries) return null;
// Bulk read all entries
var vecData = mem.ReadBytes(vecBegin, totalBytes);
if (vecData is null) return null;
var result = new List<QuestSnapshot>(entryCount);
for (var i = 0; i < entryCount; i++)
{
var entryOffset = i * entrySize;
// Read quest dat pointer
nint questDatPtr = 0;
if (entryOffset + offsets.QuestEntryQuestPtrOffset + 8 <= vecData.Length)
questDatPtr = (nint)BitConverter.ToInt64(vecData, entryOffset + offsets.QuestEntryQuestPtrOffset);
// Read state ID byte
byte stateId = 0;
if (entryOffset + offsets.QuestEntryStateIdOffset < vecData.Length)
stateId = vecData[entryOffset + offsets.QuestEntryStateIdOffset];
// Resolve quest name from dat pointer (cached)
var questName = ResolveQuestName(questDatPtr);
// Read state text pointer and resolve
string? stateText = null;
if (offsets.QuestEntryStateTextOffset > 0 &&
entryOffset + offsets.QuestEntryStateTextOffset + 8 <= vecData.Length)
{
var stateTextPtr = (nint)BitConverter.ToInt64(vecData, entryOffset + offsets.QuestEntryStateTextOffset);
if (stateTextPtr != 0 && ((ulong)stateTextPtr >> 32) is > 0 and < 0x7FFF)
stateText = _strings.ReadNullTermWString(stateTextPtr);
}
// Read progress text pointer and resolve
string? progressText = null;
if (offsets.QuestEntryProgressTextOffset > 0 &&
entryOffset + offsets.QuestEntryProgressTextOffset + 8 <= vecData.Length)
{
var progressTextPtr = (nint)BitConverter.ToInt64(vecData, entryOffset + offsets.QuestEntryProgressTextOffset);
if (progressTextPtr != 0 && ((ulong)progressTextPtr >> 32) is > 0 and < 0x7FFF)
progressText = _strings.ReadNullTermWString(progressTextPtr);
}
result.Add(new QuestSnapshot
{
QuestDatPtr = questDatPtr,
QuestName = questName,
StateId = stateId,
StateText = stateText,
ProgressText = progressText,
});
}
return result;
}
/// <summary>
/// Resolves quest name by following QuestDatPtr → dat row → wchar* name.
/// Results are cached since quest names don't change.
/// </summary>
private string? ResolveQuestName(nint questDatPtr)
{
if (questDatPtr == 0) return null;
if (_nameCache.TryGetValue(questDatPtr, out var cached))
return cached;
var mem = _ctx.Memory;
string? name = null;
// Follow the dat row pointer — first field is typically a wchar* name
var high = (ulong)questDatPtr >> 32;
if (high is > 0 and < 0x7FFF)
{
var namePtr = mem.ReadPointer(questDatPtr);
if (namePtr != 0)
name = _strings.ReadNullTermWString(namePtr);
}
_nameCache[questDatPtr] = name;
return name;
}
/// <summary>Clears cached names (call on area change).</summary>
public void InvalidateCache()
{
_nameCache.Clear();
_lastServerData = 0;
}
}

View file

@ -23,6 +23,12 @@ public sealed class NavigationController
private long _pathTimestampMs;
private Vector2? _goalPosition;
private uint _targetEntityId;
private Vector2? _exploreBiasPoint;
// Explored grid — tracks which terrain cells the player has visited
private bool[]? _exploredGrid;
private int _exploredWidth, _exploredHeight;
private const int ExploreMarkRadius = 92; // grid cells (~1000 world units)
// Stuck detection: rolling window of recent positions
private readonly Queue<Vector2> _positionHistory = new();
@ -33,6 +39,15 @@ public sealed class NavigationController
public Vector2? DesiredDirection { get; private set; }
public IReadOnlyList<Vector2>? CurrentPath => _path;
public string Status { get; private set; } = "Idle";
public bool[]? ExploredGrid => _exploredGrid;
public int ExploredWidth => _exploredWidth;
public int ExploredHeight => _exploredHeight;
/// <summary>
/// True when BFS exploration finds no more unexplored walkable cells in the current area.
/// Reset on area change.
/// </summary>
public bool IsExplorationComplete { get; private set; }
public NavigationController(BotConfig config)
{
@ -67,11 +82,19 @@ public sealed class NavigationController
_targetEntityId = 0;
_path = null;
_waypointIndex = 0;
_exploreBiasPoint = null;
Mode = NavMode.Exploring;
Status = "Exploring";
Log.Debug("NavigationController: exploring");
}
public void ExploreToward(Vector2 biasPoint)
{
if (Mode != NavMode.Exploring)
Explore();
_exploreBiasPoint = biasPoint;
}
public void Stop()
{
_goalPosition = null;
@ -95,13 +118,50 @@ public sealed class NavigationController
var playerPos = state.Player.Position;
var now = state.TimestampMs;
// Area change → clear path
// Area change → clear path, bias, and explored grid
if (state.AreaHash != _lastAreaHash)
{
_lastAreaHash = state.AreaHash;
_path = null;
_waypointIndex = 0;
_positionHistory.Clear();
_exploreBiasPoint = null;
_exploredGrid = null;
IsExplorationComplete = false;
}
// Allocate explored grid on first tick with terrain, after area change,
// or when terrain dimensions change (prevents bounds mismatch crash)
var terrain = state.Terrain;
if (terrain is not null &&
(_exploredGrid is null || terrain.Width != _exploredWidth || terrain.Height != _exploredHeight))
{
_exploredWidth = terrain.Width;
_exploredHeight = terrain.Height;
_exploredGrid = new bool[_exploredWidth * _exploredHeight];
}
// Mark cells near player as explored
if (_exploredGrid is not null && terrain is not null)
{
var pgx = (int)(playerPos.X * _config.WorldToGrid);
var pgy = (int)(playerPos.Y * _config.WorldToGrid);
var r = ExploreMarkRadius;
var r2 = r * r;
var minX = Math.Max(0, pgx - r);
var maxX = Math.Min(_exploredWidth - 1, pgx + r);
var minY = Math.Max(0, pgy - r);
var maxY = Math.Min(_exploredHeight - 1, pgy + r);
for (var y = minY; y <= maxY; y++)
{
var dy = y - pgy;
for (var x = minX; x <= maxX; x++)
{
var dx = x - pgx;
if (dx * dx + dy * dy <= r2)
_exploredGrid[y * _exploredWidth + x] = true;
}
}
}
// Resolve goal based on mode
@ -188,7 +248,10 @@ public sealed class NavigationController
state.Terrain?.Width ?? 0, state.Terrain?.Height ?? 0);
}
_path = PathFinder.FindPath(state.Terrain, playerPos, goal.Value, _config.WorldToGrid);
_path = Mode == NavMode.Exploring
? PathFinder.FindPath(state.Terrain, playerPos, goal.Value, _config.WorldToGrid,
_exploredGrid, _exploredWidth, _exploredHeight)
: PathFinder.FindPath(state.Terrain, playerPos, goal.Value, _config.WorldToGrid);
_waypointIndex = 0;
_pathTimestampMs = now;
@ -274,30 +337,61 @@ public sealed class NavigationController
private Vector2? PickExploreTarget(GameState state)
{
if (state.Terrain is null) return null;
if (state.Terrain is null || _exploredGrid is null) return null;
var terrain = state.Terrain;
// Bail if terrain dimensions don't match the allocated grid (area transition in progress)
if (terrain.Width != _exploredWidth || terrain.Height != _exploredHeight) return null;
var gridToWorld = 1f / _config.WorldToGrid;
var playerPos = state.Player.Position;
var w = terrain.Width;
var h = terrain.Height;
// Try random walkable points
for (var attempt = 0; attempt < 200; attempt++)
var startGx = Math.Clamp((int)(playerPos.X * _config.WorldToGrid), 0, w - 1);
var startGy = Math.Clamp((int)(playerPos.Y * _config.WorldToGrid), 0, h - 1);
// BFS outward from player to find nearest unexplored walkable cell
var visited = new bool[w * h];
var queue = new Queue<(int x, int y)>();
queue.Enqueue((startGx, startGy));
visited[startGy * w + startGx] = true;
var iterations = 0;
const int maxIterations = 100_000;
while (queue.Count > 0 && iterations++ < maxIterations)
{
var gx = _rng.Next(terrain.Width);
var gy = _rng.Next(terrain.Height);
var (cx, cy) = queue.Dequeue();
if (!terrain.IsWalkable(gx, gy)) continue;
var worldPos = new Vector2(gx * gridToWorld, gy * gridToWorld);
var dist = Vector2.Distance(state.Player.Position, worldPos);
// Pick points that are a reasonable distance away (not too close, not too far)
if (dist > 300f && dist < 8000f)
// Found an unexplored walkable cell
if (terrain.IsWalkable(cx, cy) && !_exploredGrid[cy * w + cx])
{
var worldPos = new Vector2(cx * gridToWorld, cy * gridToWorld);
_goalPosition = worldPos;
Log.Debug("BFS frontier: target ({Gx},{Gy}) after {Iter} iterations", cx, cy, iterations);
return worldPos;
}
// Expand 8-connected neighbors
for (var d = 0; d < 8; d++)
{
var nx = cx + _bfsDx[d];
var ny = cy + _bfsDy[d];
if (nx < 0 || nx >= w || ny < 0 || ny >= h) continue;
var idx = ny * w + nx;
if (visited[idx]) continue;
if (!terrain.IsWalkable(nx, ny)) continue;
visited[idx] = true;
queue.Enqueue((nx, ny));
}
}
Log.Information("BFS frontier: no unexplored cells found — exploration complete");
IsExplorationComplete = true;
return null;
}
private static readonly int[] _bfsDx = [-1, 0, 1, 0, -1, -1, 1, 1];
private static readonly int[] _bfsDy = [0, -1, 0, 1, -1, 1, -1, 1];
}

View file

@ -11,8 +11,11 @@ public static class PathFinder
/// <summary>
/// A* pathfinding on WalkabilitySnapshot. Returns world-coord waypoints or null if no path.
/// When exploredGrid is provided, explored cells cost 3x more — biasing paths through unexplored territory.
/// </summary>
public static List<Vector2>? FindPath(WalkabilitySnapshot terrain, Vector2 start, Vector2 goal, float worldToGrid)
public static List<Vector2>? FindPath(
WalkabilitySnapshot terrain, Vector2 start, Vector2 goal, float worldToGrid,
bool[]? exploredGrid = null, int exploredWidth = 0, int exploredHeight = 0)
{
var w = terrain.Width;
var h = terrain.Height;
@ -69,7 +72,10 @@ public static class PathFinder
}
var neighbor = (nx, ny);
var tentativeG = currentG + Cost[i];
var stepCost = Cost[i];
if (exploredGrid is not null && nx < exploredWidth && ny < exploredHeight && exploredGrid[ny * exploredWidth + nx])
stepCost *= 3f;
var tentativeG = currentG + stepCost;
if (tentativeG < gScore.GetValueOrDefault(neighbor, float.MaxValue))
{

View file

@ -1,4 +1,6 @@
using System.Numerics;
using Roboto.Core;
using Serilog;
namespace Roboto.Systems;
@ -6,10 +8,306 @@ public class CombatSystem : ISystem
{
public int Priority => SystemPriority.Combat;
public string Name => "Combat";
public bool IsEnabled { get; set; } = false;
public bool IsEnabled { get; set; } = true;
private List<SkillProfile> _skills;
private int _globalCooldownMs;
private readonly Dictionary<int, long> _cooldowns = new();
private long _lastCastGlobal;
// Aura tracking — reset on area change
private readonly HashSet<int> _aurasCast = new();
private uint _lastAreaHash;
// MaintainPressed tracking — which slots are currently held down
private readonly HashSet<int> _heldSlots = new();
public CombatSystem(BotConfig config)
{
var defaultProfile = new CharacterProfile();
_skills = defaultProfile.Skills
.Where(s => s.IsEnabled)
.OrderBy(s => s.Priority)
.ToList();
_globalCooldownMs = defaultProfile.Combat.GlobalCooldownMs;
}
/// <summary>
/// Hot-swap skill list and combat settings from a character profile.
/// </summary>
public void ApplyProfile(CharacterProfile profile)
{
_skills = profile.Skills
.Where(s => s.IsEnabled)
.OrderBy(s => s.Priority)
.ToList();
_globalCooldownMs = profile.Combat.GlobalCooldownMs;
_cooldowns.Clear();
_aurasCast.Clear();
_heldSlots.Clear();
_lastCastGlobal = 0;
}
/// <summary>
/// Releases all currently held keys (emergency stop / loading screen).
/// </summary>
public void ReleaseAllHeld(IInputController input)
{
foreach (var slotIndex in _heldSlots)
{
var skill = _skills.FirstOrDefault(s => s.SlotIndex == slotIndex);
if (skill is not null && skill.InputType == SkillInputType.KeyPress)
input.KeyUp(skill.ScanCode);
}
_heldSlots.Clear();
}
public void Update(GameState state, ActionQueue actions)
{
// STUB: skill usage and attack logic
if (state.CameraMatrix is not { } camera)
return;
var now = Environment.TickCount64;
var playerZ = state.Player.Z;
// Reset aura tracking on area change
if (state.AreaHash != _lastAreaHash)
{
_aurasCast.Clear();
_lastAreaHash = state.AreaHash;
}
// Global cooldown: don't cast if we recently cast any skill
if (now - _lastCastGlobal < _globalCooldownMs)
{
// Still need to handle MaintainPressed releases
UpdateHeldKeys(state, camera, playerZ, actions);
return;
}
// Per-skill targeting: iterate skills in priority order, find best target for each
foreach (var skill in _skills)
{
// Per-slot cooldown check
if (_cooldowns.TryGetValue(skill.SlotIndex, out var lastCast) && now - lastCast < skill.CooldownMs)
continue;
// Check memory skill data if available (match by name, not slot index)
if (skill.SkillName is { Length: > 0 } && state.Player.Skills.Count > 0)
{
var memSkill = FindMemorySkill(state.Player.Skills, skill.SkillName);
if (memSkill is not null && !memSkill.CanUse)
continue;
}
// Aura: self-cast once per zone
if (skill.IsAura)
{
if (_aurasCast.Contains(skill.SlotIndex))
continue;
// Cast aura (no target needed)
SubmitSkillAction(skill, Vector2.Zero, actions);
_cooldowns[skill.SlotIndex] = now;
_lastCastGlobal = now;
_aurasCast.Add(skill.SlotIndex);
Log.Debug("Combat: casting aura slot {Slot} ({Label})", skill.SlotIndex, skill.Label);
break;
}
// Non-aura skills need enemies
if (state.NearestEnemies.Count == 0)
continue;
// Filter enemies by TargetSelection
var candidates = FilterByTargetSelection(state.NearestEnemies, skill.TargetSelection);
// Range filter
candidates = candidates
.Where(e => e.DistanceToPlayer >= skill.RangeMin && e.DistanceToPlayer <= skill.RangeMax)
.ToList();
// MinMonstersInRange check
if (candidates.Count < skill.MinMonstersInRange)
continue;
// Pick best target
var target = PickBestTarget(candidates, skill.TargetSelection);
if (target is null)
continue;
// Project to screen
var screen = WorldToScreen.Project(target.Position, playerZ, camera);
if (screen is null)
continue;
// MaintainPressed: hold key instead of tap
if (skill.MaintainPressed && skill.InputType == SkillInputType.KeyPress)
{
if (!_heldSlots.Contains(skill.SlotIndex))
{
actions.Submit(new KeyAction(SystemPriority.Combat, skill.ScanCode, KeyActionType.Down));
_heldSlots.Add(skill.SlotIndex);
}
_cooldowns[skill.SlotIndex] = now;
_lastCastGlobal = now;
Log.Debug("Combat: holding slot {Slot} ({Label}) at {Target} dist={Dist:F0}",
skill.SlotIndex, skill.Label, target.Id, target.DistanceToPlayer);
break;
}
// Normal cast
SubmitSkillAction(skill, screen.Value, actions);
_cooldowns[skill.SlotIndex] = now;
_lastCastGlobal = now;
Log.Debug("Combat: casting slot {Slot} ({Label}) at {Target} dist={Dist:F0}",
skill.SlotIndex, skill.Label, target.Id, target.DistanceToPlayer);
break;
}
// Release held keys for skills that no longer have valid targets
UpdateHeldKeys(state, camera, playerZ, actions);
}
private void UpdateHeldKeys(GameState state, Matrix4x4 camera, float playerZ, ActionQueue actions)
{
if (_heldSlots.Count == 0) return;
var toRelease = new List<int>();
foreach (var slotIndex in _heldSlots)
{
var skill = _skills.FirstOrDefault(s => s.SlotIndex == slotIndex);
if (skill is null) { toRelease.Add(slotIndex); continue; }
// Check if we still have a valid target
var hasTarget = false;
if (state.NearestEnemies.Count > 0)
{
var candidates = FilterByTargetSelection(state.NearestEnemies, skill.TargetSelection)
.Where(e => e.DistanceToPlayer >= skill.RangeMin && e.DistanceToPlayer <= skill.RangeMax)
.ToList();
if (candidates.Count >= skill.MinMonstersInRange)
{
var target = PickBestTarget(candidates, skill.TargetSelection);
if (target is not null)
{
var screen = WorldToScreen.Project(target.Position, playerZ, camera);
hasTarget = screen is not null;
}
}
}
if (!hasTarget)
toRelease.Add(slotIndex);
}
foreach (var slotIndex in toRelease)
{
var skill = _skills.FirstOrDefault(s => s.SlotIndex == slotIndex);
if (skill is not null && skill.InputType == SkillInputType.KeyPress)
actions.Submit(new KeyAction(SystemPriority.Combat, skill.ScanCode, KeyActionType.Up));
_heldSlots.Remove(slotIndex);
}
}
/// <summary>
/// Matches a profile skill name (e.g. "SpearThrow") to a memory skill (e.g. "SpearThrowPlayer").
/// </summary>
private static SkillState? FindMemorySkill(IReadOnlyList<SkillState> memorySkills, string profileSkillName)
{
foreach (var ms in memorySkills)
{
if (ms.Name is null) continue;
// Memory names have "Player" suffix, profile names don't
var cleanName = ms.Name.EndsWith("Player", StringComparison.Ordinal)
? ms.Name[..^6]
: ms.Name;
if (string.Equals(cleanName, profileSkillName, StringComparison.OrdinalIgnoreCase))
return ms;
}
return null;
}
private static List<EntitySnapshot> FilterByTargetSelection(
IReadOnlyList<EntitySnapshot> enemies, TargetSelection selection)
{
var result = new List<EntitySnapshot>();
foreach (var e in enemies)
{
if (!e.IsAlive || !e.IsTargetable) continue;
switch (selection)
{
case TargetSelection.Nearest:
case TargetSelection.All:
case TargetSelection.Rarest:
result.Add(e);
break;
case TargetSelection.MagicPlus:
if (e.Rarity >= MonsterRarity.Magic) result.Add(e);
break;
case TargetSelection.RarePlus:
if (e.Rarity >= MonsterRarity.Rare) result.Add(e);
break;
case TargetSelection.UniqueOnly:
if (e.Rarity == MonsterRarity.Unique) result.Add(e);
break;
}
}
return result;
}
private static EntitySnapshot? PickBestTarget(List<EntitySnapshot> candidates, TargetSelection selection)
{
if (candidates.Count == 0) return null;
if (selection == TargetSelection.Rarest)
{
// Prefer higher rarity, then nearer
EntitySnapshot? best = null;
foreach (var c in candidates)
{
if (best is null
|| c.Rarity > best.Rarity
|| (c.Rarity == best.Rarity && c.DistanceToPlayer < best.DistanceToPlayer))
{
best = c;
}
}
return best;
}
// Default: nearest (candidates are already from NearestEnemies which is sorted by distance)
EntitySnapshot? nearest = null;
foreach (var c in candidates)
{
if (nearest is null || c.DistanceToPlayer < nearest.DistanceToPlayer)
nearest = c;
}
return nearest;
}
private static void SubmitSkillAction(SkillProfile skill, Vector2 screenPos, ActionQueue actions)
{
switch (skill.InputType)
{
case SkillInputType.LeftClick:
actions.Submit(new ClickAction(SystemPriority.Combat, screenPos, ClickType.Left));
break;
case SkillInputType.RightClick:
actions.Submit(new ClickAction(SystemPriority.Combat, screenPos, ClickType.Right));
break;
case SkillInputType.MiddleClick:
actions.Submit(new ClickAction(SystemPriority.Combat, screenPos, ClickType.Middle));
break;
case SkillInputType.KeyPress:
actions.Submit(new CastAction(SystemPriority.Combat, skill.ScanCode, screenPos));
break;
}
}
}

View file

@ -5,6 +5,7 @@ namespace Roboto.Systems;
public class ResourceSystem : ISystem
{
private readonly BotConfig _config;
private FlaskSettings _flasks;
private long _lastLifeFlaskMs;
private long _lastManaFlaskMs;
@ -15,6 +16,17 @@ public class ResourceSystem : ISystem
public ResourceSystem(BotConfig config)
{
_config = config;
_flasks = new FlaskSettings();
}
/// <summary>
/// Hot-swap flask settings from a character profile.
/// </summary>
public void ApplyProfile(CharacterProfile profile)
{
_flasks = profile.Flasks;
_lastLifeFlaskMs = 0;
_lastManaFlaskMs = 0;
}
public void Update(GameState state, ActionQueue actions)
@ -24,18 +36,18 @@ public class ResourceSystem : ISystem
var now = Environment.TickCount64;
if (player.LifePercent < _config.LifeFlaskThreshold
&& now - _lastLifeFlaskMs >= _config.FlaskCooldownMs)
if (player.LifePercent < _flasks.LifeFlaskThreshold
&& now - _lastLifeFlaskMs >= _flasks.FlaskCooldownMs)
{
actions.Submit(new FlaskAction(Priority, _config.LifeFlaskScanCode));
actions.Submit(new FlaskAction(Priority, _flasks.LifeFlaskScanCode));
_lastLifeFlaskMs = now;
}
if (player.ManaTotal > 0
&& player.ManaPercent < _config.ManaFlaskThreshold
&& now - _lastManaFlaskMs >= _config.FlaskCooldownMs)
&& player.ManaPercent < _flasks.ManaFlaskThreshold
&& now - _lastManaFlaskMs >= _flasks.FlaskCooldownMs)
{
actions.Submit(new FlaskAction(Priority, _config.ManaFlaskScanCode));
actions.Submit(new FlaskAction(Priority, _flasks.ManaFlaskScanCode));
_lastManaFlaskMs = now;
}
}