stuff
This commit is contained in:
parent
a8341e8232
commit
a8c43ba7e2
43 changed files with 2618 additions and 48 deletions
|
|
@ -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"
|
||||
|
|
|
|||
158
profiles/GooGoGaaGa_Default.json
Normal file
158
profiles/GooGoGaaGa_Default.json
Normal 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
|
||||
}
|
||||
]
|
||||
}
|
||||
166
profiles/GooGoGaaGa_Default_Copy.json
Normal file
166
profiles/GooGoGaaGa_Default_Copy.json
Normal 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
|
||||
}
|
||||
]
|
||||
}
|
||||
3
profiles/_assignments.json
Normal file
3
profiles/_assignments.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"GooGoGaaGa": "GooGoGaaGa_Default_Copy"
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 "?";
|
||||
|
|
|
|||
92
src/Automata.Ui/ViewModels/SkillProfileViewModel.cs
Normal file
92
src/Automata.Ui/ViewModels/SkillProfileViewModel.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
24
src/Roboto.Core/CharacterProfile.cs
Normal file
24
src/Roboto.Core/CharacterProfile.cs
Normal 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 },
|
||||
];
|
||||
}
|
||||
11
src/Roboto.Core/CombatSettings.cs
Normal file
11
src/Roboto.Core/CombatSettings.cs
Normal 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;
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
10
src/Roboto.Core/FlaskSettings.cs
Normal file
10
src/Roboto.Core/FlaskSettings.cs
Normal 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
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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; } = [];
|
||||
|
||||
|
|
|
|||
174
src/Roboto.Core/ProfileManager.cs
Normal file
174
src/Roboto.Core/ProfileManager.cs
Normal 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");
|
||||
}
|
||||
9
src/Roboto.Core/QuestProgress.cs
Normal file
9
src/Roboto.Core/QuestProgress.cs
Normal 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; }
|
||||
}
|
||||
25
src/Roboto.Core/SkillProfile.cs
Normal file
25
src/Roboto.Core/SkillProfile.cs
Normal 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; }
|
||||
}
|
||||
11
src/Roboto.Core/TargetSelection.cs
Normal file
11
src/Roboto.Core/TargetSelection.cs
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
namespace Roboto.Core;
|
||||
|
||||
public enum TargetSelection
|
||||
{
|
||||
Nearest,
|
||||
All,
|
||||
Rarest,
|
||||
MagicPlus,
|
||||
RarePlus,
|
||||
UniqueOnly,
|
||||
}
|
||||
33
src/Roboto.Core/WorldToScreen.cs
Normal file
33
src/Roboto.Core/WorldToScreen.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
201
src/Roboto.Data/AreaGraph.cs
Normal file
201
src/Roboto.Data/AreaGraph.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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})");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
183
src/Roboto.Memory/QuestReader.cs
Normal file
183
src/Roboto.Memory/QuestReader.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue