work on memory

This commit is contained in:
Boki 2026-03-01 13:26:47 -05:00
parent 2f652aa1b7
commit 6e9d89b045
28 changed files with 3590 additions and 111 deletions

3
.gitignore vendored
View file

@ -31,3 +31,6 @@ tools/python-detect/models/
# IDE / tools # IDE / tools
.claude/ .claude/
nul nul
# Extras
lib/extras

View file

@ -27,6 +27,24 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Automata.Navigation", "src\
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Automata.Memory", "src\Automata.Memory\Automata.Memory.csproj", "{B7E3F1A2-4D5C-6E7F-8A9B-0C1D2E3F4A5B}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Automata.Memory", "src\Automata.Memory\Automata.Memory.csproj", "{B7E3F1A2-4D5C-6E7F-8A9B-0C1D2E3F4A5B}"
EndProject EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "lib", "lib", "{652F700E-4F84-4E66-BD62-717D3A8D6FBC}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sidekick.Common", "lib\Sidekick\src\Sidekick.Common\Sidekick.Common.csproj", "{B858F6F2-389F-475A-87FE-E4E01DA3E948}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sidekick.Common.Database", "lib\Sidekick\src\Sidekick.Common.Database\Sidekick.Common.Database.csproj", "{6FEA655D-18E4-4DA1-839F-A41433B03FBB}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sidekick.Common.Browser", "lib\Sidekick\src\Sidekick.Common.Browser\Sidekick.Common.Browser.csproj", "{74FD0F88-86BC-49AE-9A16-136D92A10090}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sidekick.Apis.Common", "lib\Sidekick\src\Sidekick.Apis.Common\Sidekick.Apis.Common.csproj", "{3A65A8CD-0CE2-4191-8D04-E3968CA2CCD5}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sidekick.Apis.Poe", "lib\Sidekick\src\Sidekick.Apis.Poe\Sidekick.Apis.Poe.csproj", "{2A5D289F-7916-484C-B2D8-99A128F5F936}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sidekick.Apis.Poe.Trade", "lib\Sidekick\src\Sidekick.Apis.Poe.Trade\Sidekick.Apis.Poe.Trade.csproj", "{8CEE036C-A229-4F22-BD0E-D7CDAE13E54F}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sidekick.Data", "lib\Sidekick\src\Sidekick.Data\Sidekick.Data.csproj", "{9428D5D4-4061-467A-BD26-C1FEED95E8E6}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sidekick.Data.Builder", "lib\Sidekick\src\Sidekick.Data.Builder\Sidekick.Data.Builder.csproj", "{E5C26A34-5EDF-488B-93C7-F8738F2CEB97}"
EndProject
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU Debug|Any CPU = Debug|Any CPU
@ -80,6 +98,38 @@ Global
{B7E3F1A2-4D5C-6E7F-8A9B-0C1D2E3F4A5B}.Debug|Any CPU.Build.0 = Debug|Any CPU {B7E3F1A2-4D5C-6E7F-8A9B-0C1D2E3F4A5B}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B7E3F1A2-4D5C-6E7F-8A9B-0C1D2E3F4A5B}.Release|Any CPU.ActiveCfg = Release|Any CPU {B7E3F1A2-4D5C-6E7F-8A9B-0C1D2E3F4A5B}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B7E3F1A2-4D5C-6E7F-8A9B-0C1D2E3F4A5B}.Release|Any CPU.Build.0 = Release|Any CPU {B7E3F1A2-4D5C-6E7F-8A9B-0C1D2E3F4A5B}.Release|Any CPU.Build.0 = Release|Any CPU
{B858F6F2-389F-475A-87FE-E4E01DA3E948}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{B858F6F2-389F-475A-87FE-E4E01DA3E948}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B858F6F2-389F-475A-87FE-E4E01DA3E948}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B858F6F2-389F-475A-87FE-E4E01DA3E948}.Release|Any CPU.Build.0 = Release|Any CPU
{6FEA655D-18E4-4DA1-839F-A41433B03FBB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{6FEA655D-18E4-4DA1-839F-A41433B03FBB}.Debug|Any CPU.Build.0 = Debug|Any CPU
{6FEA655D-18E4-4DA1-839F-A41433B03FBB}.Release|Any CPU.ActiveCfg = Release|Any CPU
{6FEA655D-18E4-4DA1-839F-A41433B03FBB}.Release|Any CPU.Build.0 = Release|Any CPU
{74FD0F88-86BC-49AE-9A16-136D92A10090}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{74FD0F88-86BC-49AE-9A16-136D92A10090}.Debug|Any CPU.Build.0 = Debug|Any CPU
{74FD0F88-86BC-49AE-9A16-136D92A10090}.Release|Any CPU.ActiveCfg = Release|Any CPU
{74FD0F88-86BC-49AE-9A16-136D92A10090}.Release|Any CPU.Build.0 = Release|Any CPU
{3A65A8CD-0CE2-4191-8D04-E3968CA2CCD5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{3A65A8CD-0CE2-4191-8D04-E3968CA2CCD5}.Debug|Any CPU.Build.0 = Debug|Any CPU
{3A65A8CD-0CE2-4191-8D04-E3968CA2CCD5}.Release|Any CPU.ActiveCfg = Release|Any CPU
{3A65A8CD-0CE2-4191-8D04-E3968CA2CCD5}.Release|Any CPU.Build.0 = Release|Any CPU
{2A5D289F-7916-484C-B2D8-99A128F5F936}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{2A5D289F-7916-484C-B2D8-99A128F5F936}.Debug|Any CPU.Build.0 = Debug|Any CPU
{2A5D289F-7916-484C-B2D8-99A128F5F936}.Release|Any CPU.ActiveCfg = Release|Any CPU
{2A5D289F-7916-484C-B2D8-99A128F5F936}.Release|Any CPU.Build.0 = Release|Any CPU
{8CEE036C-A229-4F22-BD0E-D7CDAE13E54F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{8CEE036C-A229-4F22-BD0E-D7CDAE13E54F}.Debug|Any CPU.Build.0 = Debug|Any CPU
{8CEE036C-A229-4F22-BD0E-D7CDAE13E54F}.Release|Any CPU.ActiveCfg = Release|Any CPU
{8CEE036C-A229-4F22-BD0E-D7CDAE13E54F}.Release|Any CPU.Build.0 = Release|Any CPU
{9428D5D4-4061-467A-BD26-C1FEED95E8E6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{9428D5D4-4061-467A-BD26-C1FEED95E8E6}.Debug|Any CPU.Build.0 = Debug|Any CPU
{9428D5D4-4061-467A-BD26-C1FEED95E8E6}.Release|Any CPU.ActiveCfg = Release|Any CPU
{9428D5D4-4061-467A-BD26-C1FEED95E8E6}.Release|Any CPU.Build.0 = Release|Any CPU
{E5C26A34-5EDF-488B-93C7-F8738F2CEB97}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{E5C26A34-5EDF-488B-93C7-F8738F2CEB97}.Debug|Any CPU.Build.0 = Debug|Any CPU
{E5C26A34-5EDF-488B-93C7-F8738F2CEB97}.Release|Any CPU.ActiveCfg = Release|Any CPU
{E5C26A34-5EDF-488B-93C7-F8738F2CEB97}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection EndGlobalSection
GlobalSection(NestedProjects) = preSolution GlobalSection(NestedProjects) = preSolution
{6432F6A5-11A0-4960-AFFC-E810D4325C35} = {67A27DFE-D2C5-479D-86FE-7E156BD0CFAA} {6432F6A5-11A0-4960-AFFC-E810D4325C35} = {67A27DFE-D2C5-479D-86FE-7E156BD0CFAA}
@ -93,5 +143,13 @@ Global
{859F870E-F013-4C2B-AFEC-7A8C6A5FE3F3} = {67A27DFE-D2C5-479D-86FE-7E156BD0CFAA} {859F870E-F013-4C2B-AFEC-7A8C6A5FE3F3} = {67A27DFE-D2C5-479D-86FE-7E156BD0CFAA}
{D3F7A2E1-9B4C-4E8D-A6F5-1C2D3E4F5A6B} = {67A27DFE-D2C5-479D-86FE-7E156BD0CFAA} {D3F7A2E1-9B4C-4E8D-A6F5-1C2D3E4F5A6B} = {67A27DFE-D2C5-479D-86FE-7E156BD0CFAA}
{B7E3F1A2-4D5C-6E7F-8A9B-0C1D2E3F4A5B} = {67A27DFE-D2C5-479D-86FE-7E156BD0CFAA} {B7E3F1A2-4D5C-6E7F-8A9B-0C1D2E3F4A5B} = {67A27DFE-D2C5-479D-86FE-7E156BD0CFAA}
{B858F6F2-389F-475A-87FE-E4E01DA3E948} = {652F700E-4F84-4E66-BD62-717D3A8D6FBC}
{6FEA655D-18E4-4DA1-839F-A41433B03FBB} = {652F700E-4F84-4E66-BD62-717D3A8D6FBC}
{74FD0F88-86BC-49AE-9A16-136D92A10090} = {652F700E-4F84-4E66-BD62-717D3A8D6FBC}
{3A65A8CD-0CE2-4191-8D04-E3968CA2CCD5} = {652F700E-4F84-4E66-BD62-717D3A8D6FBC}
{2A5D289F-7916-484C-B2D8-99A128F5F936} = {652F700E-4F84-4E66-BD62-717D3A8D6FBC}
{8CEE036C-A229-4F22-BD0E-D7CDAE13E54F} = {652F700E-4F84-4E66-BD62-717D3A8D6FBC}
{9428D5D4-4061-467A-BD26-C1FEED95E8E6} = {652F700E-4F84-4E66-BD62-717D3A8D6FBC}
{E5C26A34-5EDF-488B-93C7-F8738F2CEB97} = {652F700E-4F84-4E66-BD62-717D3A8D6FBC}
EndGlobalSection EndGlobalSection
EndGlobal EndGlobal

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

1
data/poe2/mods.min.json Normal file

File diff suppressed because one or more lines are too long

View file

@ -1,12 +0,0 @@
{
"ProcessName": "PathOfExileSteam",
"GameStatePattern": "",
"InGameStateOffset": 0,
"IngameDataOffset": 0,
"TerrainDataOffset": 0,
"NumColsOffset": 0,
"NumRowsOffset": 0,
"LayerMeleeOffset": 0,
"BytesPerRowOffset": 0,
"SubTilesPerCell": 23
}

50
offsets.json Normal file
View file

@ -0,0 +1,50 @@
{
"ProcessName": "PathOfExileSteam",
"GameStatePattern": "48 83 EC ?? 48 8B F1 33 ED 48 39 2D ^",
"GameStateGlobalOffset": 0,
"PatternResultAdjust": 24,
"StatesBeginOffset": 72,
"StateStride": 16,
"StatePointerOffset": 0,
"InGameStateIndex": 4,
"StatesInline": true,
"InGameStateDirectOffset": 528,
"IngameDataFromStateOffset": 656,
"WorldDataFromStateOffset": 760,
"AreaLevelOffset": 196,
"AreaLevelIsByte": true,
"AreaLevelStaticOffset": 0,
"AreaHashOffset": 236,
"ServerDataOffset": 2544,
"LocalPlayerDirectOffset": 2576,
"EntityListOffset": 2808,
"EntityCountInternalOffset": 8,
"LocalPlayerOffset": 32,
"ComponentListOffset": 16,
"LifeComponentIndex": -1,
"RenderComponentIndex": -1,
"LifeComponentOffset1": 1056,
"LifeComponentOffset2": 152,
"LifeHealthOffset": 424,
"LifeManaOffset": 504,
"LifeEsOffset": 560,
"VitalCurrentOffset": 48,
"VitalTotalOffset": 44,
"PositionXOffset": 312,
"PositionYOffset": 316,
"PositionZOffset": 320,
"TerrainListOffset": 3264,
"TerrainInline": true,
"TerrainDimensionsOffset": 24,
"TerrainWalkableGridOffset": 208,
"TerrainBytesPerRowOffset": 256,
"TerrainGridPtrOffset": 8,
"SubTilesPerCell": 23,
"InGameStateOffset": 0,
"IngameDataOffset": 0,
"TerrainDataOffset": 0,
"NumColsOffset": 0,
"NumRowsOffset": 0,
"LayerMeleeOffset": 0,
"BytesPerRowOffset": 0
}

View file

@ -12,5 +12,6 @@
<ProjectReference Include="..\Automata.Log\Automata.Log.csproj" /> <ProjectReference Include="..\Automata.Log\Automata.Log.csproj" />
<ProjectReference Include="..\Automata.Inventory\Automata.Inventory.csproj" /> <ProjectReference Include="..\Automata.Inventory\Automata.Inventory.csproj" />
<ProjectReference Include="..\Automata.Navigation\Automata.Navigation.csproj" /> <ProjectReference Include="..\Automata.Navigation\Automata.Navigation.csproj" />
<ProjectReference Include="..\Automata.Items\Automata.Items.csproj" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View file

@ -1,5 +1,6 @@
using Automata.Core; using Automata.Core;
using Automata.Game; using Automata.Game;
using Automata.Items;
using Serilog; using Serilog;
namespace Automata.Bot; namespace Automata.Bot;
@ -193,6 +194,31 @@ public class CraftingExecutor
{ {
if (string.IsNullOrEmpty(itemText)) return false; if (string.IsNullOrEmpty(itemText)) return false;
// Try Sidekick parsed matching first
try
{
var item = ItemReader.ParseItemText(itemText);
if (item != null)
{
var statTexts = item.Stats.Select(s => s.Text).ToList();
if (matchAll)
{
return requiredMods.All(mod =>
statTexts.Any(st => st.Contains(mod, StringComparison.OrdinalIgnoreCase)));
}
else
{
return requiredMods.Any(mod =>
statTexts.Any(st => st.Contains(mod, StringComparison.OrdinalIgnoreCase)));
}
}
}
catch
{
// Fall through to raw matching
}
// Fallback: raw substring matching
if (matchAll) if (matchAll)
{ {
return requiredMods.All(mod => return requiredMods.All(mod =>

View file

@ -0,0 +1,202 @@
using System.Net.Http;
using System.Text.Json;
using Serilog;
namespace Automata.Core;
public class ModPoolService
{
private const string BaseUrl = "https://repoe-fork.github.io/poe2/";
private static readonly string DataDir = Path.GetFullPath("data/poe2");
private Dictionary<string, RePoEMod> _mods = new();
private Dictionary<string, RePoEBaseItem> _baseItems = new();
private Dictionary<string, RePoEItemClass> _itemClasses = new();
private bool _loaded;
public bool IsLoaded => _loaded;
/// <summary>Craftable item classes (weapons, armour, accessories, etc.)</summary>
private static readonly HashSet<string> CraftableCategories = new(StringComparer.OrdinalIgnoreCase)
{
"Weapon", "Armour", "Jewelry"
};
private static readonly HashSet<string> CraftableClassIds = new(StringComparer.OrdinalIgnoreCase)
{
// Weapons
"Bow", "Claw", "Crossbow", "Dagger", "FishingRod",
"OneHandAxe", "OneHandMace", "OneHandSword",
"Sceptre", "Staff", "TwoHandAxe", "TwoHandMace", "TwoHandSword",
"Wand", "Flail", "Quarterstaff", "Spear", "Trap",
// Armour
"BodyArmour", "Boots", "Gloves", "Helmet", "Shield",
"Focus", "Buckler",
// Accessories
"Amulet", "Belt", "Ring",
// Quiver
"Quiver",
};
public async Task LoadAsync()
{
try
{
Directory.CreateDirectory(DataDir);
using var http = new HttpClient();
http.Timeout = TimeSpan.FromSeconds(30);
await Task.WhenAll(
DownloadIfMissing(http, "mods.min.json"),
DownloadIfMissing(http, "base_items.min.json"),
DownloadIfMissing(http, "item_classes.min.json")
);
var modsJson = await File.ReadAllTextAsync(Path.Combine(DataDir, "mods.min.json"));
_mods = JsonSerializer.Deserialize<Dictionary<string, RePoEMod>>(modsJson) ?? new();
var basesJson = await File.ReadAllTextAsync(Path.Combine(DataDir, "base_items.min.json"));
_baseItems = JsonSerializer.Deserialize<Dictionary<string, RePoEBaseItem>>(basesJson) ?? new();
var classesJson = await File.ReadAllTextAsync(Path.Combine(DataDir, "item_classes.min.json"));
_itemClasses = JsonSerializer.Deserialize<Dictionary<string, RePoEItemClass>>(classesJson) ?? new();
_loaded = true;
Log.Information("RePoE data loaded: {Mods} mods, {Bases} base items, {Classes} item classes",
_mods.Count, _baseItems.Count, _itemClasses.Count);
}
catch (Exception ex)
{
Log.Error(ex, "Failed to load RePoE data");
}
}
private static async Task DownloadIfMissing(HttpClient http, string filename)
{
var path = Path.Combine(DataDir, filename);
if (File.Exists(path)) return;
Log.Information("Downloading RePoE {File}...", filename);
var data = await http.GetStringAsync(BaseUrl + filename);
await File.WriteAllTextAsync(path, data);
}
/// <summary>Returns craftable base items grouped by item class name.</summary>
public List<BaseItemOption> GetCraftableBases()
{
if (!_loaded) return [];
return _baseItems
.Where(kv =>
{
var b = kv.Value;
if (b.ReleaseState != "released") return false;
if (string.IsNullOrEmpty(b.ItemClass)) return false;
return CraftableClassIds.Contains(b.ItemClass);
})
.Select(kv => new BaseItemOption
{
Key = kv.Key,
Name = kv.Value.Name,
ItemClass = GetItemClassName(kv.Value.ItemClass),
})
.OrderBy(b => b.ItemClass)
.ThenBy(b => b.Name)
.ToList();
}
private string GetItemClassName(string classId)
{
if (_itemClasses.TryGetValue(classId, out var cls) && !string.IsNullOrEmpty(cls.Name))
return cls.Name;
return classId;
}
/// <summary>Returns mods that can roll on the given base item at the given ilvl.</summary>
public List<AvailableMod> GetAvailableMods(string baseItemKey, int ilvl)
{
if (!_loaded) return [];
if (!_baseItems.TryGetValue(baseItemKey, out var baseItem)) return [];
var baseTags = new HashSet<string>(baseItem.Tags, StringComparer.OrdinalIgnoreCase);
var result = new List<AvailableMod>();
foreach (var (modId, mod) in _mods)
{
if (mod.Domain != "item") continue;
if (mod.RequiredLevel > ilvl) continue;
if (mod.IsEssenceOnly) continue;
if (string.IsNullOrEmpty(mod.Text)) continue;
// Check spawn_weights: find first tag matching base's tags
var canSpawn = false;
foreach (var sw in mod.SpawnWeights)
{
if (baseTags.Contains(sw.Tag))
{
canSpawn = sw.Weight > 0;
break; // first match determines outcome
}
}
if (!canSpawn) continue;
var statRange = mod.Stats.Count > 0
? string.Join(", ", mod.Stats.Select(s => s.Min == s.Max ? $"{s.Min}" : $"{s.Min}-{s.Max}"))
: "";
// Clean display text: remove wiki markup like [Strength|Strength]
var displayText = System.Text.RegularExpressions.Regex.Replace(
mod.Text, @"\[([^|]*)\|([^\]]*)\]", "$2");
result.Add(new AvailableMod
{
ModId = modId,
Name = mod.Name,
Text = displayText,
GenerationType = mod.GenerationType,
RequiredLevel = mod.RequiredLevel,
StatRange = statRange,
Groups = mod.Groups,
});
}
return result
.OrderBy(m => m.GenerationType)
.ThenBy(m => m.Name)
.ToList();
}
/// <summary>Force re-download of cached data.</summary>
public async Task RefreshAsync()
{
var files = new[] { "mods.min.json", "base_items.min.json", "item_classes.min.json" };
foreach (var f in files)
{
var path = Path.Combine(DataDir, f);
if (File.Exists(path)) File.Delete(path);
}
_loaded = false;
await LoadAsync();
}
}
public class BaseItemOption
{
public string Key { get; set; } = "";
public string Name { get; set; } = "";
public string ItemClass { get; set; } = "";
public override string ToString() => Name;
}
public class AvailableMod
{
public string ModId { get; set; } = "";
public string Name { get; set; } = "";
public string Text { get; set; } = "";
public string GenerationType { get; set; } = "";
public int RequiredLevel { get; set; }
public string StatRange { get; set; } = "";
public List<string> Groups { get; set; } = [];
}

View file

@ -0,0 +1,50 @@
using System.Text.Json.Serialization;
namespace Automata.Core;
public class RePoEMod
{
[JsonPropertyName("name")] public string Name { get; set; } = "";
[JsonPropertyName("domain")] public string Domain { get; set; } = "";
[JsonPropertyName("generation_type")] public string GenerationType { get; set; } = "";
[JsonPropertyName("required_level")] public int RequiredLevel { get; set; }
[JsonPropertyName("groups")] public List<string> Groups { get; set; } = [];
[JsonPropertyName("spawn_weights")] public List<RePoESpawnWeight> SpawnWeights { get; set; } = [];
[JsonPropertyName("stats")] public List<RePoEStat> Stats { get; set; } = [];
[JsonPropertyName("text")] public string Text { get; set; } = "";
[JsonPropertyName("type")] public string Type { get; set; } = "";
[JsonPropertyName("is_essence_only")] public bool IsEssenceOnly { get; set; }
[JsonPropertyName("adds_tags")] public List<string> AddsTags { get; set; } = [];
[JsonPropertyName("implicit_tags")] public List<string> ImplicitTags { get; set; } = [];
}
public class RePoESpawnWeight
{
[JsonPropertyName("tag")] public string Tag { get; set; } = "";
[JsonPropertyName("weight")] public int Weight { get; set; }
}
public class RePoEStat
{
[JsonPropertyName("id")] public string Id { get; set; } = "";
[JsonPropertyName("min")] public int Min { get; set; }
[JsonPropertyName("max")] public int Max { get; set; }
}
public class RePoEBaseItem
{
[JsonPropertyName("name")] public string Name { get; set; } = "";
[JsonPropertyName("item_class")] public string ItemClass { get; set; } = "";
[JsonPropertyName("tags")] public List<string> Tags { get; set; } = [];
[JsonPropertyName("drop_level")] public int DropLevel { get; set; }
[JsonPropertyName("release_state")] public string ReleaseState { get; set; } = "";
[JsonPropertyName("domain")] public string Domain { get; set; } = "";
[JsonPropertyName("implicits")] public List<string> Implicits { get; set; } = [];
}
public class RePoEItemClass
{
[JsonPropertyName("name")] public string Name { get; set; } = "";
[JsonPropertyName("category")] public string? Category { get; set; }
[JsonPropertyName("category_id")] public string? CategoryId { get; set; }
}

View file

@ -164,6 +164,9 @@ public class CraftRecipe
public int ItemX { get; set; } public int ItemX { get; set; }
public int ItemY { get; set; } public int ItemY { get; set; }
public List<CraftStep> Steps { get; set; } = []; public List<CraftStep> Steps { get; set; } = [];
public string? BaseItemKey { get; set; }
public string? BaseItemName { get; set; }
public int ItemLevel { get; set; } = 83;
} }
public class CurrencyPosition public class CurrencyPosition

View file

@ -7,5 +7,7 @@
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\Automata.Core\Automata.Core.csproj" /> <ProjectReference Include="..\Automata.Core\Automata.Core.csproj" />
<ProjectReference Include="..\Automata.Game\Automata.Game.csproj" /> <ProjectReference Include="..\Automata.Game\Automata.Game.csproj" />
<ProjectReference Include="..\..\lib\Sidekick\src\Sidekick.Apis.Poe.Trade\Sidekick.Apis.Poe.Trade.csproj" />
<ProjectReference Include="..\..\lib\Sidekick\src\Sidekick.Apis.Poe\Sidekick.Apis.Poe.csproj" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View file

@ -1,12 +1,13 @@
using Automata.Core; using Automata.Core;
using Automata.Game; using Automata.Game;
using Sidekick.Apis.Poe.Items;
using Sidekick.Apis.Poe.Trade.Parser;
using Serilog; using Serilog;
namespace Automata.Items; namespace Automata.Items;
/// <summary> /// <summary>
/// Reads item data by hovering and pressing Ctrl+C to copy item text to clipboard. /// Reads item data by hovering and pressing Ctrl+C to copy item text to clipboard.
/// Will be wired up to Sidekick's ItemParser once the submodule is added.
/// </summary> /// </summary>
public class ItemReader public class ItemReader
{ {
@ -49,11 +50,21 @@ public class ItemReader
return text; return text;
} }
// TODO: Wire up Sidekick's ItemParser /// <summary>
// public async Task<ParsedItem?> ParseItem(int x, int y) /// Parse raw item text into a structured Sidekick Item.
// { /// Returns null if parsing fails.
// var text = await ReadItemText(x, y); /// </summary>
// if (text == null) return null; public static Item? ParseItemText(string text)
// return SidekickItemParser.Parse(text); {
// } try
{
var parser = SidekickBootstrapper.GetParser();
return parser.ParseItem(text);
}
catch (Exception ex)
{
Log.Warning(ex, "Failed to parse item text ({Length} chars)", text.Length);
return null;
}
}
} }

View file

@ -0,0 +1,71 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Sidekick.Apis.Common;
using Sidekick.Apis.Poe.Trade;
using Sidekick.Apis.Poe.Trade.Parser;
using Sidekick.Common;
using Sidekick.Common.Initialization;
using Sidekick.Common.Settings;
using Sidekick.Data;
using Serilog;
namespace Automata.Items;
/// <summary>
/// Bootstraps a minimal Sidekick DI container for item parsing.
/// </summary>
public static class SidekickBootstrapper
{
private static ServiceProvider? _provider;
public static async Task InitializeAsync()
{
var services = new ServiceCollection();
// Logging
services.AddLogging();
// Sidekick core
services.AddSidekickCommon(SidekickApplicationType.Unknown);
services.AddSidekickCommonApi();
services.AddSidekickData();
services.AddSidekickPoeTradeApi();
// Replace the real SettingsService (needs SQLite) with our stub
var existing = services.FirstOrDefault(d => d.ServiceType == typeof(ISettingsService));
if (existing != null) services.Remove(existing);
services.AddSingleton<ISettingsService, SidekickSettingsStub>();
_provider = services.BuildServiceProvider();
// Initialize all registered IInitializableService in priority order
var config = _provider.GetRequiredService<IOptions<SidekickConfiguration>>().Value;
var initServices = config.InitializableServices
.Select(type => (IInitializableService)_provider.GetRequiredService(type))
.OrderBy(s => s.Priority)
.ToList();
foreach (var svc in initServices)
{
try
{
await svc.Initialize();
}
catch (Exception ex)
{
Log.Warning(ex, "Sidekick service {Type} failed to initialize", svc.GetType().Name);
}
}
Log.Information("Sidekick bootstrapped: {Count} services initialized", initServices.Count);
}
public static T GetService<T>() where T : notnull
{
if (_provider == null)
throw new InvalidOperationException("SidekickBootstrapper.InitializeAsync() must be called first");
return _provider.GetRequiredService<T>();
}
public static IItemParser GetParser() => GetService<IItemParser>();
}

View file

@ -0,0 +1,56 @@
using Sidekick.Common.Settings;
namespace Automata.Items;
/// <summary>
/// Minimal ISettingsService returning defaults for Sidekick initialization.
/// Avoids SQLite/EF Core dependency.
/// </summary>
public class SidekickSettingsStub : ISettingsService
{
private readonly Dictionary<string, object?> _settings = new()
{
[SettingKeys.LeagueId] = "poe2.Standard",
[SettingKeys.LanguageParser] = "en",
[SettingKeys.LanguageUi] = "en",
};
public event Action<string[]>? OnSettingsChanged;
public Task<bool> GetBool(string key)
=> Task.FromResult(_settings.TryGetValue(key, out var v) && v is true);
public Task<string?> GetString(string key)
=> Task.FromResult(_settings.TryGetValue(key, out var v) ? v?.ToString() : null);
public Task<int> GetInt(string key)
=> Task.FromResult(_settings.TryGetValue(key, out var v) && v is int i ? i : 0);
public Task<double> GetDouble(string key)
=> Task.FromResult(_settings.TryGetValue(key, out var v) && v is double d ? d : 0.0);
public Task<DateTimeOffset?> GetDateTime(string key)
=> Task.FromResult<DateTimeOffset?>(null);
public Task<TEnum?> GetEnum<TEnum>(string key) where TEnum : struct, Enum
=> Task.FromResult<TEnum?>(default);
public Task<TValue?> GetObject<TValue>(string key, Func<TValue?> defaultFunc) where TValue : class
=> Task.FromResult(defaultFunc());
public Task Set(string key, object? value)
{
_settings[key] = value;
OnSettingsChanged?.Invoke([key]);
return Task.CompletedTask;
}
public Task<bool> IsSettingModified(params string[] keys)
=> Task.FromResult(false);
public Task DeleteSetting(params string[] keys)
{
foreach (var key in keys) _settings.Remove(key);
return Task.CompletedTask;
}
}

File diff suppressed because it is too large Load diff

View file

@ -14,23 +14,137 @@ public sealed class TerrainOffsets
public string ProcessName { get; set; } = "PathOfExileSteam"; public string ProcessName { get; set; } = "PathOfExileSteam";
/// <summary>Pattern to find GameState base pointer. Empty = unknown for POE2.</summary> /// <summary>Pattern to find GameState global. Use ^ to mark the RIP displacement position.</summary>
public string GameStatePattern { get; set; } = ""; public string GameStatePattern { get; set; } = "48 83 EC ?? 48 8B F1 33 ED 48 39 2D ^";
// Pointer chain: GameState → InGameState → IngameData → TerrainData /// <summary>Fallback: manual offset from module base to GameState global (hex). Used when pattern is empty or fails.</summary>
public int GameStateGlobalOffset { get; set; }
/// <summary>Bytes to add to pattern scan result to reach the actual global.</summary>
public int PatternResultAdjust { get; set; } = 0x18;
// ── GameState → States ──
// Dump: GameStateOffset { [0x08] StdVector CurrentStatePtr, [0x48] GameStateBuffer States }
// GameStateBuffer = StdTuple2D<IntPtr> (begin/end pair). Each entry is an IntPtr (8 bytes).
/// <summary>Offset to States begin/end pair in GameState (dump: 0x48).</summary>
public int StatesBeginOffset { get; set; } = 0x48;
/// <summary>Bytes per state entry (16 for inline slots, 8 for vector of pointers).</summary>
public int StateStride { get; set; } = 0x10;
/// <summary>Offset within each state entry to the actual state pointer.</summary>
public int StatePointerOffset { get; set; } = 0;
/// <summary>Which state index is InGameState (typically 4).</summary>
public int InGameStateIndex { get; set; } = 4;
/// <summary>If true, states are inline in the controller struct. If false, StatesBeginOffset points to a begin/end vector pair.</summary>
public bool StatesInline { get; set; } = true;
/// <summary>Direct offset from controller to InGameState pointer (bypasses state array). 0 = use state array instead. CE confirmed: 0x210.</summary>
public int InGameStateDirectOffset { get; set; } = 0x210;
// ── InGameState → sub-structures ──
// Dump: InGameStateOffset { [0x298] AreaInstanceData, [0x2F8] WorldData, [0x648] UiRootPtr, [0xC40] IngameUi }
/// <summary>InGameState → AreaInstance (IngameData) pointer (dump: 0x298, CE confirmed: 0x290).</summary>
public int IngameDataFromStateOffset { get; set; } = 0x290;
/// <summary>InGameState → WorldData pointer (dump: 0x2F8).</summary>
public int WorldDataFromStateOffset { get; set; } = 0x2F8;
// ── AreaInstance (IngameData) → sub-structures ──
// Dump: AreaInstanceOffsets {
// [0xAC] byte CurrentAreaLevel,
// [0xEC] uint CurrentAreaHash,
// [0x948] StdVector Environments,
// [0x9F0] LocalPlayerStruct PlayerInfo, ← contains ServerDataPtr at +0x0, LocalPlayerPtr at +0x20
// [0xAF8] EntityListStruct Entities, ← StdMap AwakeEntities + StdMap SleepingEntities
// [0xCC0] TerrainStruct TerrainMetadata ← inline, not behind pointer
// }
/// <summary>AreaInstance → CurrentAreaLevel (dump: byte at 0xAC, CE confirmed: byte at 0xC4).</summary>
public int AreaLevelOffset { get; set; } = 0xC4;
/// <summary>If true, AreaLevel is a byte. If false, read as int.</summary>
public bool AreaLevelIsByte { get; set; } = true;
/// <summary>Static offset from module base to cached area level int (CE: exe+3E84B78). 0 = disabled.</summary>
public int AreaLevelStaticOffset { get; set; } = 0;
/// <summary>AreaInstance → CurrentAreaHash uint (dump: 0xEC).</summary>
public int AreaHashOffset { get; set; } = 0xEC;
/// <summary>AreaInstance → ServerData pointer (dump: 0x9F0 via LocalPlayerStruct.ServerDataPtr).</summary>
public int ServerDataOffset { get; set; } = 0x9F0;
/// <summary>AreaInstance → LocalPlayer entity pointer (dump: 0x9F0+0x20 = 0xA10 via LocalPlayerStruct.LocalPlayerPtr).</summary>
public int LocalPlayerDirectOffset { get; set; } = 0xA10;
/// <summary>AreaInstance → EntityListStruct offset (dump: 0xAF8). Contains StdMap AwakeEntities then SleepingEntities.</summary>
public int EntityListOffset { get; set; } = 0xAF8;
/// <summary>Offset within StdMap to _Mysize (entity count). MSVC std::map: head(8) + size(8).</summary>
public int EntityCountInternalOffset { get; set; } = 0x08;
// ServerData → fields
/// <summary>ServerData → LocalPlayer entity pointer (fallback if LocalPlayerDirectOffset is 0).</summary>
public int LocalPlayerOffset { get; set; } = 0x20;
// ── Entity / Component ──
// Dump: ItemStruct { [0x0] VTablePtr, [0x8] EntityDetailsPtr, [0x10] StdVector ComponentListPtr }
// Dump: EntityOffsets { [0x0] ItemStruct, [0x80] uint Id, [0x84] byte IsValid }
public int ComponentListOffset { get; set; } = 0x10;
/// <summary>Entity → ObjectHeader pointer (for alternative component lookup via name→index map). ExileCore: 0x08.</summary>
public int EntityHeaderOffset { get; set; } = 0x08;
/// <summary>ObjectHeader → NativePtrArray for component name→index lookup. ExileCore: 0x40.</summary>
public int ComponentLookupOffset { get; set; } = 0x40;
/// <summary>Index of Life component in entity's component list. -1 = auto-discover via pattern scan.</summary>
public int LifeComponentIndex { get; set; } = -1;
/// <summary>Index of Render/Position component in entity's component list. -1 = unknown.</summary>
public int RenderComponentIndex { get; set; } = -1;
// ── Life component (via direct chain from AreaInstance) ──
// Deep scan confirmed: AreaInstance+0x420 → ptr+0x98 → Life component
// VitalStruct gaps match dump: HP→Mana = 0x50, Mana→ES = 0x38
// HP.Current@+0x188, Mana.Current@+0x1D8, ES.Current@+0x210
/// <summary>First offset from AreaInstance to reach Life component (AreaInstance → ptr). 0 = use entity component list instead.</summary>
public int LifeComponentOffset1 { get; set; } = 0x420;
/// <summary>Second offset from intermediate pointer to Life component (ptr → Life).</summary>
public int LifeComponentOffset2 { get; set; } = 0x98;
// VitalStruct offsets within Life component (VitalStruct base, add VitalCurrentOffset/VitalTotalOffset)
// ECS inner entity path: HP@+0x1D8 = 0x1A8+0x30, Mana@+0x228 = 0x1F8+0x30, ES@+0x260 = 0x230+0x30
// (shifted +0x50 from old direct chain due to inner entity component header)
public int LifeHealthOffset { get; set; } = 0x1A8;
public int LifeManaOffset { get; set; } = 0x1F8;
public int LifeEsOffset { get; set; } = 0x230;
public int VitalCurrentOffset { get; set; } = 0x30;
public int VitalTotalOffset { get; set; } = 0x2C;
// ── Render/Position component ──
// Scan confirmed: position float triplet at +0x138 in component [10] (with real Z height)
// Dump reference (older): RenderOffsets { [0xB0] CurrentWorldPosition } — shifted to 0x138 in current build
public int PositionXOffset { get; set; } = 0x138;
public int PositionYOffset { get; set; } = 0x13C;
public int PositionZOffset { get; set; } = 0x140;
// ── Terrain (inline in AreaInstance) ──
// Dump: TerrainStruct (at AreaInstance + 0xCC0) {
// [0x18] StdTuple2D<long> TotalTiles,
// [0x28] StdVector TileDetailsPtr,
// [0xD0] StdVector GridWalkableData,
// [0xE8] StdVector GridLandscapeData,
// [0x100] int BytesPerRow,
// [0x104] short TileHeightMultiplier
// }
/// <summary>Offset from AreaInstance to inline TerrainStruct (dump: 0xCC0).</summary>
public int TerrainListOffset { get; set; } = 0xCC0;
/// <summary>If true, terrain is inline in AreaInstance (no pointer dereference). If false, follow pointer.</summary>
public bool TerrainInline { get; set; } = true;
/// <summary>TerrainStruct → TotalTiles offset (dump: 0x18, StdTuple2D of long).</summary>
public int TerrainDimensionsOffset { get; set; } = 0x18;
/// <summary>TerrainStruct → GridWalkableData StdVector offset (dump: 0xD0).</summary>
public int TerrainWalkableGridOffset { get; set; } = 0xD0;
/// <summary>TerrainStruct → BytesPerRow (dump: 0x100).</summary>
public int TerrainBytesPerRowOffset { get; set; } = 0x100;
/// <summary>Kept for pointer-based terrain mode (TerrainInline=false).</summary>
public int TerrainGridPtrOffset { get; set; } = 0x08;
public int SubTilesPerCell { get; set; } = 23;
// Legacy terrain offsets (used by TerrainReader)
public int InGameStateOffset { get; set; } public int InGameStateOffset { get; set; }
public int IngameDataOffset { get; set; } public int IngameDataOffset { get; set; }
public int TerrainDataOffset { get; set; } public int TerrainDataOffset { get; set; }
// Within TerrainData struct
public int NumColsOffset { get; set; } public int NumColsOffset { get; set; }
public int NumRowsOffset { get; set; } public int NumRowsOffset { get; set; }
public int LayerMeleeOffset { get; set; } public int LayerMeleeOffset { get; set; }
public int BytesPerRowOffset { get; set; } public int BytesPerRowOffset { get; set; }
/// <summary>Number of sub-tiles per terrain cell (typically 23 for POE).</summary>
public int SubTilesPerCell { get; set; } = 23;
public static TerrainOffsets Load(string path) public static TerrainOffsets Load(string path)
{ {
if (!File.Exists(path)) if (!File.Exists(path))

View file

@ -15,5 +15,6 @@
<conv:CellBorderConverter x:Key="CellBorderConverter" /> <conv:CellBorderConverter x:Key="CellBorderConverter" />
<conv:BoolToOverlayBrushConverter x:Key="OccupiedOverlayBrush" /> <conv:BoolToOverlayBrushConverter x:Key="OccupiedOverlayBrush" />
<conv:MapRequirementsConverter x:Key="MapRequirementsText" /> <conv:MapRequirementsConverter x:Key="MapRequirementsText" />
<conv:MatchedModBrushConverter x:Key="MatchedModBrush" />
</Application.Resources> </Application.Resources>
</Application> </Application>

View file

@ -7,6 +7,7 @@ using Automata.Core;
using Automata.Game; using Automata.Game;
using Automata.GameLog; using Automata.GameLog;
using Automata.Inventory; using Automata.Inventory;
using Automata.Items;
using Automata.Screen; using Automata.Screen;
using Automata.Screen.Ocr; using Automata.Screen.Ocr;
using Automata.Trade; using Automata.Trade;
@ -27,6 +28,13 @@ public partial class App : Application
{ {
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{ {
// Initialize Sidekick parser (fire-and-forget, non-blocking)
_ = Task.Run(async () =>
{
try { await SidekickBootstrapper.InitializeAsync(); }
catch (Exception ex) { Serilog.Log.Warning(ex, "Sidekick init failed"); }
});
var services = new ServiceCollection(); var services = new ServiceCollection();
// Config // Config
@ -52,6 +60,7 @@ public partial class App : Application
services.AddSingleton<TradeExecutor>(); services.AddSingleton<TradeExecutor>();
services.AddSingleton<TradeQueue>(); services.AddSingleton<TradeQueue>();
services.AddSingleton<BotOrchestrator>(); services.AddSingleton<BotOrchestrator>();
services.AddSingleton<ModPoolService>();
// ViewModels // ViewModels
services.AddSingleton<MainWindowViewModel>(); services.AddSingleton<MainWindowViewModel>();
@ -60,11 +69,24 @@ public partial class App : Application
services.AddSingleton<MappingViewModel>(); services.AddSingleton<MappingViewModel>();
services.AddSingleton<AtlasViewModel>(); services.AddSingleton<AtlasViewModel>();
services.AddSingleton<CraftingViewModel>(); services.AddSingleton<CraftingViewModel>();
services.AddSingleton<MemoryViewModel>();
var provider = services.BuildServiceProvider(); var provider = services.BuildServiceProvider();
var store = provider.GetRequiredService<ConfigStore>(); var store = provider.GetRequiredService<ConfigStore>();
var bot = provider.GetRequiredService<BotOrchestrator>(); var bot = provider.GetRequiredService<BotOrchestrator>();
var modPool = provider.GetRequiredService<ModPoolService>();
// Fire-and-forget: load RePoE data, then populate CraftingVM base items
_ = Task.Run(async () =>
{
await modPool.LoadAsync();
Avalonia.Threading.Dispatcher.UIThread.Post(() =>
{
var craftVm = provider.GetRequiredService<CraftingViewModel>();
craftVm.LoadBaseItems();
});
});
var mainVm = provider.GetRequiredService<MainWindowViewModel>(); var mainVm = provider.GetRequiredService<MainWindowViewModel>();
mainVm.DebugVm = provider.GetRequiredService<DebugViewModel>(); mainVm.DebugVm = provider.GetRequiredService<DebugViewModel>();
@ -72,6 +94,7 @@ public partial class App : Application
mainVm.MappingVm = provider.GetRequiredService<MappingViewModel>(); mainVm.MappingVm = provider.GetRequiredService<MappingViewModel>();
mainVm.AtlasVm = provider.GetRequiredService<AtlasViewModel>(); mainVm.AtlasVm = provider.GetRequiredService<AtlasViewModel>();
mainVm.CraftingVm = provider.GetRequiredService<CraftingViewModel>(); mainVm.CraftingVm = provider.GetRequiredService<CraftingViewModel>();
mainVm.MemoryVm = provider.GetRequiredService<MemoryViewModel>();
var window = new MainWindow { DataContext = mainVm }; var window = new MainWindow { DataContext = mainVm };
window.SetConfigStore(store); window.SetConfigStore(store);

View file

@ -23,5 +23,16 @@
<ProjectReference Include="..\Automata.Trade\Automata.Trade.csproj" /> <ProjectReference Include="..\Automata.Trade\Automata.Trade.csproj" />
<ProjectReference Include="..\Automata.Log\Automata.Log.csproj" /> <ProjectReference Include="..\Automata.Log\Automata.Log.csproj" />
<ProjectReference Include="..\Automata.Inventory\Automata.Inventory.csproj" /> <ProjectReference Include="..\Automata.Inventory\Automata.Inventory.csproj" />
<ProjectReference Include="..\Automata.Memory\Automata.Memory.csproj" />
</ItemGroup>
<!-- Sidekick data files (English only) -->
<ItemGroup>
<Content Include="..\..\lib\Sidekick\data\poe2\ninja\**\*" Link="wwwroot\data\poe2\ninja\%(RecursiveDir)%(Filename)%(Extension)" CopyToOutputDirectory="PreserveNewest" />
<Content Include="..\..\lib\Sidekick\data\poe2\pseudo\en.json" Link="wwwroot\data\poe2\pseudo\en.json" CopyToOutputDirectory="PreserveNewest" />
<Content Include="..\..\lib\Sidekick\data\poe2\stats\en.json" Link="wwwroot\data\poe2\stats\en.json" CopyToOutputDirectory="PreserveNewest" />
<Content Include="..\..\lib\Sidekick\data\poe2\trade\leagues.invariant.json" Link="wwwroot\data\poe2\trade\leagues.invariant.json" CopyToOutputDirectory="PreserveNewest" />
<Content Include="..\..\lib\Sidekick\data\poe2\trade\stats.en.json" Link="wwwroot\data\poe2\trade\stats.en.json" CopyToOutputDirectory="PreserveNewest" />
<Content Include="..\..\lib\Sidekick\data\poe2\trade\stats.invariant.json" Link="wwwroot\data\poe2\trade\stats.invariant.json" CopyToOutputDirectory="PreserveNewest" />
<Content Include="..\..\lib\Sidekick\data\poe2\trade\raw\**\*.en.json" Link="wwwroot\data\poe2\trade\raw\%(Filename)%(Extension)" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View file

@ -130,3 +130,15 @@ public class CellBorderConverter : IValueConverter
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
=> throw new NotSupportedException(); => throw new NotSupportedException();
} }
public class MatchedModBrushConverter : IValueConverter
{
private static readonly SolidColorBrush MatchedBrush = new(Color.Parse("#3fb950"));
private static readonly SolidColorBrush UnmatchedBrush = new(Color.Parse("#e6edf3"));
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
=> value is true ? MatchedBrush : UnmatchedBrush;
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
=> throw new NotSupportedException();
}

View file

@ -1,9 +1,11 @@
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using System.IO;
using Avalonia.Threading; using Avalonia.Threading;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using Automata.Bot; using Automata.Bot;
using Automata.Core; using Automata.Core;
using Automata.Items;
using Serilog; using Serilog;
namespace Automata.Ui.ViewModels; namespace Automata.Ui.ViewModels;
@ -14,18 +16,18 @@ public partial class CraftStepViewModel : ObservableObject
private readonly Action _onChanged; private readonly Action _onChanged;
[ObservableProperty] private string _label; [ObservableProperty] private string _label;
[ObservableProperty] private string _selectedCurrency; [ObservableProperty] private CurrencyPositionViewModel? _selectedCurrency;
[ObservableProperty] private string _requiredModsText; [ObservableProperty] private string _requiredModsText;
[ObservableProperty] private bool _matchAll; [ObservableProperty] private bool _matchAll;
[ObservableProperty] private decimal? _maxAttempts; [ObservableProperty] private decimal? _maxAttempts;
[ObservableProperty] private decimal? _onFailGoTo; [ObservableProperty] private decimal? _onFailGoTo;
public CraftStepViewModel(CraftStep model, Action onChanged) public CraftStepViewModel(CraftStep model, ObservableCollection<CurrencyPositionViewModel> currencyItems, Action onChanged)
{ {
_model = model; _model = model;
_onChanged = onChanged; _onChanged = onChanged;
_label = model.Label; _label = model.Label;
_selectedCurrency = model.CurrencyName; _selectedCurrency = currencyItems.FirstOrDefault(c => c.Name == model.CurrencyName);
_requiredModsText = string.Join(", ", model.RequiredMods); _requiredModsText = string.Join(", ", model.RequiredMods);
_matchAll = model.MatchAll; _matchAll = model.MatchAll;
_maxAttempts = model.MaxAttempts; _maxAttempts = model.MaxAttempts;
@ -33,7 +35,7 @@ public partial class CraftStepViewModel : ObservableObject
} }
partial void OnLabelChanged(string value) { _model.Label = value; _onChanged(); } partial void OnLabelChanged(string value) { _model.Label = value; _onChanged(); }
partial void OnSelectedCurrencyChanged(string value) { _model.CurrencyName = value ?? ""; _onChanged(); } partial void OnSelectedCurrencyChanged(CurrencyPositionViewModel? value) { _model.CurrencyName = value?.Name ?? ""; _onChanged(); }
partial void OnMatchAllChanged(bool value) { _model.MatchAll = value; _onChanged(); } partial void OnMatchAllChanged(bool value) { _model.MatchAll = value; _onChanged(); }
partial void OnMaxAttemptsChanged(decimal? value) { _model.MaxAttempts = (int)(value ?? 500); _onChanged(); } partial void OnMaxAttemptsChanged(decimal? value) { _model.MaxAttempts = (int)(value ?? 500); _onChanged(); }
partial void OnOnFailGoToChanged(decimal? value) { _model.OnFailGoTo = value.HasValue ? (int)value.Value : null; _onChanged(); } partial void OnOnFailGoToChanged(decimal? value) { _model.OnFailGoTo = value.HasValue ? (int)value.Value : null; _onChanged(); }
@ -74,6 +76,7 @@ public partial class CraftRecipeViewModel : ObservableObject
public partial class CraftingViewModel : ObservableObject public partial class CraftingViewModel : ObservableObject
{ {
private readonly BotOrchestrator _bot; private readonly BotOrchestrator _bot;
private readonly ModPoolService _modPool;
private CraftingExecutor? _executor; private CraftingExecutor? _executor;
public ObservableCollection<CraftRecipeViewModel> Crafts { get; } = []; public ObservableCollection<CraftRecipeViewModel> Crafts { get; } = [];
@ -83,7 +86,7 @@ public partial class CraftingViewModel : ObservableObject
private CraftRecipeViewModel? _selectedCraft; private CraftRecipeViewModel? _selectedCraft;
public ObservableCollection<CraftStepViewModel> Steps { get; } = []; public ObservableCollection<CraftStepViewModel> Steps { get; } = [];
public ObservableCollection<string> CurrencyNames { get; } = []; public ObservableCollection<CurrencyPositionViewModel> CurrencyItems { get; } = [];
[ObservableProperty] private string _craftState = "Idle"; [ObservableProperty] private string _craftState = "Idle";
[ObservableProperty] private int _currentStepIndex; [ObservableProperty] private int _currentStepIndex;
@ -92,12 +95,25 @@ public partial class CraftingViewModel : ObservableObject
[ObservableProperty] private string? _lastItemText; [ObservableProperty] private string? _lastItemText;
[ObservableProperty] private string _itemPositionText = ""; [ObservableProperty] private string _itemPositionText = "";
// Parsed mods display
public ObservableCollection<ParsedModViewModel> ParsedMods { get; } = [];
// Mod pool
public ObservableCollection<BaseItemOption> BaseItems { get; } = [];
[ObservableProperty] private BaseItemOption? _selectedBaseItem;
[ObservableProperty] private decimal _itemLevel = 83;
public ObservableCollection<AvailableModViewModel> AvailableMods { get; } = [];
[ObservableProperty] private string _modFilterText = "";
private List<AvailableMod> _allMods = [];
public bool HasSelectedCraft => SelectedCraft != null; public bool HasSelectedCraft => SelectedCraft != null;
public CraftingViewModel(BotOrchestrator bot) public CraftingViewModel(BotOrchestrator bot, ModPoolService modPool)
{ {
_bot = bot; _bot = bot;
RefreshCurrencyNames(); _modPool = modPool;
RefreshCurrencyItems();
// Load from config // Load from config
foreach (var recipe in bot.Config.Crafts) foreach (var recipe in bot.Config.Crafts)
@ -105,15 +121,41 @@ public partial class CraftingViewModel : ObservableObject
if (Crafts.Count > 0) if (Crafts.Count > 0)
SelectedCraft = Crafts[0]; SelectedCraft = Crafts[0];
// Load base items when data is ready
if (_modPool.IsLoaded)
LoadBaseItems();
} }
public void RefreshCurrencyNames() public void LoadBaseItems()
{ {
CurrencyNames.Clear(); BaseItems.Clear();
foreach (var b in _modPool.GetCraftableBases())
BaseItems.Add(b);
// Restore selection from current craft
if (SelectedCraft?.Model.BaseItemKey is { } key)
SelectedBaseItem = BaseItems.FirstOrDefault(b => b.Key == key);
}
public void RefreshCurrencyItems()
{
CurrencyItems.Clear();
var iconDir = Path.GetFullPath("assets/currency/currency");
foreach (var cp in _bot.Config.CurrencyPositions) foreach (var cp in _bot.Config.CurrencyPositions)
{ {
if (cp.Name == "Craft Item") continue; if (cp.Name == "Craft Item") continue;
CurrencyNames.Add(cp.Name); var vm = new CurrencyPositionViewModel(cp, () => { });
if (!string.IsNullOrEmpty(cp.ApiId))
{
var cached = Path.Combine(iconDir, cp.ApiId + ".png");
if (File.Exists(cached))
{
try { vm.IconBitmap = new Avalonia.Media.Imaging.Bitmap(cached); }
catch { /* ignore corrupt files */ }
}
}
CurrencyItems.Add(vm);
} }
UpdateItemPositionText(); UpdateItemPositionText();
} }
@ -133,7 +175,81 @@ public partial class CraftingViewModel : ObservableObject
if (value == null) return; if (value == null) return;
foreach (var step in value.Model.Steps) foreach (var step in value.Model.Steps)
Steps.Add(new CraftStepViewModel(step, SaveConfig)); Steps.Add(new CraftStepViewModel(step, CurrencyItems, SaveConfig));
// Restore base/ilvl from saved recipe
if (value.Model.BaseItemKey is { } key)
SelectedBaseItem = BaseItems.FirstOrDefault(b => b.Key == key);
else
SelectedBaseItem = null;
ItemLevel = value.Model.ItemLevel;
}
partial void OnSelectedBaseItemChanged(BaseItemOption? value)
{
if (SelectedCraft != null)
{
SelectedCraft.Model.BaseItemKey = value?.Key;
SelectedCraft.Model.BaseItemName = value?.Name;
SaveConfig();
}
RefreshModPool();
}
partial void OnItemLevelChanged(decimal value)
{
if (SelectedCraft != null)
{
SelectedCraft.Model.ItemLevel = (int)value;
SaveConfig();
}
RefreshModPool();
}
partial void OnModFilterTextChanged(string value)
{
ApplyModFilter();
}
private void RefreshModPool()
{
AvailableMods.Clear();
_allMods.Clear();
if (SelectedBaseItem == null || !_modPool.IsLoaded) return;
_allMods = _modPool.GetAvailableMods(SelectedBaseItem.Key, (int)ItemLevel);
ApplyModFilter();
}
private void ApplyModFilter()
{
AvailableMods.Clear();
var filter = ModFilterText?.Trim() ?? "";
foreach (var mod in _allMods)
{
if (filter.Length > 0 &&
!mod.Text.Contains(filter, StringComparison.OrdinalIgnoreCase) &&
!mod.Name.Contains(filter, StringComparison.OrdinalIgnoreCase))
continue;
AvailableMods.Add(new AvailableModViewModel(mod));
}
}
[RelayCommand]
private void AddModToStep(AvailableModViewModel? modVm)
{
if (modVm == null || Steps.Count == 0) return;
// Add to the last step's required mods
var lastStep = Steps[^1];
var existing = lastStep.RequiredModsText;
var modText = modVm.Text;
lastStep.RequiredModsText = string.IsNullOrWhiteSpace(existing)
? modText
: $"{existing}, {modText}";
} }
[RelayCommand] [RelayCommand]
@ -163,7 +279,7 @@ public partial class CraftingViewModel : ObservableObject
if (SelectedCraft == null) return; if (SelectedCraft == null) return;
var step = new CraftStep { Label = $"Step {SelectedCraft.Model.Steps.Count + 1}" }; var step = new CraftStep { Label = $"Step {SelectedCraft.Model.Steps.Count + 1}" };
SelectedCraft.Model.Steps.Add(step); SelectedCraft.Model.Steps.Add(step);
Steps.Add(new CraftStepViewModel(step, SaveConfig)); Steps.Add(new CraftStepViewModel(step, CurrencyItems, SaveConfig));
SaveConfig(); SaveConfig();
} }
@ -235,11 +351,49 @@ public partial class CraftingViewModel : ObservableObject
CurrentAttempt = _executor.CurrentAttempt; CurrentAttempt = _executor.CurrentAttempt;
TotalAttempts = _executor.TotalAttempts; TotalAttempts = _executor.TotalAttempts;
LastItemText = _executor.LastItemText; LastItemText = _executor.LastItemText;
UpdateParsedMods(_executor.LastItemText);
} }
_bot.UpdateExecutorState(); _bot.UpdateExecutorState();
}); });
} }
private void UpdateParsedMods(string? itemText)
{
ParsedMods.Clear();
if (string.IsNullOrWhiteSpace(itemText)) return;
try
{
var item = ItemReader.ParseItemText(itemText);
if (item == null) return;
// Get current step's required mods for matching
var requiredMods = new List<string>();
if (_executor != null && _executor.CurrentStepIndex < Steps.Count)
{
var stepVm = Steps[_executor.CurrentStepIndex];
if (!string.IsNullOrWhiteSpace(stepVm.RequiredModsText))
{
requiredMods = stepVm.RequiredModsText
.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)
.ToList();
}
}
foreach (var stat in item.Stats)
{
var category = ParsedModViewModel.CategoryFromStatCategory(stat.Category);
var isMatched = requiredMods.Any(mod =>
stat.Text.Contains(mod, StringComparison.OrdinalIgnoreCase));
ParsedMods.Add(new ParsedModViewModel(category, stat.Text, isMatched));
}
}
catch (Exception ex)
{
Log.Debug(ex, "Failed to parse item mods for display");
}
}
private void SaveConfig() private void SaveConfig()
{ {
_bot.Store.Save(); _bot.Store.Save();

View file

@ -182,6 +182,7 @@ public partial class MainWindowViewModel : ObservableObject
public MappingViewModel? MappingVm { get; set; } public MappingViewModel? MappingVm { get; set; }
public AtlasViewModel? AtlasVm { get; set; } public AtlasViewModel? AtlasVm { get; set; }
public CraftingViewModel? CraftingVm { get; set; } public CraftingViewModel? CraftingVm { get; set; }
public MemoryViewModel? MemoryVm { get; set; }
partial void OnBotModeChanged(BotMode value) partial void OnBotModeChanged(BotMode value)
{ {

View file

@ -0,0 +1,428 @@
using System.Collections.ObjectModel;
using Avalonia.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Automata.Memory;
namespace Automata.Ui.ViewModels;
public partial class MemoryNodeViewModel : ObservableObject
{
[ObservableProperty] private string _name;
[ObservableProperty] private string _value = "";
[ObservableProperty] private string _valueColor = "#484f58";
[ObservableProperty] private bool _isExpanded = true;
public ObservableCollection<MemoryNodeViewModel> Children { get; } = [];
public MemoryNodeViewModel(string name)
{
_name = name;
}
public void Set(string value, bool valid = true)
{
Value = value;
ValueColor = valid ? "#3fb950" : "#484f58";
}
}
public partial class MemoryViewModel : ObservableObject
{
private GameMemoryReader? _reader;
private CancellationTokenSource? _cts;
[ObservableProperty] private bool _isEnabled;
[ObservableProperty] private string _statusText = "Not attached";
public ObservableCollection<MemoryNodeViewModel> RootNodes { get; } = [];
// Raw explorer
[ObservableProperty] private string _rawAddress = "";
[ObservableProperty] private string _rawOffsets = "";
[ObservableProperty] private string _rawType = "pointer";
[ObservableProperty] private string _rawResult = "";
// Scan
[ObservableProperty] private string _scanAddress = "";
[ObservableProperty] private string _scanOffsets = "";
[ObservableProperty] private string _scanSize = "400";
[ObservableProperty] private string _scanResult = "";
// Component scan vitals
[ObservableProperty] private string _vitalHp = "";
[ObservableProperty] private string _vitalMana = "";
[ObservableProperty] private string _vitalEs = "";
public static string[] RawTypes { get; } = ["int32", "int64", "float", "double", "pointer", "bytes16", "string"];
// Tree node references
private MemoryNodeViewModel? _processStatus;
private MemoryNodeViewModel? _processPid;
private MemoryNodeViewModel? _processModule;
private MemoryNodeViewModel? _gsPattern;
private MemoryNodeViewModel? _gsBase;
private MemoryNodeViewModel? _gsController;
private MemoryNodeViewModel? _gsStates;
private MemoryNodeViewModel? _inGameState;
private MemoryNodeViewModel? _areaInstance;
private MemoryNodeViewModel? _areaLevel;
private MemoryNodeViewModel? _areaHash;
private MemoryNodeViewModel? _serverData;
private MemoryNodeViewModel? _localPlayer;
private MemoryNodeViewModel? _entityCount;
private MemoryNodeViewModel? _playerPos;
private MemoryNodeViewModel? _playerLife;
private MemoryNodeViewModel? _playerMana;
private MemoryNodeViewModel? _playerEs;
private MemoryNodeViewModel? _terrainCells;
private MemoryNodeViewModel? _terrainGrid;
partial void OnIsEnabledChanged(bool value)
{
if (value)
Enable();
else
Disable();
}
private void Enable()
{
_reader = new GameMemoryReader();
var attached = _reader.Attach();
if (attached)
{
var snap = _reader.ReadSnapshot();
StatusText = $"Attached (PID {snap.ProcessId})";
}
else
{
StatusText = "Process not found";
}
BuildTree();
_cts = new CancellationTokenSource();
_ = ReadLoop(_cts.Token);
}
private void Disable()
{
_cts?.Cancel();
_cts = null;
_reader?.Dispose();
_reader = null;
RootNodes.Clear();
StatusText = "Not attached";
}
private void BuildTree()
{
RootNodes.Clear();
// Process
var process = new MemoryNodeViewModel("Process");
_processStatus = new MemoryNodeViewModel("Status:");
_processPid = new MemoryNodeViewModel("PID:");
_processModule = new MemoryNodeViewModel("Module:");
process.Children.Add(_processStatus);
process.Children.Add(_processPid);
process.Children.Add(_processModule);
// GameState
var gameState = new MemoryNodeViewModel("GameState");
_gsPattern = new MemoryNodeViewModel("Pattern:");
_gsBase = new MemoryNodeViewModel("Base:");
_gsController = new MemoryNodeViewModel("Controller:");
_gsStates = new MemoryNodeViewModel("States:");
_inGameState = new MemoryNodeViewModel("InGameState:");
gameState.Children.Add(_gsPattern);
gameState.Children.Add(_gsBase);
gameState.Children.Add(_gsController);
gameState.Children.Add(_gsStates);
gameState.Children.Add(_inGameState);
// InGameState children
var inGameStateGroup = new MemoryNodeViewModel("InGameState");
// AreaInstance (primary data source in POE2)
var areaInstanceGroup = new MemoryNodeViewModel("AreaInstance");
_areaInstance = new MemoryNodeViewModel("Ptr:");
_areaLevel = new MemoryNodeViewModel("AreaLevel:");
_areaHash = new MemoryNodeViewModel("AreaHash:");
_serverData = new MemoryNodeViewModel("ServerData:");
_localPlayer = new MemoryNodeViewModel("LocalPlayer:");
_entityCount = new MemoryNodeViewModel("Entities:");
areaInstanceGroup.Children.Add(_areaInstance);
areaInstanceGroup.Children.Add(_areaLevel);
areaInstanceGroup.Children.Add(_areaHash);
areaInstanceGroup.Children.Add(_serverData);
areaInstanceGroup.Children.Add(_localPlayer);
areaInstanceGroup.Children.Add(_entityCount);
// Player
var player = new MemoryNodeViewModel("Player");
_playerPos = new MemoryNodeViewModel("Position:") { Value = "?", ValueColor = "#484f58" };
_playerLife = new MemoryNodeViewModel("Life:") { Value = "?", ValueColor = "#484f58" };
_playerMana = new MemoryNodeViewModel("Mana:") { Value = "?", ValueColor = "#484f58" };
_playerEs = new MemoryNodeViewModel("ES:") { Value = "?", ValueColor = "#484f58" };
player.Children.Add(_playerPos);
player.Children.Add(_playerLife);
player.Children.Add(_playerMana);
player.Children.Add(_playerEs);
// Terrain
var terrain = new MemoryNodeViewModel("Terrain");
_terrainCells = new MemoryNodeViewModel("Cells:");
_terrainGrid = new MemoryNodeViewModel("Grid:");
terrain.Children.Add(_terrainCells);
terrain.Children.Add(_terrainGrid);
inGameStateGroup.Children.Add(areaInstanceGroup);
inGameStateGroup.Children.Add(player);
inGameStateGroup.Children.Add(terrain);
RootNodes.Add(process);
RootNodes.Add(gameState);
RootNodes.Add(inGameStateGroup);
}
private async Task ReadLoop(CancellationToken ct)
{
using var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(500));
while (!ct.IsCancellationRequested)
{
try
{
if (!await timer.WaitForNextTickAsync(ct))
break;
}
catch (OperationCanceledException) { break; }
if (_reader is null) break;
var snap = _reader.ReadSnapshot();
Dispatcher.UIThread.Post(() => UpdateTree(snap));
}
}
private void UpdateTree(GameStateSnapshot snap)
{
if (_processStatus is null) return;
// Process
_processStatus.Set(snap.Attached ? "Attached" : "Not found", snap.Attached);
_processPid!.Set(snap.Attached ? snap.ProcessId.ToString() : "—", snap.Attached);
_processModule!.Set(
snap.Attached ? $"0x{snap.ModuleBase:X} ({snap.ModuleSize:N0} bytes)" : "—",
snap.Attached);
// GameState
_gsPattern!.Set(snap.OffsetsConfigured ? "configured" : "not configured", snap.OffsetsConfigured);
_gsBase!.Set(
snap.GameStateBase != 0 ? $"0x{snap.GameStateBase:X}" : "not resolved",
snap.GameStateBase != 0);
_gsController!.Set(
snap.ControllerPtr != 0 ? $"0x{snap.ControllerPtr:X}" : "—",
snap.ControllerPtr != 0);
_gsStates!.Set(
snap.StatesCount > 0 ? snap.StatesCount.ToString() : "—",
snap.StatesCount > 0);
_inGameState!.Set(
snap.InGameStatePtr != 0 ? $"0x{snap.InGameStatePtr:X}" : "not found",
snap.InGameStatePtr != 0);
// Status text
if (snap.Attached)
StatusText = snap.InGameStatePtr != 0
? $"Attached (PID {snap.ProcessId}) — InGame"
: $"Attached (PID {snap.ProcessId})";
else if (snap.Error is not null)
StatusText = $"Error: {snap.Error}";
// AreaInstance
_areaInstance!.Set(
snap.AreaInstancePtr != 0 ? $"0x{snap.AreaInstancePtr:X}" : "—",
snap.AreaInstancePtr != 0);
_areaLevel!.Set(
snap.AreaLevel > 0 ? snap.AreaLevel.ToString() : "—",
snap.AreaLevel > 0);
_areaHash!.Set(
snap.AreaHash != 0 ? $"0x{snap.AreaHash:X8}" : "—",
snap.AreaHash != 0);
_serverData!.Set(
snap.ServerDataPtr != 0 ? $"0x{snap.ServerDataPtr:X}" : "—",
snap.ServerDataPtr != 0);
_localPlayer!.Set(
snap.LocalPlayerPtr != 0 ? $"0x{snap.LocalPlayerPtr:X}" : "—",
snap.LocalPlayerPtr != 0);
_entityCount!.Set(
snap.EntityCount > 0 ? snap.EntityCount.ToString() : "—",
snap.EntityCount > 0);
// Player position
if (snap.HasPosition)
_playerPos!.Set($"({snap.PlayerX:F1}, {snap.PlayerY:F1}, {snap.PlayerZ:F1})");
else
_playerPos!.Set("? (no Render component found)", false);
// Player vitals
if (snap.HasVitals)
{
_playerLife!.Set($"{snap.LifeCurrent} / {snap.LifeTotal}");
_playerMana!.Set($"{snap.ManaCurrent} / {snap.ManaTotal}");
_playerEs!.Set($"{snap.EsCurrent} / {snap.EsTotal}");
}
else
{
_playerLife!.Set("? (set LifeComponentIndex)", false);
_playerMana!.Set("? (set LifeComponentIndex)", false);
_playerEs!.Set("? (set LifeComponentIndex)", false);
}
// Terrain
if (snap.TerrainCols > 0 && snap.TerrainRows > 0)
{
_terrainCells!.Set($"{snap.TerrainCols}x{snap.TerrainRows}");
_terrainGrid!.Set($"{snap.TerrainWidth}x{snap.TerrainHeight}");
}
else
{
_terrainCells!.Set("?", false);
_terrainGrid!.Set("?", false);
}
}
[RelayCommand]
private void ReadAddressExecute()
{
if (_reader is null || !_reader.IsAttached)
{
RawResult = "Error: not attached";
return;
}
RawResult = _reader.ReadAddress(RawAddress, RawOffsets, RawType);
}
[RelayCommand]
private void ScanExecute()
{
if (_reader is null || !_reader.IsAttached)
{
ScanResult = "Error: not attached";
return;
}
if (!int.TryParse(ScanSize, System.Globalization.NumberStyles.HexNumber, null, out var size))
size = 0x400;
ScanResult = _reader.ScanRegion(ScanAddress, ScanOffsets, size);
}
[RelayCommand]
private void ScanStatesExecute()
{
if (_reader is null || !_reader.IsAttached)
{
ScanResult = "Error: not attached";
return;
}
ScanResult = _reader.ScanAllStates();
}
[RelayCommand]
private void ProbeExecute()
{
if (_reader is null || !_reader.IsAttached)
{
ScanResult = "Error: not attached";
return;
}
ScanResult = _reader.ProbeInGameState();
}
[RelayCommand]
private void ScanComponentsExecute()
{
if (_reader is null || !_reader.IsAttached)
{
ScanResult = "Error: not attached";
return;
}
int.TryParse(VitalHp, out var hp);
int.TryParse(VitalMana, out var mana);
int.TryParse(VitalEs, out var es);
ScanResult = _reader.ScanComponents(hp, mana, es);
}
[RelayCommand]
private void DeepScanExecute()
{
if (_reader is null || !_reader.IsAttached)
{
ScanResult = "Error: not attached";
return;
}
int.TryParse(VitalHp, out var hp);
int.TryParse(VitalMana, out var mana);
int.TryParse(VitalEs, out var es);
ScanResult = _reader.DeepScanVitals(hp, mana, es);
}
[RelayCommand]
private void DiagnoseVitalsExecute()
{
if (_reader is null || !_reader.IsAttached)
{
ScanResult = "Error: not attached";
return;
}
ScanResult = _reader.DiagnoseVitals();
}
[RelayCommand]
private void ScanPositionExecute()
{
if (_reader is null || !_reader.IsAttached)
{
ScanResult = "Error: not attached";
return;
}
ScanResult = _reader.ScanPosition();
}
[RelayCommand]
private void DiagnoseEntityExecute()
{
if (_reader is null || !_reader.IsAttached)
{
ScanResult = "Error: not attached";
return;
}
ScanResult = _reader.DiagnoseEntity();
}
[RelayCommand]
private void ScanStructureExecute()
{
if (_reader is null || !_reader.IsAttached)
{
ScanResult = "Error: not attached";
return;
}
if (!int.TryParse(ScanSize, System.Globalization.NumberStyles.HexNumber, null, out var size))
size = 0x2000;
ScanResult = _reader.ScanStructure(ScanAddress, ScanOffsets, size);
}
}

View file

@ -0,0 +1,28 @@
using CommunityToolkit.Mvvm.ComponentModel;
using Automata.Core;
namespace Automata.Ui.ViewModels;
public partial class AvailableModViewModel : ObservableObject
{
public string ModId { get; }
public string Name { get; }
public string Text { get; }
public string GenerationType { get; }
public int RequiredLevel { get; }
public string StatRange { get; }
public string TypeTag { get; }
public string TypeColor { get; }
public AvailableModViewModel(AvailableMod mod)
{
ModId = mod.ModId;
Name = mod.Name;
Text = mod.Text;
GenerationType = mod.GenerationType;
RequiredLevel = mod.RequiredLevel;
StatRange = mod.StatRange;
TypeTag = mod.GenerationType == "prefix" ? "P" : "S";
TypeColor = mod.GenerationType == "prefix" ? "#58a6ff" : "#bc8cff";
}
}

View file

@ -0,0 +1,46 @@
using CommunityToolkit.Mvvm.ComponentModel;
using Sidekick.Data.Items;
namespace Automata.Ui.ViewModels;
public partial class ParsedModViewModel : ObservableObject
{
public string Category { get; }
public string Text { get; }
public string CategoryColor { get; }
[ObservableProperty] private bool _isMatched;
public ParsedModViewModel(string category, string text, bool isMatched)
{
Category = category;
Text = text;
_isMatched = isMatched;
CategoryColor = CategoryToColor(category);
}
public static string CategoryToColor(string category) => category switch
{
"Explicit" => "#58a6ff",
"Implicit" => "#bc8cff",
"Crafted" => "#b4f5fc",
"Fractured" => "#d2a064",
"Enchant" => "#b4f5fc",
"Rune" => "#7ce0a6",
"Corrupted" => "#d73a4a",
_ => "#8b949e",
};
public static string CategoryFromStatCategory(StatCategory cat) => cat switch
{
StatCategory.Explicit => "Explicit",
StatCategory.Implicit => "Implicit",
StatCategory.Crafted => "Crafted",
StatCategory.Fractured => "Fractured",
StatCategory.Enchant => "Enchant",
StatCategory.Rune => "Rune",
StatCategory.Corrupted => "Corrupted",
StatCategory.Delve => "Explicit",
StatCategory.Sanctum => "Explicit",
_ => cat.ToString(),
};
}

View file

@ -339,6 +339,21 @@
FontSize="10" Foreground="#484f58" FontSize="10" Foreground="#484f58"
VerticalAlignment="Center" Margin="8,0,0,0" /> VerticalAlignment="Center" Margin="8,0,0,0" />
</StackPanel> </StackPanel>
<StackPanel Orientation="Horizontal" Spacing="10">
<TextBlock Text="Base" FontSize="12" Foreground="#8b949e"
Width="80" VerticalAlignment="Center" />
<AutoCompleteBox ItemsSource="{Binding BaseItems}"
SelectedItem="{Binding SelectedBaseItem}"
FilterMode="ContainsOrdinal"
MinimumPrefixLength="0"
Watermark="Search base type..."
MinWidth="240" MaxDropDownHeight="300" />
<TextBlock Text="iLvl" FontSize="12" Foreground="#8b949e"
VerticalAlignment="Center" Margin="8,0,0,0" />
<NumericUpDown Value="{Binding ItemLevel}"
Minimum="1" Maximum="100"
Width="90" />
</StackPanel>
</StackPanel> </StackPanel>
<!-- Bottom: status + controls --> <!-- Bottom: status + controls -->
@ -357,10 +372,32 @@
FontSize="13" Foreground="#484f58" FontSize="13" Foreground="#484f58"
VerticalAlignment="Center" /> VerticalAlignment="Center" />
</StackPanel> </StackPanel>
<!-- Parsed mods (color-coded); fallback to raw text -->
<ItemsControl ItemsSource="{Binding ParsedMods}"
IsVisible="{Binding ParsedMods.Count}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:ParsedModViewModel">
<StackPanel Orientation="Horizontal" Spacing="4" Margin="0,1">
<Border Background="{Binding CategoryColor}"
CornerRadius="3" Padding="3,1"
VerticalAlignment="Center" MinWidth="18"
HorizontalAlignment="Center">
<TextBlock Text="{Binding Category}"
FontSize="8" FontWeight="Bold"
Foreground="#0d1117"
HorizontalAlignment="Center" />
</Border>
<TextBlock Text="{Binding Text}" FontSize="11"
Foreground="{Binding IsMatched, Converter={StaticResource MatchedModBrush}}" />
</StackPanel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<TextBlock Text="{Binding LastItemText}" <TextBlock Text="{Binding LastItemText}"
FontSize="11" FontFamily="Consolas" FontSize="11" FontFamily="Consolas"
Foreground="#8b949e" TextTrimming="CharacterEllipsis" Foreground="#8b949e" TextTrimming="CharacterEllipsis"
MaxLines="3" TextWrapping="Wrap" /> MaxLines="3" TextWrapping="Wrap"
IsVisible="{Binding !ParsedMods.Count}" />
<StackPanel Orientation="Horizontal" Spacing="8" Margin="0,4,0,0"> <StackPanel Orientation="Horizontal" Spacing="8" Margin="0,4,0,0">
<Button Content="Start" Command="{Binding StartCraftCommand}" <Button Content="Start" Command="{Binding StartCraftCommand}"
Padding="16,6" /> Padding="16,6" />
@ -369,8 +406,10 @@
</StackPanel> </StackPanel>
</StackPanel> </StackPanel>
<!-- Steps + Mod Pool -->
<Grid RowDefinitions="*,Auto,*">
<!-- Steps list --> <!-- Steps list -->
<DockPanel> <DockPanel Grid.Row="0">
<StackPanel DockPanel.Dock="Top" Orientation="Horizontal" <StackPanel DockPanel.Dock="Top" Orientation="Horizontal"
Spacing="6" Margin="0,0,0,6"> Spacing="6" Margin="0,0,0,6">
<TextBlock Text="STEPS" FontSize="11" FontWeight="SemiBold" <TextBlock Text="STEPS" FontSize="11" FontWeight="SemiBold"
@ -408,9 +447,19 @@
Foreground="#8b949e" Foreground="#8b949e"
VerticalAlignment="Center" VerticalAlignment="Center"
Width="55" /> Width="55" />
<ComboBox ItemsSource="{ReflectionBinding #StepsControl.DataContext.CurrencyNames}" <ComboBox ItemsSource="{ReflectionBinding #StepsControl.DataContext.CurrencyItems}"
SelectedItem="{Binding SelectedCurrency}" SelectedItem="{Binding SelectedCurrency}"
MinWidth="180" /> MinWidth="180">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:CurrencyPositionViewModel">
<StackPanel Orientation="Horizontal" Spacing="4">
<Image Source="{Binding IconBitmap}" Width="16" Height="16"
IsVisible="{Binding IconBitmap, Converter={x:Static ObjectConverters.IsNotNull}}" />
<TextBlock Text="{Binding Name}" FontSize="11" Foreground="#e6edf3" />
</StackPanel>
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
</StackPanel> </StackPanel>
<DockPanel> <DockPanel>
<TextBlock Text="Until" FontSize="11" <TextBlock Text="Until" FontSize="11"
@ -443,6 +492,50 @@
</ItemsControl> </ItemsControl>
</ScrollViewer> </ScrollViewer>
</DockPanel> </DockPanel>
<!-- Separator -->
<Border Grid.Row="1" Height="1" Background="#30363d" Margin="0,6" />
<!-- Mod Pool -->
<DockPanel Grid.Row="2">
<StackPanel DockPanel.Dock="Top" Orientation="Horizontal"
Spacing="6" Margin="0,0,0,6">
<TextBlock Text="MOD POOL" FontSize="11" FontWeight="SemiBold"
Foreground="#8b949e" VerticalAlignment="Center" />
<TextBox Text="{Binding ModFilterText}" Watermark="Filter mods..."
Width="200" FontSize="11" />
</StackPanel>
<ScrollViewer>
<ItemsControl ItemsSource="{Binding AvailableMods}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:AvailableModViewModel">
<Button Command="{ReflectionBinding $parent[ItemsControl].DataContext.AddModToStepCommand}"
CommandParameter="{Binding}"
Background="Transparent" Padding="2,1"
HorizontalContentAlignment="Left"
HorizontalAlignment="Stretch" Cursor="Hand">
<StackPanel Orientation="Horizontal" Spacing="6">
<Border Background="{Binding TypeColor}"
CornerRadius="3" Padding="4,1"
VerticalAlignment="Center">
<TextBlock Text="{Binding TypeTag}"
FontSize="9" FontWeight="Bold"
Foreground="#0d1117" />
</Border>
<TextBlock Text="{Binding Text}" FontSize="11"
Foreground="#e6edf3"
TextTrimming="CharacterEllipsis" />
<TextBlock Text="{Binding RequiredLevel, StringFormat='L{0}'}"
FontSize="9" Foreground="#484f58"
VerticalAlignment="Center" />
</StackPanel>
</Button>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</DockPanel>
</Grid>
</DockPanel> </DockPanel>
</Border> </Border>
</Grid> </Grid>
@ -577,6 +670,118 @@
</DockPanel> </DockPanel>
</TabItem> </TabItem>
<!-- ========== MEMORY TAB ========== -->
<TabItem Header="Memory">
<DockPanel DataContext="{Binding MemoryVm}" Margin="0,6,0,0"
x:DataType="vm:MemoryViewModel">
<!-- Top bar: Enable + status -->
<Border DockPanel.Dock="Top" Background="#161b22" BorderBrush="#30363d"
BorderThickness="1" CornerRadius="8" Padding="8" Margin="0,0,0,6">
<StackPanel Orientation="Horizontal" Spacing="12">
<CheckBox IsChecked="{Binding IsEnabled}" Content="Enable"
Foreground="#e6edf3" VerticalAlignment="Center" />
<TextBlock Text="{Binding StatusText}" FontSize="12"
Foreground="#8b949e" VerticalAlignment="Center" />
</StackPanel>
</Border>
<!-- Bottom: Raw Address Explorer + Scan -->
<Border DockPanel.Dock="Bottom" Background="#161b22" BorderBrush="#30363d"
BorderThickness="1" CornerRadius="8" Padding="8" Margin="0,6,0,0">
<Grid ColumnDefinitions="*,8,*">
<!-- Left: Read -->
<StackPanel Grid.Column="0" Spacing="6">
<TextBlock Text="READ" FontSize="11" FontWeight="SemiBold"
Foreground="#8b949e" />
<StackPanel Orientation="Horizontal" Spacing="6">
<TextBox Text="{Binding RawAddress}" Watermark="Address (hex)"
Width="130" FontFamily="Consolas" FontSize="11" />
<TextBox Text="{Binding RawOffsets}" Watermark="Offsets"
Width="120" FontFamily="Consolas" FontSize="11" />
<ComboBox ItemsSource="{x:Static vm:MemoryViewModel.RawTypes}"
SelectedItem="{Binding RawType}" Width="90" FontSize="11" />
<Button Content="Read" Command="{Binding ReadAddressExecuteCommand}"
Padding="10,4" />
</StackPanel>
<TextBox Text="{Binding RawResult, Mode=OneWay}" FontFamily="Consolas"
FontSize="11" Foreground="#e6edf3" Background="#0d1117"
BorderBrush="#30363d" BorderThickness="1" CornerRadius="4"
IsReadOnly="True" TextWrapping="Wrap" MinHeight="24" />
</StackPanel>
<!-- Right: Scan -->
<StackPanel Grid.Column="2" Spacing="6">
<TextBlock Text="SCAN (find pointers)" FontSize="11" FontWeight="SemiBold"
Foreground="#8b949e" />
<StackPanel Orientation="Horizontal" Spacing="6">
<TextBox Text="{Binding ScanAddress}" Watermark="Address (hex)"
Width="130" FontFamily="Consolas" FontSize="11" />
<TextBox Text="{Binding ScanOffsets}" Watermark="Offsets"
Width="120" FontFamily="Consolas" FontSize="11" />
<TextBox Text="{Binding ScanSize}" Watermark="Size (hex)"
Width="60" FontFamily="Consolas" FontSize="11" />
<Button Content="Scan" Command="{Binding ScanExecuteCommand}"
Padding="10,4" />
<Button Content="RTTI Struct" Command="{Binding ScanStructureExecuteCommand}"
Padding="10,4" />
<Button Content="All States" Command="{Binding ScanStatesExecuteCommand}"
Padding="10,4" />
<Button Content="Probe IGS" Command="{Binding ProbeExecuteCommand}"
Padding="10,4" FontWeight="Bold" />
</StackPanel>
<StackPanel Orientation="Horizontal" Spacing="6" Margin="0,4,0,0">
<TextBlock Text="Vitals:" Foreground="#8b949e" FontSize="11"
VerticalAlignment="Center" />
<TextBox Text="{Binding VitalHp}" Watermark="HP"
Width="60" FontFamily="Consolas" FontSize="11" />
<TextBox Text="{Binding VitalMana}" Watermark="Mana"
Width="60" FontFamily="Consolas" FontSize="11" />
<TextBox Text="{Binding VitalEs}" Watermark="ES"
Width="60" FontFamily="Consolas" FontSize="11" />
<Button Content="Scan Components" Command="{Binding ScanComponentsExecuteCommand}"
Padding="10,4" FontWeight="Bold" />
<Button Content="Deep Scan" Command="{Binding DeepScanExecuteCommand}"
Padding="10,4" FontWeight="Bold" />
<Button Content="Diagnose Vitals" Command="{Binding DiagnoseVitalsExecuteCommand}"
Padding="10,4" FontWeight="Bold" />
<Button Content="Diagnose Entity" Command="{Binding DiagnoseEntityExecuteCommand}"
Padding="10,4" FontWeight="Bold" />
<Button Content="Scan Position" Command="{Binding ScanPositionExecuteCommand}"
Padding="10,4" FontWeight="Bold" />
</StackPanel>
<TextBox Text="{Binding ScanResult, Mode=OneWay}" FontFamily="Consolas"
FontSize="10" Foreground="#e6edf3" Background="#0d1117"
BorderBrush="#30363d" BorderThickness="1" CornerRadius="4"
IsReadOnly="True" AcceptsReturn="True"
TextWrapping="NoWrap" MinHeight="60" MaxHeight="400"
ScrollViewer.HorizontalScrollBarVisibility="Auto"
ScrollViewer.VerticalScrollBarVisibility="Auto" />
</StackPanel>
</Grid>
</Border>
<!-- Center: TreeView -->
<Border Background="#161b22" BorderBrush="#30363d"
BorderThickness="1" CornerRadius="8" Padding="8">
<TreeView ItemsSource="{Binding RootNodes}"
Background="Transparent">
<TreeView.ItemTemplate>
<TreeDataTemplate ItemsSource="{Binding Children}"
x:DataType="vm:MemoryNodeViewModel">
<StackPanel Orientation="Horizontal" Spacing="6">
<TextBlock Text="{Binding Name}" Foreground="#8b949e"
FontSize="12" />
<TextBlock Text="{Binding Value}" Foreground="{Binding ValueColor}"
FontSize="12" FontFamily="Consolas" />
</StackPanel>
</TreeDataTemplate>
</TreeView.ItemTemplate>
</TreeView>
</Border>
</DockPanel>
</TabItem>
<!-- ========== DEBUG TAB ========== --> <!-- ========== DEBUG TAB ========== -->
<TabItem Header="Debug"> <TabItem Header="Debug">
<ScrollViewer DataContext="{Binding DebugVm}" Margin="0,6,0,0"> <ScrollViewer DataContext="{Binding DebugVm}" Margin="0,6,0,0">