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/LeagueIncursion/EncounterChest",
|
||||||
"Metadata/Chests/MossyChest11",
|
"Metadata/Chests/MossyChest11",
|
||||||
"Metadata/Chests/MossyChest13",
|
"Metadata/Chests/MossyChest13",
|
||||||
|
"Metadata/Chests/MossyChest14",
|
||||||
"Metadata/Chests/MossyChest20",
|
"Metadata/Chests/MossyChest20",
|
||||||
"Metadata/Chests/MossyChest21",
|
"Metadata/Chests/MossyChest21",
|
||||||
"Metadata/Chests/MossyChest26",
|
"Metadata/Chests/MossyChest26",
|
||||||
|
"Metadata/Chests/MuddyChest1",
|
||||||
|
"Metadata/Critters/BloodWorm/BloodWormBrown",
|
||||||
"Metadata/Critters/Chicken/Chicken_kingsmarch",
|
"Metadata/Critters/Chicken/Chicken_kingsmarch",
|
||||||
"Metadata/Critters/Crow/Crow",
|
"Metadata/Critters/Crow/Crow",
|
||||||
"Metadata/Critters/Ferret/Ferret",
|
"Metadata/Critters/Ferret/Ferret",
|
||||||
|
|
@ -64,6 +67,8 @@
|
||||||
"Metadata/Monsters/Hags/Objects/BossRoomMinimapIcon",
|
"Metadata/Monsters/Hags/Objects/BossRoomMinimapIcon",
|
||||||
"Metadata/Monsters/Hags/UrchinHag1",
|
"Metadata/Monsters/Hags/UrchinHag1",
|
||||||
"Metadata/Monsters/Hags/UrchinHagBoss",
|
"Metadata/Monsters/Hags/UrchinHagBoss",
|
||||||
|
"Metadata/Monsters/HuhuGrub/HuhuGrubLarvaeEmerge1",
|
||||||
|
"Metadata/Monsters/HuhuGrub/HuhuGrubLarvaeRanged1",
|
||||||
"Metadata/Monsters/InvisibleFire/MDCarrionCroneWave",
|
"Metadata/Monsters/InvisibleFire/MDCarrionCroneWave",
|
||||||
"Metadata/Monsters/MonsterMods/OnDeathColdExplosionParent",
|
"Metadata/Monsters/MonsterMods/OnDeathColdExplosionParent",
|
||||||
"Metadata/Monsters/Urchins/MeleeUrchin1",
|
"Metadata/Monsters/Urchins/MeleeUrchin1",
|
||||||
|
|
@ -148,6 +153,7 @@
|
||||||
"Metadata/Terrain/Tools/AudioTools/G1_2/ForestEntrance",
|
"Metadata/Terrain/Tools/AudioTools/G1_2/ForestEntrance",
|
||||||
"Metadata/Terrain/Tools/AudioTools/G1_2/HagArena",
|
"Metadata/Terrain/Tools/AudioTools/G1_2/HagArena",
|
||||||
"Metadata/Terrain/Tools/AudioTools/G1_2/RiverRapidsMedium",
|
"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_4/WitchHutIndoorAudio",
|
||||||
"Metadata/Terrain/Tools/AudioTools/G1_Town/FurnaceFireAudio",
|
"Metadata/Terrain/Tools/AudioTools/G1_Town/FurnaceFireAudio",
|
||||||
"Metadata/Terrain/Tools/AudioTools/G1_Town/InsideWaterMillAudio"
|
"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
|
// Explored radius: pixels around player position to mark as explored on world map
|
||||||
public int ExploredRadius { get; set; } = 75;
|
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)
|
// Temporal smoothing: majority vote over ring buffer (walls only)
|
||||||
public int TemporalFrameCount { get; set; } = 5;
|
public int TemporalFrameCount { get; set; } = 5;
|
||||||
public int WallTemporalThreshold { get; set; } = 3;
|
public int WallTemporalThreshold { get; set; } = 3;
|
||||||
|
|
|
||||||
|
|
@ -483,13 +483,21 @@ public class WorldMap : IDisposable
|
||||||
var y0 = Math.Clamp(cy - half, 0, _canvasSize - viewSize);
|
var y0 = Math.Clamp(cy - half, 0, _canvasSize - viewSize);
|
||||||
var roi = new Mat(_canvas, new Rect(x0, y0, viewSize, 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));
|
using var colored = new Mat(viewSize, viewSize, MatType.CV_8UC3, new Scalar(23, 17, 13));
|
||||||
for (var r = 0; r < viewSize; r++)
|
for (var r = 0; r < viewSize; r++)
|
||||||
for (var c = 0; c < viewSize; c++)
|
for (var c = 0; c < viewSize; c++)
|
||||||
{
|
{
|
||||||
var v = roi.At<byte>(r, c);
|
var v = roi.At<byte>(r, c);
|
||||||
if (v == (byte)MapCell.Explored)
|
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)
|
else if (v == (byte)MapCell.Wall)
|
||||||
colored.Set(r, c, new Vec3b(26, 45, 61));
|
colored.Set(r, c, new Vec3b(26, 45, 61));
|
||||||
else if (v == (byte)MapCell.Fog)
|
else if (v == (byte)MapCell.Fog)
|
||||||
|
|
|
||||||
|
|
@ -859,6 +859,13 @@ public partial class MemoryViewModel : ObservableObject
|
||||||
if (e.HasPosition)
|
if (e.HasPosition)
|
||||||
parts.Add($"({e.X:F0},{e.Y:F0})");
|
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)
|
if (e.HasVitals)
|
||||||
parts.Add($"HP:{e.LifeCurrent}/{e.LifeTotal}");
|
parts.Add($"HP:{e.LifeCurrent}/{e.LifeTotal}");
|
||||||
|
|
||||||
|
|
@ -882,7 +889,11 @@ public partial class MemoryViewModel : ObservableObject
|
||||||
needed.Add(("Path:", e.Path, true));
|
needed.Add(("Path:", e.Path, true));
|
||||||
|
|
||||||
if (e.TransitionName is not null)
|
if (e.TransitionName is not null)
|
||||||
|
{
|
||||||
needed.Add(("Destination:", e.TransitionName, true));
|
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)
|
if (e.HasPosition)
|
||||||
needed.Add(("Pos:", $"({e.X:F1}, {e.Y:F1}, {e.Z:F1})", true));
|
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();
|
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.Collections.ObjectModel;
|
||||||
using System.Numerics;
|
using System.Numerics;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using Avalonia;
|
||||||
|
using Avalonia.Media.Imaging;
|
||||||
using Roboto.Memory;
|
using Roboto.Memory;
|
||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
using CommunityToolkit.Mvvm.Input;
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
using Automata.GameLog;
|
||||||
using Roboto.Core;
|
using Roboto.Core;
|
||||||
using Roboto.Data;
|
using Roboto.Data;
|
||||||
using Roboto.Engine;
|
using Roboto.Engine;
|
||||||
|
|
@ -62,6 +66,11 @@ public partial class EntityListItem : ObservableObject
|
||||||
|
|
||||||
public partial class RobotoViewModel : ObservableObject, IDisposable
|
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 readonly BotEngine _engine;
|
||||||
private long _lastUiUpdate;
|
private long _lastUiUpdate;
|
||||||
private bool _disposed;
|
private bool _disposed;
|
||||||
|
|
@ -84,15 +93,48 @@ public partial class RobotoViewModel : ObservableObject, IDisposable
|
||||||
|
|
||||||
// Systems
|
// Systems
|
||||||
[ObservableProperty] private string _systemsInfo = "—";
|
[ObservableProperty] private string _systemsInfo = "—";
|
||||||
|
[ObservableProperty] private string _apmInfo = "0";
|
||||||
|
|
||||||
// Navigation
|
// Navigation
|
||||||
[ObservableProperty] private string _navMode = "Idle";
|
[ObservableProperty] private string _navMode = "Idle";
|
||||||
[ObservableProperty] private string _navStatus = "—";
|
[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
|
// Entity list for checkbox UI
|
||||||
[ObservableProperty] private bool _showAllEntities;
|
[ObservableProperty] private bool _showAllEntities;
|
||||||
public ObservableCollection<EntityListItem> Entities { get; } = [];
|
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>
|
/// <summary>
|
||||||
/// Thread-safe snapshot for the overlay layer (written on UI thread, read on overlay thread).
|
/// Thread-safe snapshot for the overlay layer (written on UI thread, read on overlay thread).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|
@ -103,14 +145,14 @@ public partial class RobotoViewModel : ObservableObject, IDisposable
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static volatile GameDataCache? SharedCache;
|
public static volatile GameDataCache? SharedCache;
|
||||||
|
|
||||||
public RobotoViewModel()
|
public RobotoViewModel(IClientLogWatcher logWatcher)
|
||||||
{
|
{
|
||||||
var config = new BotConfig();
|
var config = new BotConfig();
|
||||||
var reader = new GameMemoryReader();
|
var reader = new GameMemoryReader();
|
||||||
var humanizer = new Humanizer(config);
|
var humanizer = new Humanizer(config);
|
||||||
var input = new InterceptionInputController(humanizer);
|
var input = new InterceptionInputController(humanizer);
|
||||||
|
|
||||||
_engine = new BotEngine(config, reader, input);
|
_engine = new BotEngine(config, reader, input, humanizer);
|
||||||
|
|
||||||
_engine.StatusChanged += status =>
|
_engine.StatusChanged += status =>
|
||||||
{
|
{
|
||||||
|
|
@ -118,6 +160,17 @@ public partial class RobotoViewModel : ObservableObject, IDisposable
|
||||||
};
|
};
|
||||||
|
|
||||||
_engine.StateUpdated += OnStateUpdated;
|
_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]
|
[RelayCommand]
|
||||||
|
|
@ -147,6 +200,7 @@ public partial class RobotoViewModel : ObservableObject, IDisposable
|
||||||
EntityCount = "—";
|
EntityCount = "—";
|
||||||
HostileCount = "—";
|
HostileCount = "—";
|
||||||
TickInfo = "—";
|
TickInfo = "—";
|
||||||
|
ApmInfo = "0";
|
||||||
NavMode = "Idle";
|
NavMode = "Idle";
|
||||||
NavStatus = "—";
|
NavStatus = "—";
|
||||||
Entities.Clear();
|
Entities.Clear();
|
||||||
|
|
@ -167,8 +221,147 @@ public partial class RobotoViewModel : ObservableObject, IDisposable
|
||||||
_engine.Nav.Stop();
|
_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()
|
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
|
// Throttle UI updates to ~10Hz
|
||||||
var now = Environment.TickCount64;
|
var now = Environment.TickCount64;
|
||||||
if (now - _lastUiUpdate < 100) return;
|
if (now - _lastUiUpdate < 100) return;
|
||||||
|
|
@ -197,6 +390,7 @@ public partial class RobotoViewModel : ObservableObject, IDisposable
|
||||||
EntityCount = $"{state.Entities.Count} total";
|
EntityCount = $"{state.Entities.Count} total";
|
||||||
HostileCount = $"{state.HostileMonsters.Count} hostile";
|
HostileCount = $"{state.HostileMonsters.Count} hostile";
|
||||||
TickInfo = $"Tick {state.TickNumber}, dt={state.DeltaTime * 1000:F0}ms";
|
TickInfo = $"Tick {state.TickNumber}, dt={state.DeltaTime * 1000:F0}ms";
|
||||||
|
ApmInfo = $"{_engine.CurrentApm} / {250}";
|
||||||
|
|
||||||
// Systems status
|
// Systems status
|
||||||
var systems = _engine.Systems;
|
var systems = _engine.Systems;
|
||||||
|
|
@ -207,6 +401,35 @@ public partial class RobotoViewModel : ObservableObject, IDisposable
|
||||||
NavMode = _engine.Nav.Mode.ToString();
|
NavMode = _engine.Nav.Mode.ToString();
|
||||||
NavStatus = _engine.Nav.Status;
|
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
|
// Entity list
|
||||||
UpdateEntityList(state);
|
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)
|
private static string GetShortLabel(string? path)
|
||||||
{
|
{
|
||||||
if (path is null) return "?";
|
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" />
|
Padding="10,4" FontWeight="Bold" Margin="0,0,6,4" />
|
||||||
<Button Content="Actor Diff" Command="{Binding ScanActorDiffExecuteCommand}"
|
<Button Content="Actor Diff" Command="{Binding ScanActorDiffExecuteCommand}"
|
||||||
Padding="10,4" FontWeight="Bold" Margin="0,0,6,4" />
|
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>
|
</WrapPanel>
|
||||||
<TextBox Text="{Binding ScanResult, Mode=OneWay}" FontFamily="Consolas"
|
<TextBox Text="{Binding ScanResult, Mode=OneWay}" FontFamily="Consolas"
|
||||||
FontSize="10" Foreground="#e6edf3" Background="#0d1117"
|
FontSize="10" Foreground="#e6edf3" Background="#0d1117"
|
||||||
|
|
@ -832,6 +834,116 @@
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</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 -->
|
<!-- Player State -->
|
||||||
<Border Background="#161b22" BorderBrush="#30363d" BorderThickness="1"
|
<Border Background="#161b22" BorderBrush="#30363d" BorderThickness="1"
|
||||||
CornerRadius="8" Padding="8">
|
CornerRadius="8" Padding="8">
|
||||||
|
|
@ -857,7 +969,7 @@
|
||||||
<StackPanel Spacing="4">
|
<StackPanel Spacing="4">
|
||||||
<TextBlock Text="GAME STATE" FontSize="11" FontWeight="SemiBold"
|
<TextBlock Text="GAME STATE" FontSize="11" FontWeight="SemiBold"
|
||||||
Foreground="#8b949e" />
|
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="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="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" />
|
<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="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="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="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="0" Text="APM:" Foreground="#8b949e" FontSize="12" />
|
||||||
<TextBlock Grid.Row="4" Grid.Column="1" Text="{Binding SystemsInfo}" Foreground="#e6edf3" FontFamily="Consolas" 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="Tick:" Foreground="#8b949e" FontSize="12" />
|
<TextBlock Grid.Row="5" Grid.Column="0" Text="Systems:" Foreground="#8b949e" FontSize="12" />
|
||||||
<TextBlock Grid.Row="5" Grid.Column="1" Text="{Binding TickInfo}" Foreground="#484f58" FontFamily="Consolas" 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>
|
</Grid>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
@ -892,6 +1006,8 @@
|
||||||
<TextBlock Grid.Row="1" Grid.Column="0" Text="Status:" Foreground="#8b949e" FontSize="12" />
|
<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" />
|
<TextBlock Grid.Row="1" Grid.Column="1" Text="{Binding NavStatus}" Foreground="#e6edf3" FontFamily="Consolas" FontSize="12" />
|
||||||
</Grid>
|
</Grid>
|
||||||
|
<Image Source="{Binding TerrainImage}" Width="400" Height="400"
|
||||||
|
Stretch="Uniform" Margin="0,4,0,0" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ using System.Numerics;
|
||||||
|
|
||||||
namespace Roboto.Core;
|
namespace Roboto.Core;
|
||||||
|
|
||||||
public enum ClickType { Left, Right }
|
public enum ClickType { Left, Right, Middle }
|
||||||
public enum KeyActionType { Press, Down, Up }
|
public enum KeyActionType { Press, Down, Up }
|
||||||
|
|
||||||
public abstract record BotAction(int Priority);
|
public abstract record BotAction(int Priority);
|
||||||
|
|
|
||||||
|
|
@ -14,10 +14,6 @@ public class BotConfig
|
||||||
// Navigation
|
// Navigation
|
||||||
public float WorldToGrid { get; set; } = 23f / 250f;
|
public float WorldToGrid { get; set; } = 23f / 250f;
|
||||||
|
|
||||||
// Combat
|
|
||||||
public float CriticalHpPercent { get; set; } = 30f;
|
|
||||||
public float LowHpPercent { get; set; } = 50f;
|
|
||||||
|
|
||||||
// Loot
|
// Loot
|
||||||
public float LootPickupRange { get; set; } = 600f;
|
public float LootPickupRange { get; set; } = 600f;
|
||||||
|
|
||||||
|
|
@ -30,11 +26,4 @@ public class BotConfig
|
||||||
|
|
||||||
// Anti-detection
|
// Anti-detection
|
||||||
public float PollIntervalJitter { get; set; } = 0.2f;
|
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 MonsterRarity Rarity { get; init; }
|
||||||
public List<string>? ModNames { get; init; }
|
public List<string>? ModNames { get; init; }
|
||||||
public string? TransitionName { get; init; }
|
public string? TransitionName { get; init; }
|
||||||
|
public int TransitionState { get; init; } = -1;
|
||||||
public string? Metadata { get; init; }
|
public string? Metadata { get; init; }
|
||||||
|
|
||||||
// Action state (from Actor component)
|
// 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 uint AreaHash { get; set; }
|
||||||
public int AreaLevel { get; set; }
|
public int AreaLevel { get; set; }
|
||||||
|
public string? CurrentAreaName { get; set; }
|
||||||
public bool IsLoading { get; set; }
|
public bool IsLoading { get; set; }
|
||||||
public bool IsEscapeOpen { get; set; }
|
public bool IsEscapeOpen { get; set; }
|
||||||
public DangerLevel Danger { get; set; }
|
public DangerLevel Danger { get; set; }
|
||||||
public Matrix4x4? CameraMatrix { get; set; }
|
public Matrix4x4? CameraMatrix { get; set; }
|
||||||
|
public IReadOnlyList<QuestProgress> ActiveQuests { get; set; } = [];
|
||||||
|
|
||||||
// Derived (computed once per tick by GameStateEnricher)
|
// Derived (computed once per tick by GameStateEnricher)
|
||||||
public ThreatMap Threats { get; set; } = new();
|
public ThreatMap Threats { get; set; } = new();
|
||||||
|
|
|
||||||
|
|
@ -7,9 +7,11 @@ public interface IInputController
|
||||||
void KeyUp(ushort scanCode);
|
void KeyUp(ushort scanCode);
|
||||||
void KeyPress(ushort scanCode, int holdMs = 50);
|
void KeyPress(ushort scanCode, int holdMs = 50);
|
||||||
void MouseMoveTo(int x, int y);
|
void MouseMoveTo(int x, int y);
|
||||||
|
void SmoothMoveTo(int x, int y);
|
||||||
void MouseMoveBy(int dx, int dy);
|
void MouseMoveBy(int dx, int dy);
|
||||||
void LeftClick(int x, int y);
|
void LeftClick(int x, int y);
|
||||||
void RightClick(int x, int y);
|
void RightClick(int x, int y);
|
||||||
|
void MiddleClick(int x, int y);
|
||||||
void LeftDown();
|
void LeftDown();
|
||||||
void LeftUp();
|
void LeftUp();
|
||||||
void RightDown();
|
void RightDown();
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ namespace Roboto.Core;
|
||||||
|
|
||||||
public record PlayerState
|
public record PlayerState
|
||||||
{
|
{
|
||||||
|
public string? CharacterName { get; init; }
|
||||||
public Vector2 Position { get; init; }
|
public Vector2 Position { get; init; }
|
||||||
public float Z { get; init; }
|
public float Z { get; init; }
|
||||||
public bool HasPosition { 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 ManaPercent => ManaTotal > 0 ? (float)ManaCurrent / ManaTotal * 100f : 0f;
|
||||||
public float EsPercent => EsTotal > 0 ? (float)EsCurrent / EsTotal * 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)
|
// Flask state (populated by memory when available)
|
||||||
public IReadOnlyList<FlaskState> Flasks { get; init; } = [];
|
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,
|
Components = e.Components,
|
||||||
ModNames = e.ModNames,
|
ModNames = e.ModNames,
|
||||||
TransitionName = AreaNameLookup.Resolve(e.TransitionName) ?? e.TransitionName,
|
TransitionName = AreaNameLookup.Resolve(e.TransitionName) ?? e.TransitionName,
|
||||||
|
TransitionState = e.TransitionState,
|
||||||
ActionId = e.ActionId,
|
ActionId = e.ActionId,
|
||||||
IsAttacking = e.IsAttacking,
|
IsAttacking = e.IsAttacking,
|
||||||
IsMoving = e.IsMoving,
|
IsMoving = e.IsMoving,
|
||||||
|
|
|
||||||
|
|
@ -86,6 +86,8 @@ public sealed class GameDataCache
|
||||||
public volatile WalkabilitySnapshot? Terrain;
|
public volatile WalkabilitySnapshot? Terrain;
|
||||||
public volatile uint AreaHash;
|
public volatile uint AreaHash;
|
||||||
public volatile int AreaLevel;
|
public volatile int AreaLevel;
|
||||||
|
public volatile string? CurrentAreaName;
|
||||||
|
public volatile string? CharacterName;
|
||||||
|
|
||||||
// ── Full GameState (updated at 10Hz) — for systems that need the complete object ──
|
// ── Full GameState (updated at 10Hz) — for systems that need the complete object ──
|
||||||
public volatile GameState? LatestState;
|
public volatile GameState? LatestState;
|
||||||
|
|
|
||||||
|
|
@ -142,6 +142,7 @@ public sealed class MemoryPoller : IDisposable
|
||||||
_cache.Terrain = state.Terrain;
|
_cache.Terrain = state.Terrain;
|
||||||
_cache.AreaHash = state.AreaHash;
|
_cache.AreaHash = state.AreaHash;
|
||||||
_cache.AreaLevel = state.AreaLevel;
|
_cache.AreaLevel = state.AreaLevel;
|
||||||
|
_cache.CharacterName = state.Player.CharacterName;
|
||||||
_cache.LatestState = state;
|
_cache.LatestState = state;
|
||||||
_cache.ColdTickTimestamp = Environment.TickCount64;
|
_cache.ColdTickTimestamp = Environment.TickCount64;
|
||||||
|
|
||||||
|
|
@ -258,12 +259,14 @@ public sealed class MemoryPoller : IDisposable
|
||||||
|
|
||||||
state.AreaHash = snap.AreaHash;
|
state.AreaHash = snap.AreaHash;
|
||||||
state.AreaLevel = snap.AreaLevel;
|
state.AreaLevel = snap.AreaLevel;
|
||||||
|
state.CurrentAreaName = _cache.CurrentAreaName;
|
||||||
state.IsLoading = snap.IsLoading;
|
state.IsLoading = snap.IsLoading;
|
||||||
state.IsEscapeOpen = snap.IsEscapeOpen;
|
state.IsEscapeOpen = snap.IsEscapeOpen;
|
||||||
state.CameraMatrix = snap.CameraMatrix;
|
state.CameraMatrix = snap.CameraMatrix;
|
||||||
|
|
||||||
state.Player = new PlayerState
|
state.Player = new PlayerState
|
||||||
{
|
{
|
||||||
|
CharacterName = snap.CharacterName,
|
||||||
HasPosition = snap.HasPosition,
|
HasPosition = snap.HasPosition,
|
||||||
Position = snap.HasPosition ? new Vector2(snap.PlayerX, snap.PlayerY) : Vector2.Zero,
|
Position = snap.HasPosition ? new Vector2(snap.PlayerX, snap.PlayerY) : Vector2.Zero,
|
||||||
Z = snap.PlayerZ,
|
Z = snap.PlayerZ,
|
||||||
|
|
@ -273,10 +276,24 @@ public sealed class MemoryPoller : IDisposable
|
||||||
ManaTotal = snap.ManaTotal,
|
ManaTotal = snap.ManaTotal,
|
||||||
EsCurrent = snap.EsCurrent,
|
EsCurrent = snap.EsCurrent,
|
||||||
EsTotal = snap.EsTotal,
|
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 })
|
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 playerPos = state.Player.Position;
|
||||||
var allEntities = new List<EntitySnapshot>(snap.Entities.Count);
|
var allEntities = new List<EntitySnapshot>(snap.Entities.Count);
|
||||||
var hostiles = new List<EntitySnapshot>();
|
var hostiles = new List<EntitySnapshot>();
|
||||||
|
|
@ -300,6 +317,17 @@ public sealed class MemoryPoller : IDisposable
|
||||||
state.NearbyLoot = loot;
|
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)
|
if (snap.Terrain is not null)
|
||||||
{
|
{
|
||||||
state.Terrain = new WalkabilitySnapshot
|
state.Terrain = new WalkabilitySnapshot
|
||||||
|
|
|
||||||
|
|
@ -60,6 +60,20 @@ public sealed class Humanizer
|
||||||
_actionTimestamps.Enqueue(Environment.TickCount64);
|
_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>
|
/// <summary>
|
||||||
/// Returns base interval ± jitter% for poll/tick randomization.
|
/// Returns base interval ± jitter% for poll/tick randomization.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,26 @@
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
using InputInterceptorNS;
|
using InputInterceptorNS;
|
||||||
using Roboto.Core;
|
using Roboto.Core;
|
||||||
using Serilog;
|
using Serilog;
|
||||||
|
|
||||||
namespace Roboto.Input;
|
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 readonly Humanizer? _humanizer;
|
||||||
private KeyboardHook? _keyboard;
|
private KeyboardHook? _keyboard;
|
||||||
private MouseHook? _mouse;
|
private MouseHook? _mouse;
|
||||||
private bool _disposed;
|
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 bool IsInitialized => _keyboard is not null && _mouse is not null;
|
||||||
|
|
||||||
public InterceptionInputController(Humanizer? humanizer = null)
|
public InterceptionInputController(Humanizer? humanizer = null)
|
||||||
|
|
@ -79,6 +89,56 @@ public sealed class InterceptionInputController : IInputController, IDisposable
|
||||||
_mouse?.MoveCursorBy(dx, dy, false);
|
_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)
|
public void LeftClick(int x, int y)
|
||||||
{
|
{
|
||||||
if (_humanizer is not null)
|
if (_humanizer is not null)
|
||||||
|
|
@ -88,7 +148,7 @@ public sealed class InterceptionInputController : IInputController, IDisposable
|
||||||
Thread.Sleep(_humanizer.GaussianDelay(10));
|
Thread.Sleep(_humanizer.GaussianDelay(10));
|
||||||
_humanizer.RecordAction();
|
_humanizer.RecordAction();
|
||||||
}
|
}
|
||||||
MouseMoveTo(x, y);
|
SmoothMoveTo(x, y);
|
||||||
Thread.Sleep(_humanizer is not null ? _humanizer.GaussianDelay(10) : 10);
|
Thread.Sleep(_humanizer is not null ? _humanizer.GaussianDelay(10) : 10);
|
||||||
_mouse?.SimulateLeftButtonClick(_humanizer?.GaussianDelay(50) ?? 50);
|
_mouse?.SimulateLeftButtonClick(_humanizer?.GaussianDelay(50) ?? 50);
|
||||||
}
|
}
|
||||||
|
|
@ -102,11 +162,25 @@ public sealed class InterceptionInputController : IInputController, IDisposable
|
||||||
Thread.Sleep(_humanizer.GaussianDelay(10));
|
Thread.Sleep(_humanizer.GaussianDelay(10));
|
||||||
_humanizer.RecordAction();
|
_humanizer.RecordAction();
|
||||||
}
|
}
|
||||||
MouseMoveTo(x, y);
|
SmoothMoveTo(x, y);
|
||||||
Thread.Sleep(_humanizer is not null ? _humanizer.GaussianDelay(10) : 10);
|
Thread.Sleep(_humanizer is not null ? _humanizer.GaussianDelay(10) : 10);
|
||||||
_mouse?.SimulateRightButtonClick(_humanizer?.GaussianDelay(50) ?? 50);
|
_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()
|
public void LeftDown()
|
||||||
{
|
{
|
||||||
_mouse?.SimulateLeftButtonDown();
|
_mouse?.SimulateLeftButtonDown();
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
<TargetFramework>net8.0-windows10.0.19041.0</TargetFramework>
|
<TargetFramework>net8.0-windows10.0.19041.0</TargetFramework>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
|
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="InputInterceptor" Version="2.2.1" />
|
<PackageReference Include="InputInterceptor" Version="2.2.1" />
|
||||||
|
|
|
||||||
|
|
@ -291,6 +291,19 @@ public sealed class ComponentReader
|
||||||
return true;
|
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>
|
/// <summary>
|
||||||
/// Resolves EntityDetails pointer for an entity, handling ECS inner entity wrapper.
|
/// Resolves EntityDetails pointer for an entity, handling ECS inner entity wrapper.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,7 @@ public class Entity
|
||||||
|
|
||||||
// AreaTransition destination (raw area ID, e.g. "G1_4")
|
// AreaTransition destination (raw area ID, e.g. "G1_4")
|
||||||
public string? TransitionName { get; internal set; }
|
public string? TransitionName { get; internal set; }
|
||||||
|
public int TransitionState { get; internal set; } = -1;
|
||||||
|
|
||||||
// Action state (from Actor component)
|
// Action state (from Actor component)
|
||||||
public short ActionId { get; internal set; }
|
public short ActionId { get; internal set; }
|
||||||
|
|
|
||||||
|
|
@ -80,6 +80,17 @@ public sealed class EntityReader
|
||||||
|
|
||||||
var (compFirst, compCount) = _components.FindComponentList(entityPtr);
|
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
|
// Read HP/Actor/Mods for monsters
|
||||||
if (entity.Components.Contains("Monster"))
|
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") &&
|
if (entity.Components.Contains("AreaTransition") &&
|
||||||
lookup.TryGetValue("AreaTransition", out var atIdx) && atIdx >= 0 && atIdx < compCount)
|
lookup.TryGetValue("AreaTransition", out var atIdx) && atIdx >= 0 && atIdx < compCount)
|
||||||
{
|
{
|
||||||
|
|
@ -127,6 +138,17 @@ public sealed class EntityReader
|
||||||
if (atComp != 0)
|
if (atComp != 0)
|
||||||
entity.TransitionName = ReadAreaTransitionName(atComp);
|
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 MsvcStringReader? _strings;
|
||||||
private RttiResolver? _rtti;
|
private RttiResolver? _rtti;
|
||||||
private SkillReader? _skills;
|
private SkillReader? _skills;
|
||||||
|
private QuestReader? _quests;
|
||||||
|
|
||||||
public ObjectRegistry Registry => _registry;
|
public ObjectRegistry Registry => _registry;
|
||||||
public MemoryDiagnostics? Diagnostics { get; private set; }
|
public MemoryDiagnostics? Diagnostics { get; private set; }
|
||||||
|
|
@ -99,6 +100,7 @@ public class GameMemoryReader : IDisposable
|
||||||
_entities = new EntityReader(_ctx, _components, _strings);
|
_entities = new EntityReader(_ctx, _components, _strings);
|
||||||
_terrain = new TerrainReader(_ctx);
|
_terrain = new TerrainReader(_ctx);
|
||||||
_skills = new SkillReader(_ctx, _components, _strings);
|
_skills = new SkillReader(_ctx, _components, _strings);
|
||||||
|
_quests = new QuestReader(_ctx, _strings);
|
||||||
Diagnostics = new MemoryDiagnostics(_ctx, _stateReader, _components, _entities, _strings, _rtti);
|
Diagnostics = new MemoryDiagnostics(_ctx, _stateReader, _components, _entities, _strings, _rtti);
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
|
|
@ -116,6 +118,7 @@ public class GameMemoryReader : IDisposable
|
||||||
_strings = null;
|
_strings = null;
|
||||||
_rtti = null;
|
_rtti = null;
|
||||||
_skills = null;
|
_skills = null;
|
||||||
|
_quests = null;
|
||||||
Diagnostics = null;
|
Diagnostics = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -219,7 +222,9 @@ public class GameMemoryReader : IDisposable
|
||||||
_components.InvalidateCaches(snap.LocalPlayerPtr);
|
_components.InvalidateCaches(snap.LocalPlayerPtr);
|
||||||
_components.ReadPlayerVitals(snap);
|
_components.ReadPlayerVitals(snap);
|
||||||
_components.ReadPlayerPosition(snap);
|
_components.ReadPlayerPosition(snap);
|
||||||
|
snap.CharacterName = _components.ReadPlayerName(snap.LocalPlayerPtr);
|
||||||
snap.PlayerSkills = _skills!.ReadPlayerSkills(snap.LocalPlayerPtr);
|
snap.PlayerSkills = _skills!.ReadPlayerSkills(snap.LocalPlayerPtr);
|
||||||
|
snap.QuestFlags = _quests!.ReadQuestFlags(snap.ServerDataPtr);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read state flag bytes
|
// Read state flag bytes
|
||||||
|
|
|
||||||
|
|
@ -90,6 +90,24 @@ public sealed class GameOffsets
|
||||||
// ServerData → fields
|
// ServerData → fields
|
||||||
/// <summary>ServerData → LocalPlayer entity pointer (fallback if LocalPlayerDirectOffset is 0).</summary>
|
/// <summary>ServerData → LocalPlayer entity pointer (fallback if LocalPlayerDirectOffset is 0).</summary>
|
||||||
public int LocalPlayerOffset { get; set; } = 0x20;
|
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 ──
|
// ── Entity / Component ──
|
||||||
public int ComponentListOffset { get; set; } = 0x10;
|
public int ComponentListOffset { get; set; } = 0x10;
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,9 @@ public class GameStateSnapshot
|
||||||
public int AreaLevel;
|
public int AreaLevel;
|
||||||
public uint AreaHash;
|
public uint AreaHash;
|
||||||
|
|
||||||
|
// Player
|
||||||
|
public string? CharacterName;
|
||||||
|
|
||||||
// Player position (Render component)
|
// Player position (Render component)
|
||||||
public bool HasPosition;
|
public bool HasPosition;
|
||||||
public float PlayerX, PlayerY, PlayerZ;
|
public float PlayerX, PlayerY, PlayerZ;
|
||||||
|
|
@ -63,6 +66,9 @@ public class GameStateSnapshot
|
||||||
// Player skills (from Actor component)
|
// Player skills (from Actor component)
|
||||||
public List<SkillSnapshot>? PlayerSkills;
|
public List<SkillSnapshot>? PlayerSkills;
|
||||||
|
|
||||||
|
// Quest flags (from ServerData → PlayerServerData)
|
||||||
|
public List<QuestSnapshot>? QuestFlags;
|
||||||
|
|
||||||
// Camera
|
// Camera
|
||||||
public Matrix4x4? CameraMatrix;
|
public Matrix4x4? CameraMatrix;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3834,4 +3834,288 @@ public sealed class MemoryDiagnostics
|
||||||
result.AppendLine("Click again to capture a new baseline.");
|
result.AppendLine("Click again to capture a new baseline.");
|
||||||
return result.ToString();
|
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 long _pathTimestampMs;
|
||||||
private Vector2? _goalPosition;
|
private Vector2? _goalPosition;
|
||||||
private uint _targetEntityId;
|
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
|
// Stuck detection: rolling window of recent positions
|
||||||
private readonly Queue<Vector2> _positionHistory = new();
|
private readonly Queue<Vector2> _positionHistory = new();
|
||||||
|
|
@ -33,6 +39,15 @@ public sealed class NavigationController
|
||||||
public Vector2? DesiredDirection { get; private set; }
|
public Vector2? DesiredDirection { get; private set; }
|
||||||
public IReadOnlyList<Vector2>? CurrentPath => _path;
|
public IReadOnlyList<Vector2>? CurrentPath => _path;
|
||||||
public string Status { get; private set; } = "Idle";
|
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)
|
public NavigationController(BotConfig config)
|
||||||
{
|
{
|
||||||
|
|
@ -67,11 +82,19 @@ public sealed class NavigationController
|
||||||
_targetEntityId = 0;
|
_targetEntityId = 0;
|
||||||
_path = null;
|
_path = null;
|
||||||
_waypointIndex = 0;
|
_waypointIndex = 0;
|
||||||
|
_exploreBiasPoint = null;
|
||||||
Mode = NavMode.Exploring;
|
Mode = NavMode.Exploring;
|
||||||
Status = "Exploring";
|
Status = "Exploring";
|
||||||
Log.Debug("NavigationController: exploring");
|
Log.Debug("NavigationController: exploring");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void ExploreToward(Vector2 biasPoint)
|
||||||
|
{
|
||||||
|
if (Mode != NavMode.Exploring)
|
||||||
|
Explore();
|
||||||
|
_exploreBiasPoint = biasPoint;
|
||||||
|
}
|
||||||
|
|
||||||
public void Stop()
|
public void Stop()
|
||||||
{
|
{
|
||||||
_goalPosition = null;
|
_goalPosition = null;
|
||||||
|
|
@ -95,13 +118,50 @@ public sealed class NavigationController
|
||||||
var playerPos = state.Player.Position;
|
var playerPos = state.Player.Position;
|
||||||
var now = state.TimestampMs;
|
var now = state.TimestampMs;
|
||||||
|
|
||||||
// Area change → clear path
|
// Area change → clear path, bias, and explored grid
|
||||||
if (state.AreaHash != _lastAreaHash)
|
if (state.AreaHash != _lastAreaHash)
|
||||||
{
|
{
|
||||||
_lastAreaHash = state.AreaHash;
|
_lastAreaHash = state.AreaHash;
|
||||||
_path = null;
|
_path = null;
|
||||||
_waypointIndex = 0;
|
_waypointIndex = 0;
|
||||||
_positionHistory.Clear();
|
_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
|
// Resolve goal based on mode
|
||||||
|
|
@ -188,7 +248,10 @@ public sealed class NavigationController
|
||||||
state.Terrain?.Width ?? 0, state.Terrain?.Height ?? 0);
|
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;
|
_waypointIndex = 0;
|
||||||
_pathTimestampMs = now;
|
_pathTimestampMs = now;
|
||||||
|
|
||||||
|
|
@ -274,30 +337,61 @@ public sealed class NavigationController
|
||||||
|
|
||||||
private Vector2? PickExploreTarget(GameState state)
|
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;
|
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 gridToWorld = 1f / _config.WorldToGrid;
|
||||||
|
var playerPos = state.Player.Position;
|
||||||
|
var w = terrain.Width;
|
||||||
|
var h = terrain.Height;
|
||||||
|
|
||||||
// Try random walkable points
|
var startGx = Math.Clamp((int)(playerPos.X * _config.WorldToGrid), 0, w - 1);
|
||||||
for (var attempt = 0; attempt < 200; attempt++)
|
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 (cx, cy) = queue.Dequeue();
|
||||||
var gy = _rng.Next(terrain.Height);
|
|
||||||
|
|
||||||
if (!terrain.IsWalkable(gx, gy)) continue;
|
// Found an unexplored walkable cell
|
||||||
|
if (terrain.IsWalkable(cx, cy) && !_exploredGrid[cy * w + cx])
|
||||||
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)
|
|
||||||
{
|
{
|
||||||
|
var worldPos = new Vector2(cx * gridToWorld, cy * gridToWorld);
|
||||||
_goalPosition = worldPos;
|
_goalPosition = worldPos;
|
||||||
|
Log.Debug("BFS frontier: target ({Gx},{Gy}) after {Iter} iterations", cx, cy, iterations);
|
||||||
return worldPos;
|
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;
|
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>
|
/// <summary>
|
||||||
/// A* pathfinding on WalkabilitySnapshot. Returns world-coord waypoints or null if no path.
|
/// 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>
|
/// </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 w = terrain.Width;
|
||||||
var h = terrain.Height;
|
var h = terrain.Height;
|
||||||
|
|
@ -69,7 +72,10 @@ public static class PathFinder
|
||||||
}
|
}
|
||||||
|
|
||||||
var neighbor = (nx, ny);
|
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))
|
if (tentativeG < gScore.GetValueOrDefault(neighbor, float.MaxValue))
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
|
using System.Numerics;
|
||||||
using Roboto.Core;
|
using Roboto.Core;
|
||||||
|
using Serilog;
|
||||||
|
|
||||||
namespace Roboto.Systems;
|
namespace Roboto.Systems;
|
||||||
|
|
||||||
|
|
@ -6,10 +8,306 @@ public class CombatSystem : ISystem
|
||||||
{
|
{
|
||||||
public int Priority => SystemPriority.Combat;
|
public int Priority => SystemPriority.Combat;
|
||||||
public string Name => "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)
|
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
|
public class ResourceSystem : ISystem
|
||||||
{
|
{
|
||||||
private readonly BotConfig _config;
|
private readonly BotConfig _config;
|
||||||
|
private FlaskSettings _flasks;
|
||||||
private long _lastLifeFlaskMs;
|
private long _lastLifeFlaskMs;
|
||||||
private long _lastManaFlaskMs;
|
private long _lastManaFlaskMs;
|
||||||
|
|
||||||
|
|
@ -15,6 +16,17 @@ public class ResourceSystem : ISystem
|
||||||
public ResourceSystem(BotConfig config)
|
public ResourceSystem(BotConfig config)
|
||||||
{
|
{
|
||||||
_config = 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)
|
public void Update(GameState state, ActionQueue actions)
|
||||||
|
|
@ -24,18 +36,18 @@ public class ResourceSystem : ISystem
|
||||||
|
|
||||||
var now = Environment.TickCount64;
|
var now = Environment.TickCount64;
|
||||||
|
|
||||||
if (player.LifePercent < _config.LifeFlaskThreshold
|
if (player.LifePercent < _flasks.LifeFlaskThreshold
|
||||||
&& now - _lastLifeFlaskMs >= _config.FlaskCooldownMs)
|
&& now - _lastLifeFlaskMs >= _flasks.FlaskCooldownMs)
|
||||||
{
|
{
|
||||||
actions.Submit(new FlaskAction(Priority, _config.LifeFlaskScanCode));
|
actions.Submit(new FlaskAction(Priority, _flasks.LifeFlaskScanCode));
|
||||||
_lastLifeFlaskMs = now;
|
_lastLifeFlaskMs = now;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (player.ManaTotal > 0
|
if (player.ManaTotal > 0
|
||||||
&& player.ManaPercent < _config.ManaFlaskThreshold
|
&& player.ManaPercent < _flasks.ManaFlaskThreshold
|
||||||
&& now - _lastManaFlaskMs >= _config.FlaskCooldownMs)
|
&& now - _lastManaFlaskMs >= _flasks.FlaskCooldownMs)
|
||||||
{
|
{
|
||||||
actions.Submit(new FlaskAction(Priority, _config.ManaFlaskScanCode));
|
actions.Submit(new FlaskAction(Priority, _flasks.ManaFlaskScanCode));
|
||||||
_lastManaFlaskMs = now;
|
_lastManaFlaskMs = now;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue