lots working good, minimap / rotation / follow / entities
This commit is contained in:
parent
69a8eaea62
commit
1ba7c39c30
11 changed files with 2496 additions and 99 deletions
36
components.json
Normal file
36
components.json
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
[
|
||||
"Actor",
|
||||
"Animated",
|
||||
"AreaTransition",
|
||||
"BaseEvents",
|
||||
"Buffs",
|
||||
"Chest",
|
||||
"ControlZone",
|
||||
"CritterAI",
|
||||
"Functions",
|
||||
"HideoutDoodad",
|
||||
"InteractionAction",
|
||||
"Inventories",
|
||||
"Life",
|
||||
"MinimapIcon",
|
||||
"Monster",
|
||||
"NPC",
|
||||
"ObjectMagicProperties",
|
||||
"Pathfinding",
|
||||
"PetAi",
|
||||
"Player",
|
||||
"PlayerClass",
|
||||
"Portal",
|
||||
"Positioned",
|
||||
"Preload",
|
||||
"Projectile",
|
||||
"ProximityTrigger",
|
||||
"Render",
|
||||
"StateMachine",
|
||||
"Stats",
|
||||
"Targetable",
|
||||
"Timer",
|
||||
"Transitionable",
|
||||
"TriggerableBlockage",
|
||||
"WorldItem"
|
||||
]
|
||||
94
entities.json
Normal file
94
entities.json
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
[
|
||||
"Metadata/Characters/Character_login",
|
||||
"Metadata/Characters/Dex/DexFour",
|
||||
"Metadata/Characters/Dex/DexFourb",
|
||||
"Metadata/Characters/DexInt/DexIntFourb",
|
||||
"Metadata/Characters/Int/IntFour",
|
||||
"Metadata/Characters/Int/IntFourb",
|
||||
"Metadata/Characters/Str/StrFourb",
|
||||
"Metadata/Characters/StrDex/StrDexFourb",
|
||||
"Metadata/Characters/StrInt/StrIntFourb",
|
||||
"Metadata/Chests/EzomyteChest_05",
|
||||
"Metadata/Chests/EzomyteChest_06",
|
||||
"Metadata/Chests/MossyChest26",
|
||||
"Metadata/Critters/Chicken/Chicken_kingsmarch",
|
||||
"Metadata/Critters/Hedgehog/HedgehogSlow",
|
||||
"Metadata/Critters/Weta/Basic",
|
||||
"Metadata/Effects/Effect",
|
||||
"Metadata/Effects/Microtransactions/foot_prints/delirium/footprints_delirium",
|
||||
"Metadata/Effects/Microtransactions/foot_prints/harvest02/footprints_harvest",
|
||||
"Metadata/Effects/PermanentEffect",
|
||||
"Metadata/Effects/ServerEffect",
|
||||
"Metadata/MiscellaneousObjects/AreaTransitionBlockage",
|
||||
"Metadata/MiscellaneousObjects/AreaTransitionDoodad",
|
||||
"Metadata/MiscellaneousObjects/AreaTransition_Animate",
|
||||
"Metadata/MiscellaneousObjects/Checkpoint",
|
||||
"Metadata/MiscellaneousObjects/Doodad",
|
||||
"Metadata/MiscellaneousObjects/DoodadNoBlocking",
|
||||
"Metadata/MiscellaneousObjects/Environment/NonDefaultFlows/FlowBlocking_20_1",
|
||||
"Metadata/MiscellaneousObjects/Environment/NonDefaultFlows/FlowSink_6_4",
|
||||
"Metadata/MiscellaneousObjects/Environment/NonDefaultFlows/FlowSource_4.75_1",
|
||||
"Metadata/MiscellaneousObjects/GuildStash",
|
||||
"Metadata/MiscellaneousObjects/HealingWell",
|
||||
"Metadata/MiscellaneousObjects/LeagueIncursionNew/IncursionPedestalCrystal_1",
|
||||
"Metadata/MiscellaneousObjects/LeagueIncursionNew/IncursionPedestalCrystal_2",
|
||||
"Metadata/MiscellaneousObjects/LeagueIncursionNew/IncursionPedestalCrystal_3",
|
||||
"Metadata/MiscellaneousObjects/LeagueIncursionNew/IncursionPedestalCrystal_4",
|
||||
"Metadata/MiscellaneousObjects/LeagueIncursionNew/IncursionPedestalCrystal_5",
|
||||
"Metadata/MiscellaneousObjects/LeagueIncursionNew/IncursionPedestalCrystal_6",
|
||||
"Metadata/MiscellaneousObjects/LeagueIncursionNew/IncursionPedestalEncounter",
|
||||
"Metadata/MiscellaneousObjects/MultiplexPortal",
|
||||
"Metadata/MiscellaneousObjects/ServerDoodadHidden",
|
||||
"Metadata/MiscellaneousObjects/Stash",
|
||||
"Metadata/MiscellaneousObjects/Waypoint",
|
||||
"Metadata/MiscellaneousObjects/WorldItem",
|
||||
"Metadata/Monsters/Hags/UrchinHag1",
|
||||
"Metadata/Monsters/Urchins/MeleeUrchin1",
|
||||
"Metadata/Monsters/Urchins/SlingUrchin1",
|
||||
"Metadata/Monsters/Wolves/RottenWolf1_",
|
||||
"Metadata/Monsters/Wolves/RottenWolfDead",
|
||||
"Metadata/Monsters/Zombies/CourtGuardZombieUnarmed",
|
||||
"Metadata/Monsters/Zombies/Lumberjack/LumberingDrownedDryOneHandAxe",
|
||||
"Metadata/Monsters/Zombies/Lumberjack/LumberingDrownedDryUnarmed",
|
||||
"Metadata/Monsters/Zombies/Lumberjack/LumberingDrownedDryUnarmedPhysics",
|
||||
"Metadata/NPC/Four_Act1/ClearfellPosting1",
|
||||
"Metadata/NPC/Four_Act1/DogTrader_Entrance",
|
||||
"Metadata/NPC/Four_Act1/ExecutionerFemaleNPCTown",
|
||||
"Metadata/NPC/Four_Act1/EzomyteCivilianFemale01",
|
||||
"Metadata/NPC/Four_Act1/EzomyteCivilianFemale02",
|
||||
"Metadata/NPC/Four_Act1/EzomyteCivilianMale01",
|
||||
"Metadata/NPC/Four_Act1/Finn",
|
||||
"Metadata/NPC/Four_Act1/FinnHoodedMentorInjured",
|
||||
"Metadata/NPC/Four_Act1/HoodedMentor",
|
||||
"Metadata/NPC/Four_Act1/HoodedMentorAfterIronCount",
|
||||
"Metadata/NPC/Four_Act1/HoodedMentorInjured",
|
||||
"Metadata/NPC/Four_Act1/Renly",
|
||||
"Metadata/NPC/Four_Act1/RenlyAfterIronCount",
|
||||
"Metadata/NPC/Four_Act1/RenlyIntro",
|
||||
"Metadata/NPC/Four_Act1/Una",
|
||||
"Metadata/NPC/Four_Act1/UnaAfterHealHoodedMentor",
|
||||
"Metadata/NPC/Four_Act1/UnaAfterIronCount",
|
||||
"Metadata/NPC/Four_Act1/UnaHoodedOneInjured",
|
||||
"Metadata/NPC/League/Incursion/AlvaIncursionWild",
|
||||
"Metadata/Pet/BetaKiwis/FaridunKiwi",
|
||||
"Metadata/Pet/FledglingBellcrow/FledglingBellcrow",
|
||||
"Metadata/Pet/OrigamiPet/OrigamiPetBase",
|
||||
"Metadata/Pet/Phoenix/PhoenixPetGreen",
|
||||
"Metadata/Pet/Phoenix/PhoenixPetRed",
|
||||
"Metadata/Projectiles/SlingUrchinProjectile",
|
||||
"Metadata/Projectiles/Twister",
|
||||
"Metadata/Terrain/Doodads/Gallows/ClearfellBull1",
|
||||
"Metadata/Terrain/Doodads/Gallows/ClearfellBull1_CountKilled",
|
||||
"Metadata/Terrain/Doodads/Gallows/ClearfellBull2",
|
||||
"Metadata/Terrain/Doodads/Gallows/ClearfellBull2_CountKilled",
|
||||
"Metadata/Terrain/Gallows/Act1/1_2/Objects/RuleSet",
|
||||
"Metadata/Terrain/Gallows/Act1/1_town_ExileEncampment/Objects/Act1_finished_LightController",
|
||||
"Metadata/Terrain/Gallows/Act1/1_town_ExileEncampment/Objects/CraftingBenchEzomyte",
|
||||
"Metadata/Terrain/Gallows/Act1/1_town_ExileEncampment/Objects/CraftingBench_DisableRendering",
|
||||
"Metadata/Terrain/Gallows/Act1/1_town_ExileEncampment/Objects/CraftingBench_EnableRendering",
|
||||
"Metadata/Terrain/Gallows/Act1/1_town_ExileEncampment/Objects/VisitedAct2_DisableRendering",
|
||||
"Metadata/Terrain/Gallows/Act1/1_town_ExileEncampment/Objects/VisitedAct2_EnableRendering",
|
||||
"Metadata/Terrain/Tools/AudioTools/G1_2/RiverRapidsMedium",
|
||||
"Metadata/Terrain/Tools/AudioTools/G1_Town/FurnaceFireAudio",
|
||||
"Metadata/Terrain/Tools/AudioTools/G1_Town/InsideWaterMillAudio"
|
||||
]
|
||||
24
offsets.json
24
offsets.json
|
|
@ -6,9 +6,13 @@
|
|||
"StatesBeginOffset": 72,
|
||||
"StateStride": 16,
|
||||
"StatePointerOffset": 0,
|
||||
"StateCount": 12,
|
||||
"InGameStateIndex": 4,
|
||||
"ActiveStatesOffset": 32,
|
||||
"StatesInline": true,
|
||||
"InGameStateDirectOffset": 528,
|
||||
"IsLoadingOffset": 832,
|
||||
"EscapeStateOffset": 524,
|
||||
"IngameDataFromStateOffset": 656,
|
||||
"WorldDataFromStateOffset": 760,
|
||||
"AreaLevelOffset": 196,
|
||||
|
|
@ -19,8 +23,22 @@
|
|||
"LocalPlayerDirectOffset": 2576,
|
||||
"EntityListOffset": 2896,
|
||||
"EntityCountInternalOffset": 8,
|
||||
"EntityNodeLeftOffset": 0,
|
||||
"EntityNodeParentOffset": 8,
|
||||
"EntityNodeRightOffset": 16,
|
||||
"EntityNodeValueOffset": 40,
|
||||
"EntityIdOffset": 128,
|
||||
"EntityFlagsOffset": 132,
|
||||
"EntityDetailsOffset": 8,
|
||||
"EntityPathStringOffset": 8,
|
||||
"LocalPlayerOffset": 32,
|
||||
"ComponentListOffset": 16,
|
||||
"EntityHeaderOffset": 8,
|
||||
"ComponentLookupOffset": 40,
|
||||
"ComponentLookupVec2Offset": 40,
|
||||
"ComponentLookupEntrySize": 16,
|
||||
"ComponentLookupNameOffset": 0,
|
||||
"ComponentLookupIndexOffset": 8,
|
||||
"LifeComponentIndex": -1,
|
||||
"RenderComponentIndex": -1,
|
||||
"LifeComponentOffset1": 1056,
|
||||
|
|
@ -35,9 +53,9 @@
|
|||
"PositionZOffset": 320,
|
||||
"TerrainListOffset": 3264,
|
||||
"TerrainInline": true,
|
||||
"TerrainDimensionsOffset": 24,
|
||||
"TerrainWalkableGridOffset": 208,
|
||||
"TerrainBytesPerRowOffset": 256,
|
||||
"TerrainDimensionsOffset": 144,
|
||||
"TerrainWalkableGridOffset": 328,
|
||||
"TerrainBytesPerRowOffset": 424,
|
||||
"TerrainGridPtrOffset": 8,
|
||||
"SubTilesPerCell": 23,
|
||||
"InGameStateOffset": 0,
|
||||
|
|
|
|||
|
|
@ -5,6 +5,9 @@
|
|||
<Nullable>enable</Nullable>
|
||||
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="System.Drawing.Common" Version="8.0.12" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Automata.Core\Automata.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
|
|
|||
201
src/Automata.Memory/Entity.cs
Normal file
201
src/Automata.Memory/Entity.cs
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
namespace Automata.Memory;
|
||||
|
||||
public enum EntityType
|
||||
{
|
||||
Unknown,
|
||||
Player,
|
||||
Monster,
|
||||
Npc,
|
||||
Effect,
|
||||
WorldItem,
|
||||
MiscellaneousObject,
|
||||
Terrain,
|
||||
Critter,
|
||||
Chest,
|
||||
Shrine,
|
||||
Portal,
|
||||
TownPortal,
|
||||
Waypoint,
|
||||
AreaTransition,
|
||||
Door,
|
||||
}
|
||||
|
||||
public enum MonsterRarity
|
||||
{
|
||||
White,
|
||||
Magic,
|
||||
Rare,
|
||||
Unique,
|
||||
}
|
||||
|
||||
public class Entity
|
||||
{
|
||||
public nint Address { get; }
|
||||
public uint Id { get; }
|
||||
public string? Path { get; }
|
||||
public string? Metadata { get; }
|
||||
|
||||
// Position (from Render component)
|
||||
public bool HasPosition { get; internal set; }
|
||||
public float X { get; internal set; }
|
||||
public float Y { get; internal set; }
|
||||
public float Z { get; internal set; }
|
||||
|
||||
// Vitals (from Life component — only populated when explicitly read)
|
||||
public bool HasVitals { get; internal set; }
|
||||
public int LifeCurrent { get; internal set; }
|
||||
public int LifeTotal { get; internal set; }
|
||||
public int ManaCurrent { get; internal set; }
|
||||
public int ManaTotal { get; internal set; }
|
||||
public int EsCurrent { get; internal set; }
|
||||
public int EsTotal { get; internal set; }
|
||||
|
||||
// Component info
|
||||
public int ComponentCount { get; internal set; }
|
||||
public HashSet<string>? Components { get; internal set; }
|
||||
|
||||
// Component-based properties (populated by GameMemoryReader)
|
||||
public bool IsTargetable { get; internal set; }
|
||||
public bool IsOpened { get; internal set; }
|
||||
public bool IsAvailable { get; internal set; }
|
||||
public MonsterRarity Rarity { get; internal set; }
|
||||
|
||||
// Derived properties
|
||||
public bool IsAlive => HasVitals && LifeCurrent > 0;
|
||||
public bool IsDead => HasVitals && LifeCurrent <= 0;
|
||||
public bool IsHostile => Type == EntityType.Monster;
|
||||
public bool IsNpc => Type == EntityType.Npc;
|
||||
public bool IsPlayer => Type == EntityType.Player;
|
||||
public bool HasComponent(string name) => Components?.Contains(name) == true;
|
||||
|
||||
/// <summary>
|
||||
/// Grid-plane distance to another point (ignores Z).
|
||||
/// </summary>
|
||||
public float DistanceTo(float px, float py)
|
||||
{
|
||||
var dx = X - px;
|
||||
var dy = Y - py;
|
||||
return MathF.Sqrt(dx * dx + dy * dy);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Grid-plane distance to another entity.
|
||||
/// </summary>
|
||||
public float DistanceTo(Entity other) => DistanceTo(other.X, other.Y);
|
||||
|
||||
/// <summary>
|
||||
/// Short category string derived from path (e.g. "Monsters", "Effects", "NPC").
|
||||
/// </summary>
|
||||
public string Category
|
||||
{
|
||||
get
|
||||
{
|
||||
if (Path is null) return "?";
|
||||
var parts = Path.Split('/');
|
||||
return parts.Length >= 2 ? parts[1] : "?";
|
||||
}
|
||||
}
|
||||
|
||||
public EntityType Type { get; internal set; }
|
||||
|
||||
internal Entity(nint address, uint id, string? path)
|
||||
{
|
||||
Address = address;
|
||||
Id = id;
|
||||
Path = path;
|
||||
Metadata = ExtractMetadata(path);
|
||||
Type = ClassifyType(path);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reclassify entity type using component names (called after components are read).
|
||||
/// Component-based classification is more reliable than path-based.
|
||||
/// </summary>
|
||||
internal void ReclassifyFromComponents()
|
||||
{
|
||||
if (Components is null || Components.Count == 0) return;
|
||||
|
||||
// Priority order matching ExileCore's ParseType logic
|
||||
if (Components.Contains("Monster")) { Type = EntityType.Monster; return; }
|
||||
if (Components.Contains("Chest")) { Type = EntityType.Chest; return; }
|
||||
if (Components.Contains("Shrine")) { Type = EntityType.Shrine; return; }
|
||||
if (Components.Contains("Waypoint")) { Type = EntityType.Waypoint; return; }
|
||||
if (Components.Contains("AreaTransition")) { Type = EntityType.AreaTransition; return; }
|
||||
if (Components.Contains("Portal")) { Type = EntityType.Portal; return; }
|
||||
if (Components.Contains("TownPortal")) { Type = EntityType.TownPortal; return; }
|
||||
if (Components.Contains("NPC")) { Type = EntityType.Npc; return; }
|
||||
if (Components.Contains("Player")) { Type = EntityType.Player; return; }
|
||||
// Don't override path-based classification for Effects/Terrain/etc.
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Strips the "@N" instance suffix from the path.
|
||||
/// "Metadata/Monsters/Wolves/RottenWolf1_@2" → "Metadata/Monsters/Wolves/RottenWolf1_"
|
||||
/// </summary>
|
||||
private static string? ExtractMetadata(string? path)
|
||||
{
|
||||
if (path is null) return null;
|
||||
var atIndex = path.LastIndexOf('@');
|
||||
return atIndex > 0 ? path[..atIndex] : path;
|
||||
}
|
||||
|
||||
private static EntityType ClassifyType(string? path)
|
||||
{
|
||||
if (path is null) return EntityType.Unknown;
|
||||
|
||||
// Check second path segment: "Metadata/<Category>/..."
|
||||
var firstSlash = path.IndexOf('/');
|
||||
if (firstSlash < 0) return EntityType.Unknown;
|
||||
|
||||
var secondSlash = path.IndexOf('/', firstSlash + 1);
|
||||
var category = secondSlash > 0
|
||||
? path[(firstSlash + 1)..secondSlash]
|
||||
: path[(firstSlash + 1)..];
|
||||
|
||||
switch (category)
|
||||
{
|
||||
case "Characters":
|
||||
return EntityType.Player;
|
||||
|
||||
case "Monsters":
|
||||
// Sub-classify: some "monsters" are actually NPCs or critters
|
||||
if (path.Contains("/Critters/", StringComparison.OrdinalIgnoreCase))
|
||||
return EntityType.Critter;
|
||||
if (path.Contains("/NPC/", StringComparison.OrdinalIgnoreCase) ||
|
||||
path.Contains("/TownNPC/", StringComparison.OrdinalIgnoreCase))
|
||||
return EntityType.Npc;
|
||||
return EntityType.Monster;
|
||||
|
||||
case "NPC":
|
||||
return EntityType.Npc;
|
||||
|
||||
case "Effects":
|
||||
return EntityType.Effect;
|
||||
|
||||
case "MiscellaneousObjects":
|
||||
if (path.Contains("/Chest", StringComparison.OrdinalIgnoreCase) ||
|
||||
path.Contains("/Stash", StringComparison.OrdinalIgnoreCase))
|
||||
return EntityType.Chest;
|
||||
if (path.Contains("/Shrine", StringComparison.OrdinalIgnoreCase))
|
||||
return EntityType.Shrine;
|
||||
if (path.Contains("/Portal", StringComparison.OrdinalIgnoreCase))
|
||||
return EntityType.Portal;
|
||||
return EntityType.MiscellaneousObject;
|
||||
|
||||
case "Terrain":
|
||||
return EntityType.Terrain;
|
||||
|
||||
case "Items":
|
||||
return EntityType.WorldItem;
|
||||
|
||||
default:
|
||||
return EntityType.Unknown;
|
||||
}
|
||||
}
|
||||
|
||||
public override string ToString()
|
||||
{
|
||||
var pos = HasPosition ? $"({X:F0},{Y:F0})" : "no pos";
|
||||
return $"[{Id}] {Type} {Path ?? "?"} {pos}";
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
134
src/Automata.Memory/ObjectRegistry.cs
Normal file
134
src/Automata.Memory/ObjectRegistry.cs
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
using System.Text.Json;
|
||||
using Serilog;
|
||||
|
||||
namespace Automata.Memory;
|
||||
|
||||
/// <summary>
|
||||
/// Persistent registry of discovered strings, organized by category.
|
||||
/// Saves each category to its own JSON file (e.g. components.json, entities.json).
|
||||
/// Loads on startup, saves whenever new entries are found.
|
||||
/// </summary>
|
||||
public sealed class ObjectRegistry
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true };
|
||||
|
||||
private readonly Dictionary<string, Category> _categories = [];
|
||||
|
||||
/// <summary>
|
||||
/// Get or create a category. Each category persists to its own JSON file.
|
||||
/// </summary>
|
||||
public Category this[string name]
|
||||
{
|
||||
get
|
||||
{
|
||||
if (!_categories.TryGetValue(name, out var cat))
|
||||
{
|
||||
cat = new Category(name);
|
||||
_categories[name] = cat;
|
||||
}
|
||||
return cat;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Flush all dirty categories to disk.
|
||||
/// </summary>
|
||||
public void Flush()
|
||||
{
|
||||
foreach (var cat in _categories.Values)
|
||||
cat.Flush();
|
||||
}
|
||||
|
||||
public sealed class Category
|
||||
{
|
||||
private readonly string _path;
|
||||
private readonly HashSet<string> _known = [];
|
||||
private bool _dirty;
|
||||
|
||||
public IReadOnlySet<string> Known => _known;
|
||||
public int Count => _known.Count;
|
||||
|
||||
internal Category(string name)
|
||||
{
|
||||
_path = $"{name}.json";
|
||||
Load();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Register a single entry. Returns true if it was new.
|
||||
/// </summary>
|
||||
public bool Register(string? value)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value)) return false;
|
||||
if (_known.Add(value))
|
||||
{
|
||||
_dirty = true;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Register multiple entries. Returns true if any were new.
|
||||
/// </summary>
|
||||
public bool Register(IEnumerable<string> values)
|
||||
{
|
||||
var added = false;
|
||||
foreach (var value in values)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(value) && _known.Add(value))
|
||||
{
|
||||
added = true;
|
||||
_dirty = true;
|
||||
}
|
||||
}
|
||||
return added;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Save to disk if there are unsaved changes.
|
||||
/// </summary>
|
||||
public void Flush()
|
||||
{
|
||||
if (!_dirty) return;
|
||||
Save();
|
||||
_dirty = false;
|
||||
}
|
||||
|
||||
private void Load()
|
||||
{
|
||||
if (!File.Exists(_path)) return;
|
||||
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(_path);
|
||||
var list = JsonSerializer.Deserialize<List<string>>(json);
|
||||
if (list is not null)
|
||||
{
|
||||
foreach (var name in list)
|
||||
_known.Add(name);
|
||||
Log.Information("Loaded {Count} entries from '{Path}'", _known.Count, _path);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error(ex, "Error loading from '{Path}'", _path);
|
||||
}
|
||||
}
|
||||
|
||||
private void Save()
|
||||
{
|
||||
try
|
||||
{
|
||||
var sorted = _known.OrderBy(n => n, StringComparer.Ordinal).ToList();
|
||||
var json = JsonSerializer.Serialize(sorted, JsonOptions);
|
||||
File.WriteAllText(_path, json);
|
||||
Log.Debug("Saved {Count} entries to '{Path}'", sorted.Count, _path);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error(ex, "Error saving to '{Path}'", _path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -32,15 +32,23 @@ public sealed class TerrainOffsets
|
|||
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>Total number of state slots (ExileCore: 12 states, State0-State11).</summary>
|
||||
public int StateCount { get; set; } = 12;
|
||||
/// <summary>Which state index is InGameState (typically 4).</summary>
|
||||
public int InGameStateIndex { get; set; } = 4;
|
||||
/// <summary>Offset from controller to active states vector begin/end pair (ExileCore: 0x20).</summary>
|
||||
public int ActiveStatesOffset { get; set; } = 0x20;
|
||||
/// <summary>Offset from controller to the active state pointer. When it != InGameState, we're loading. 0 = disabled, use ScanAreaLoadingState to find.</summary>
|
||||
public int IsLoadingOffset { get; set; } = 0;
|
||||
/// <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 }
|
||||
// Dump: InGameStateOffset { [0x208] EscapeState flags, [0x298] AreaInstanceData, [0x2F8] WorldData, [0x648] UiRootPtr, [0xC40] IngameUi }
|
||||
/// <summary>InGameState → EscapeState int32 flag (0=closed, 1=open). Diff-scan confirmed: 0x20C. 0 = disabled.</summary>
|
||||
public int EscapeStateOffset { get; set; } = 0x20C;
|
||||
/// <summary>InGameState → AreaInstance (IngameData) pointer (dump: 0x298, CE confirmed: 0x290).</summary>
|
||||
public int IngameDataFromStateOffset { get; set; } = 0x290;
|
||||
/// <summary>InGameState → WorldData pointer (dump: 0x2F8).</summary>
|
||||
|
|
@ -102,8 +110,16 @@ public sealed class TerrainOffsets
|
|||
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>EntityDetails → ComponentLookup object pointer. Confirmed: 0x28 (right after wstring path).</summary>
|
||||
public int ComponentLookupOffset { get; set; } = 0x28;
|
||||
/// <summary>ComponentLookup object → Vec2 begin/end (name entry array). Object layout: +0x10=Vec1(ptrs), +0x28=Vec2(names).</summary>
|
||||
public int ComponentLookupVec2Offset { get; set; } = 0x28;
|
||||
/// <summary>Size of each entry in Vec2 (bytes). Confirmed: 16 = { char* name (8), int32 index (4), int32 flags (4) }.</summary>
|
||||
public int ComponentLookupEntrySize { get; set; } = 16;
|
||||
/// <summary>Offset to the char* name pointer within each lookup entry.</summary>
|
||||
public int ComponentLookupNameOffset { get; set; } = 0;
|
||||
/// <summary>Offset to the component index (int32) within each lookup entry.</summary>
|
||||
public int ComponentLookupIndexOffset { get; set; } = 8;
|
||||
/// <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>
|
||||
|
|
@ -134,24 +150,25 @@ public sealed class TerrainOffsets
|
|||
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
|
||||
// }
|
||||
// Scan-confirmed TerrainStruct (at AreaInstance + 0xCC0):
|
||||
// [0x90] StdTuple2D<long> TotalTiles (cols, rows as int64)
|
||||
// [0xA0] StdVector TileDetailsPtr (56 bytes/tile)
|
||||
// [0x100] int32 cols, int32 rows (redundant compact dims)
|
||||
// [0x148] StdVector GridWalkableData (size = bytesPerRow * gridHeight)
|
||||
// [0x160] StdVector GridLandscapeData
|
||||
// [0x178] StdVector Grid3
|
||||
// [0x190] StdVector Grid4
|
||||
// [0x1A8] int BytesPerRow = ceil(cols * 23 / 2)
|
||||
/// <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>TerrainStruct → TotalTiles offset (scan: 0x90, StdTuple2D of long).</summary>
|
||||
public int TerrainDimensionsOffset { get; set; } = 0x90;
|
||||
/// <summary>TerrainStruct → GridWalkableData StdVector offset (scan: 0x148).</summary>
|
||||
public int TerrainWalkableGridOffset { get; set; } = 0x148;
|
||||
/// <summary>TerrainStruct → BytesPerRow (scan: 0x1A8).</summary>
|
||||
public int TerrainBytesPerRowOffset { get; set; } = 0x1A8;
|
||||
/// <summary>Kept for pointer-based terrain mode (TerrainInline=false).</summary>
|
||||
public int TerrainGridPtrOffset { get; set; } = 0x08;
|
||||
public int SubTilesPerCell { get; set; } = 23;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,8 @@
|
|||
using System.Collections.ObjectModel;
|
||||
using System.Runtime.InteropServices;
|
||||
using Avalonia;
|
||||
using Avalonia.Media.Imaging;
|
||||
using Avalonia.Platform;
|
||||
using Avalonia.Threading;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
|
|
@ -32,11 +36,21 @@ public partial class MemoryViewModel : ObservableObject
|
|||
private GameMemoryReader? _reader;
|
||||
private CancellationTokenSource? _cts;
|
||||
|
||||
private const int ViewportRadius = 300; // grid pixels visible in each direction from player
|
||||
|
||||
[ObservableProperty] private bool _isEnabled;
|
||||
[ObservableProperty] private string _statusText = "Not attached";
|
||||
|
||||
public ObservableCollection<MemoryNodeViewModel> RootNodes { get; } = [];
|
||||
|
||||
// Minimap
|
||||
[ObservableProperty] private Bitmap? _terrainImage;
|
||||
private byte[]? _terrainBasePixels;
|
||||
private byte[]? _minimapBuffer; // reused each frame
|
||||
private int _terrainImageWidth, _terrainImageHeight;
|
||||
private uint _terrainImageAreaHash;
|
||||
private WalkabilityGrid? _terrainGridRef;
|
||||
|
||||
// Raw explorer
|
||||
[ObservableProperty] private string _rawAddress = "";
|
||||
[ObservableProperty] private string _rawOffsets = "";
|
||||
|
|
@ -75,10 +89,15 @@ public partial class MemoryViewModel : ObservableObject
|
|||
private MemoryNodeViewModel? _playerLife;
|
||||
private MemoryNodeViewModel? _playerMana;
|
||||
private MemoryNodeViewModel? _playerEs;
|
||||
private MemoryNodeViewModel? _isLoadingNode;
|
||||
private MemoryNodeViewModel? _escapeStateNode;
|
||||
private MemoryNodeViewModel? _statesNode;
|
||||
private MemoryNodeViewModel? _terrainCells;
|
||||
private MemoryNodeViewModel? _terrainGrid;
|
||||
private MemoryNodeViewModel? _terrainWalkable;
|
||||
private MemoryNodeViewModel? _entitySummary;
|
||||
private MemoryNodeViewModel? _entityTypesNode;
|
||||
private MemoryNodeViewModel? _entityListNode;
|
||||
|
||||
partial void OnIsEnabledChanged(bool value)
|
||||
{
|
||||
|
|
@ -115,6 +134,13 @@ public partial class MemoryViewModel : ObservableObject
|
|||
_reader = null;
|
||||
RootNodes.Clear();
|
||||
StatusText = "Not attached";
|
||||
|
||||
_terrainBasePixels = null;
|
||||
_terrainImageAreaHash = 0;
|
||||
_terrainGridRef = null;
|
||||
var old = TerrainImage;
|
||||
TerrainImage = null;
|
||||
old?.Dispose();
|
||||
}
|
||||
|
||||
private void BuildTree()
|
||||
|
|
@ -137,11 +163,17 @@ public partial class MemoryViewModel : ObservableObject
|
|||
_gsController = new MemoryNodeViewModel("Controller:");
|
||||
_gsStates = new MemoryNodeViewModel("States:");
|
||||
_inGameState = new MemoryNodeViewModel("InGameState:");
|
||||
_isLoadingNode = new MemoryNodeViewModel("Loading:");
|
||||
_escapeStateNode = new MemoryNodeViewModel("Escape:");
|
||||
_statesNode = new MemoryNodeViewModel("State Slots") { IsExpanded = true };
|
||||
gameState.Children.Add(_gsPattern);
|
||||
gameState.Children.Add(_gsBase);
|
||||
gameState.Children.Add(_gsController);
|
||||
gameState.Children.Add(_gsStates);
|
||||
gameState.Children.Add(_inGameState);
|
||||
gameState.Children.Add(_isLoadingNode);
|
||||
gameState.Children.Add(_escapeStateNode);
|
||||
gameState.Children.Add(_statesNode);
|
||||
|
||||
// InGameState children
|
||||
var inGameStateGroup = new MemoryNodeViewModel("InGameState");
|
||||
|
|
@ -176,15 +208,19 @@ public partial class MemoryViewModel : ObservableObject
|
|||
var entitiesGroup = new MemoryNodeViewModel("Entities");
|
||||
_entitySummary = new MemoryNodeViewModel("Summary:");
|
||||
_entityTypesNode = new MemoryNodeViewModel("Types:") { IsExpanded = false };
|
||||
_entityListNode = new MemoryNodeViewModel("List:") { IsExpanded = false };
|
||||
entitiesGroup.Children.Add(_entitySummary);
|
||||
entitiesGroup.Children.Add(_entityTypesNode);
|
||||
entitiesGroup.Children.Add(_entityListNode);
|
||||
|
||||
// Terrain
|
||||
var terrain = new MemoryNodeViewModel("Terrain");
|
||||
_terrainCells = new MemoryNodeViewModel("Cells:");
|
||||
_terrainGrid = new MemoryNodeViewModel("Grid:");
|
||||
_terrainWalkable = new MemoryNodeViewModel("Walkable:");
|
||||
terrain.Children.Add(_terrainCells);
|
||||
terrain.Children.Add(_terrainGrid);
|
||||
terrain.Children.Add(_terrainWalkable);
|
||||
|
||||
inGameStateGroup.Children.Add(areaInstanceGroup);
|
||||
inGameStateGroup.Children.Add(player);
|
||||
|
|
@ -198,7 +234,7 @@ public partial class MemoryViewModel : ObservableObject
|
|||
|
||||
private async Task ReadLoop(CancellationToken ct)
|
||||
{
|
||||
using var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(500));
|
||||
using var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(30));
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
|
|
@ -240,6 +276,54 @@ public partial class MemoryViewModel : ObservableObject
|
|||
_inGameState!.Set(
|
||||
snap.InGameStatePtr != 0 ? $"0x{snap.InGameStatePtr:X}" : "not found",
|
||||
snap.InGameStatePtr != 0);
|
||||
_isLoadingNode!.Set(snap.IsLoading ? "Loading..." : "Ready", !snap.IsLoading);
|
||||
_escapeStateNode!.Set(snap.IsEscapeOpen ? "Open" : "Closed", !snap.IsEscapeOpen);
|
||||
|
||||
// State Slots — show pointer + int32 at +0x08 for each state slot
|
||||
if (_statesNode is not null && snap.StateSlots.Length > 0)
|
||||
{
|
||||
var slots = snap.StateSlots;
|
||||
var needed = slots.Length;
|
||||
|
||||
while (_statesNode.Children.Count > needed)
|
||||
_statesNode.Children.RemoveAt(_statesNode.Children.Count - 1);
|
||||
|
||||
for (var i = 0; i < needed; i++)
|
||||
{
|
||||
var ptr = slots[i];
|
||||
var stateName = i < GameMemoryReader.StateNames.Length ? GameMemoryReader.StateNames[i] : $"State{i}";
|
||||
var label = $"[{i}] {stateName}:";
|
||||
string val;
|
||||
string color;
|
||||
|
||||
if (ptr == 0)
|
||||
{
|
||||
val = "null";
|
||||
color = "#484f58";
|
||||
}
|
||||
else
|
||||
{
|
||||
// Read int32 at state+0x08 (the value CE found)
|
||||
var int32Val = snap.StateSlotValues?.Length > i ? snap.StateSlotValues[i] : 0;
|
||||
val = $"0x{ptr:X} [+0x08]={int32Val}";
|
||||
color = ptr == snap.InGameStatePtr ? "#3fb950" : "#8b949e";
|
||||
}
|
||||
|
||||
if (i < _statesNode.Children.Count)
|
||||
{
|
||||
_statesNode.Children[i].Name = label;
|
||||
_statesNode.Children[i].Set(val, true);
|
||||
_statesNode.Children[i].ValueColor = color;
|
||||
}
|
||||
else
|
||||
{
|
||||
var node = new MemoryNodeViewModel(label);
|
||||
node.Set(val, true);
|
||||
node.ValueColor = color;
|
||||
_statesNode.Children.Add(node);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Status text
|
||||
if (snap.Attached)
|
||||
|
|
@ -292,18 +376,15 @@ public partial class MemoryViewModel : ObservableObject
|
|||
// Entities
|
||||
if (snap.Entities is { Count: > 0 })
|
||||
{
|
||||
var withPath = snap.Entities.Count(e => e.Path is not null);
|
||||
var withPos = snap.Entities.Count(e => e.HasPosition);
|
||||
_entitySummary!.Set($"{snap.Entities.Count} entities, {withPath} with path, {withPos} with pos");
|
||||
var withComps = snap.Entities.Count(e => e.Components is not null);
|
||||
var monsters = snap.Entities.Count(e => e.Type == Automata.Memory.EntityType.Monster);
|
||||
var knownComps = _reader?.Registry["components"].Count ?? 0;
|
||||
_entitySummary!.Set($"{snap.Entities.Count} total, {withComps} with comps, {knownComps} known, {monsters} monsters");
|
||||
|
||||
// Group by path category: "Metadata/Monsters/..." → "Monsters"
|
||||
// Group by EntityType
|
||||
var typeCounts = snap.Entities
|
||||
.GroupBy(e =>
|
||||
{
|
||||
if (e.Path is null) return "?";
|
||||
var parts = e.Path.Split('/');
|
||||
return parts.Length >= 2 ? parts[1] : "?";
|
||||
})
|
||||
.GroupBy(e => e.Type)
|
||||
.OrderByDescending(g => g.Count())
|
||||
.Take(20);
|
||||
|
||||
|
|
@ -314,11 +395,15 @@ public partial class MemoryViewModel : ObservableObject
|
|||
node.Set(group.Count().ToString());
|
||||
_entityTypesNode.Children.Add(node);
|
||||
}
|
||||
|
||||
// Entity list grouped by type
|
||||
UpdateEntityList(snap.Entities);
|
||||
}
|
||||
else
|
||||
{
|
||||
_entitySummary!.Set("—", false);
|
||||
_entityTypesNode!.Children.Clear();
|
||||
_entityListNode!.Children.Clear();
|
||||
}
|
||||
|
||||
// Terrain
|
||||
|
|
@ -326,11 +411,372 @@ public partial class MemoryViewModel : ObservableObject
|
|||
{
|
||||
_terrainCells!.Set($"{snap.TerrainCols}x{snap.TerrainRows}");
|
||||
_terrainGrid!.Set($"{snap.TerrainWidth}x{snap.TerrainHeight}");
|
||||
if (snap.Terrain != null)
|
||||
_terrainWalkable!.Set($"{snap.TerrainWalkablePercent}%");
|
||||
else
|
||||
_terrainWalkable!.Set("no grid data", false);
|
||||
}
|
||||
else
|
||||
{
|
||||
_terrainCells!.Set("?", false);
|
||||
_terrainGrid!.Set("?", false);
|
||||
_terrainWalkable!.Set("?", false);
|
||||
}
|
||||
|
||||
UpdateMinimap(snap);
|
||||
}
|
||||
|
||||
private void UpdateMinimap(GameStateSnapshot snap)
|
||||
{
|
||||
// Skip rendering entirely during loading — terrain data is stale/invalid
|
||||
if (snap.IsLoading)
|
||||
{
|
||||
_terrainBasePixels = null;
|
||||
_terrainImageAreaHash = 0;
|
||||
_terrainGridRef = null;
|
||||
var oldImg = TerrainImage;
|
||||
TerrainImage = null;
|
||||
oldImg?.Dispose();
|
||||
return;
|
||||
}
|
||||
|
||||
// Invalidate cache when area changes or terrain grid object changes
|
||||
var terrainChanged = snap.Terrain is not null && !ReferenceEquals(snap.Terrain, _terrainGridRef);
|
||||
if (terrainChanged || (snap.AreaHash != 0 && snap.AreaHash != _terrainImageAreaHash))
|
||||
{
|
||||
_terrainBasePixels = null;
|
||||
_terrainImageAreaHash = 0;
|
||||
_terrainGridRef = null;
|
||||
var old = TerrainImage;
|
||||
TerrainImage = null;
|
||||
old?.Dispose();
|
||||
}
|
||||
|
||||
// Rebuild base pixels from new terrain data
|
||||
if (snap.Terrain is { } grid && _terrainBasePixels is null)
|
||||
{
|
||||
var w = grid.Width;
|
||||
var h = grid.Height;
|
||||
var pixels = new byte[w * h * 4];
|
||||
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 (grid.Data[srcY * w + x] == 0) // walkable — transparent
|
||||
{
|
||||
pixels[i] = 0x00;
|
||||
pixels[i + 1] = 0x00;
|
||||
pixels[i + 2] = 0x00;
|
||||
pixels[i + 3] = 0x00;
|
||||
}
|
||||
else // blocked
|
||||
{
|
||||
pixels[i] = 0x50; // B
|
||||
pixels[i + 1] = 0x50; // G
|
||||
pixels[i + 2] = 0x50; // R
|
||||
pixels[i + 3] = 0xFF; // A
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_terrainBasePixels = pixels;
|
||||
_terrainImageWidth = w;
|
||||
_terrainImageHeight = h;
|
||||
_terrainImageAreaHash = snap.AreaHash;
|
||||
_terrainGridRef = grid;
|
||||
}
|
||||
|
||||
var basePixels = _terrainBasePixels;
|
||||
if (basePixels is null) return;
|
||||
|
||||
var tw = _terrainImageWidth;
|
||||
var th = _terrainImageHeight;
|
||||
if (tw < 2 || th < 2) return;
|
||||
|
||||
// World-to-grid conversion: Render component gives world coords,
|
||||
// terrain bitmap is in grid/subtile coords (NumCols*23 x NumRows*23).
|
||||
// Each tile = 250 world units = 23 subtiles, so grid = world * 23/250.
|
||||
const float worldToGrid = 23.0f / 250.0f;
|
||||
const float cos45 = 0.70710678f;
|
||||
const float sin45 = 0.70710678f;
|
||||
|
||||
var viewSize = ViewportRadius * 2;
|
||||
|
||||
// Player grid position — center of the output
|
||||
float pgx, pgy;
|
||||
if (snap.HasPosition)
|
||||
{
|
||||
pgx = snap.PlayerX * worldToGrid;
|
||||
pgy = th - 1 - snap.PlayerY * worldToGrid;
|
||||
}
|
||||
else
|
||||
{
|
||||
pgx = tw * 0.5f;
|
||||
pgy = th * 0.5f;
|
||||
}
|
||||
|
||||
var bufSize = viewSize * viewSize * 4;
|
||||
if (_minimapBuffer is null || _minimapBuffer.Length != bufSize)
|
||||
_minimapBuffer = new byte[bufSize];
|
||||
var buf = _minimapBuffer;
|
||||
Array.Clear(buf, 0, bufSize);
|
||||
|
||||
var outStride = viewSize * 4;
|
||||
var cx = viewSize * 0.5f;
|
||||
var cy = viewSize * 0.5f;
|
||||
|
||||
// Sample terrain with -45° rotation baked in (nearest-neighbor, unsafe).
|
||||
unsafe
|
||||
{
|
||||
fixed (byte* srcPtr = basePixels, dstPtr = buf)
|
||||
{
|
||||
var srcInt = (int*)srcPtr;
|
||||
var dstInt = (int*)dstPtr;
|
||||
for (var ry = 0; ry < viewSize; ry++)
|
||||
{
|
||||
var dy = ry - cy;
|
||||
var baseX = -dy * sin45 + pgx;
|
||||
var baseY = dy * cos45 + pgy;
|
||||
for (var rx = 0; rx < viewSize; rx++)
|
||||
{
|
||||
var dx = rx - cx;
|
||||
var sx = (int)(dx * cos45 + baseX);
|
||||
var sy = (int)(dx * sin45 + baseY);
|
||||
if ((uint)sx >= (uint)tw || (uint)sy >= (uint)th) continue;
|
||||
|
||||
dstInt[ry * viewSize + rx] = srcInt[sy * tw + sx];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Draw entity dots — transform grid coords into rotated output space
|
||||
if (snap.Entities is { Count: > 0 })
|
||||
{
|
||||
foreach (var e in snap.Entities)
|
||||
{
|
||||
if (!e.HasPosition) continue;
|
||||
if (e.Type is not (EntityType.Player or EntityType.Monster or EntityType.Npc)) continue;
|
||||
if (e.Address == snap.LocalPlayerPtr) continue;
|
||||
if (e.Type == EntityType.Monster && e.IsDead) continue;
|
||||
|
||||
// Entity position relative to player in grid coords
|
||||
var dx = e.X * worldToGrid - pgx;
|
||||
var dy = (th - 1 - e.Y * worldToGrid) - pgy;
|
||||
// Apply -45° rotation into output space
|
||||
var ex = (int)(dx * cos45 + dy * sin45 + cx);
|
||||
var ey = (int)(-dx * sin45 + dy * cos45 + cy);
|
||||
|
||||
if (ex < 0 || ex >= viewSize || ey < 0 || ey >= viewSize) continue;
|
||||
|
||||
byte b, g, r;
|
||||
switch (e.Type)
|
||||
{
|
||||
case EntityType.Player: // other players — green #3FB950
|
||||
b = 0x50; g = 0xB9; r = 0x3F; break;
|
||||
case EntityType.Npc: // orange #FF8C00
|
||||
b = 0x00; g = 0x8C; r = 0xFF; break;
|
||||
case EntityType.Monster: // red #FF4444
|
||||
b = 0x44; g = 0x44; r = 0xFF; break;
|
||||
default: continue;
|
||||
}
|
||||
|
||||
DrawDot(buf, outStride, viewSize, viewSize, ex, ey, 4, b, g, r);
|
||||
}
|
||||
}
|
||||
|
||||
// Draw player dot at center (white, on top)
|
||||
if (snap.HasPosition)
|
||||
DrawDot(buf, outStride, viewSize, viewSize, (int)cx, (int)cy, 5, 0xFF, 0xFF, 0xFF);
|
||||
|
||||
// Create WriteableBitmap
|
||||
var bmp = new WriteableBitmap(
|
||||
new PixelSize(viewSize, viewSize),
|
||||
new 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 void UpdateEntityList(List<Entity> entities)
|
||||
{
|
||||
if (_entityListNode is null) return;
|
||||
|
||||
// Group by type, sorted by type name
|
||||
var groups = entities
|
||||
.GroupBy(e => e.Type)
|
||||
.OrderBy(g => g.Key.ToString());
|
||||
|
||||
// Build a lookup of existing type group nodes by name for reuse
|
||||
var existingGroups = new Dictionary<string, MemoryNodeViewModel>();
|
||||
foreach (var child in _entityListNode.Children)
|
||||
existingGroups[child.Name] = child;
|
||||
|
||||
var usedGroups = new HashSet<string>();
|
||||
|
||||
foreach (var group in groups)
|
||||
{
|
||||
var groupName = $"{group.Key} ({group.Count()})";
|
||||
usedGroups.Add(groupName);
|
||||
|
||||
if (!existingGroups.TryGetValue(groupName, out var groupNode))
|
||||
{
|
||||
groupNode = new MemoryNodeViewModel(groupName) { IsExpanded = false };
|
||||
_entityListNode.Children.Add(groupNode);
|
||||
existingGroups[groupName] = groupNode;
|
||||
}
|
||||
|
||||
// Sort: players first, then by distance to 0,0 (or by id)
|
||||
var sorted = group.OrderBy(e => e.Id).ToList();
|
||||
|
||||
// Rebuild children — reuse by index to reduce churn
|
||||
while (groupNode.Children.Count > sorted.Count)
|
||||
groupNode.Children.RemoveAt(groupNode.Children.Count - 1);
|
||||
|
||||
for (var i = 0; i < sorted.Count; i++)
|
||||
{
|
||||
var e = sorted[i];
|
||||
var label = FormatEntityName(e);
|
||||
var value = FormatEntityValue(e);
|
||||
|
||||
if (i < groupNode.Children.Count)
|
||||
{
|
||||
var existing = groupNode.Children[i];
|
||||
existing.Name = label;
|
||||
existing.Set(value, e.HasPosition);
|
||||
UpdateEntityChildren(existing, e);
|
||||
}
|
||||
else
|
||||
{
|
||||
var node = new MemoryNodeViewModel(label) { IsExpanded = false };
|
||||
node.Set(value, e.HasPosition);
|
||||
UpdateEntityChildren(node, e);
|
||||
groupNode.Children.Add(node);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove stale type groups
|
||||
for (var i = _entityListNode.Children.Count - 1; i >= 0; i--)
|
||||
{
|
||||
if (!usedGroups.Contains(_entityListNode.Children[i].Name))
|
||||
_entityListNode.Children.RemoveAt(i);
|
||||
}
|
||||
}
|
||||
|
||||
private static string FormatEntityName(Entity e)
|
||||
{
|
||||
// Short name: last path segment without @instance
|
||||
if (e.Path is not null)
|
||||
{
|
||||
var lastSlash = e.Path.LastIndexOf('/');
|
||||
var name = lastSlash >= 0 ? e.Path[(lastSlash + 1)..] : e.Path;
|
||||
var at = name.IndexOf('@');
|
||||
if (at > 0) name = name[..at];
|
||||
return $"[{e.Id}] {name}";
|
||||
}
|
||||
return $"[{e.Id}] ?";
|
||||
}
|
||||
|
||||
private static string FormatEntityValue(Entity e)
|
||||
{
|
||||
var parts = new List<string>();
|
||||
|
||||
if (e.HasVitals)
|
||||
parts.Add(e.IsAlive ? "Alive" : "Dead");
|
||||
|
||||
if (e.HasPosition)
|
||||
parts.Add($"({e.X:F0},{e.Y:F0})");
|
||||
|
||||
if (e.HasVitals)
|
||||
parts.Add($"HP:{e.LifeCurrent}/{e.LifeTotal}");
|
||||
|
||||
if (e.Components is { Count: > 0 })
|
||||
parts.Add($"{e.Components.Count} comps");
|
||||
|
||||
return parts.Count > 0 ? string.Join(" ", parts) : "—";
|
||||
}
|
||||
|
||||
private static void UpdateEntityChildren(MemoryNodeViewModel node, Entity e)
|
||||
{
|
||||
// Build children: address, position, vitals, components
|
||||
var needed = new List<(string name, string value, bool valid)>();
|
||||
|
||||
needed.Add(("Addr:", $"0x{e.Address:X}", true));
|
||||
|
||||
if (e.Path is not null)
|
||||
needed.Add(("Path:", e.Path, true));
|
||||
|
||||
if (e.HasPosition)
|
||||
needed.Add(("Pos:", $"({e.X:F1}, {e.Y:F1}, {e.Z:F1})", true));
|
||||
|
||||
if (e.HasVitals)
|
||||
{
|
||||
needed.Add(("Life:", $"{e.LifeCurrent} / {e.LifeTotal}", e.LifeCurrent > 0));
|
||||
if (e.ManaTotal > 0)
|
||||
needed.Add(("Mana:", $"{e.ManaCurrent} / {e.ManaTotal}", true));
|
||||
if (e.EsTotal > 0)
|
||||
needed.Add(("ES:", $"{e.EsCurrent} / {e.EsTotal}", true));
|
||||
}
|
||||
|
||||
if (e.Components is { Count: > 0 })
|
||||
{
|
||||
var compList = string.Join(", ", e.Components.OrderBy(c => c));
|
||||
needed.Add(("Components:", compList, true));
|
||||
}
|
||||
|
||||
// Reuse existing children by index
|
||||
while (node.Children.Count > needed.Count)
|
||||
node.Children.RemoveAt(node.Children.Count - 1);
|
||||
|
||||
for (var i = 0; i < needed.Count; i++)
|
||||
{
|
||||
var (name, value, valid) = needed[i];
|
||||
if (i < node.Children.Count)
|
||||
{
|
||||
node.Children[i].Name = name;
|
||||
node.Children[i].Set(value, valid);
|
||||
}
|
||||
else
|
||||
{
|
||||
var child = new MemoryNodeViewModel(name);
|
||||
child.Set(value, valid);
|
||||
node.Children.Add(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -465,6 +911,66 @@ public partial class MemoryViewModel : ObservableObject
|
|||
ScanResult = _reader.ScanEntities();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void ScanCompLookupExecute()
|
||||
{
|
||||
if (_reader is null || !_reader.IsAttached)
|
||||
{
|
||||
ScanResult = "Error: not attached";
|
||||
return;
|
||||
}
|
||||
|
||||
ScanResult = _reader.ScanComponentLookup();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void ScanAreaLoadingExecute()
|
||||
{
|
||||
if (_reader is null || !_reader.IsAttached)
|
||||
{
|
||||
ScanResult = "Error: not attached";
|
||||
return;
|
||||
}
|
||||
|
||||
ScanResult = _reader.ScanAreaLoadingState();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void ScanDiffExecute()
|
||||
{
|
||||
if (_reader is null || !_reader.IsAttached)
|
||||
{
|
||||
ScanResult = "Error: not attached";
|
||||
return;
|
||||
}
|
||||
|
||||
ScanResult = _reader.ScanMemoryDiff();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void ScanActiveVecExecute()
|
||||
{
|
||||
if (_reader is null || !_reader.IsAttached)
|
||||
{
|
||||
ScanResult = "Error: not attached";
|
||||
return;
|
||||
}
|
||||
|
||||
ScanResult = _reader.ScanActiveStatesVector();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void ScanTerrainExecute()
|
||||
{
|
||||
if (_reader is null || !_reader.IsAttached)
|
||||
{
|
||||
ScanResult = "Error: not attached";
|
||||
return;
|
||||
}
|
||||
|
||||
ScanResult = _reader.ScanTerrain();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void ScanStructureExecute()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -126,11 +126,16 @@
|
|||
</Grid>
|
||||
<Grid>
|
||||
<Image Source="{Binding MinimapImage}" Stretch="Uniform"
|
||||
RenderOptions.BitmapInterpolationMode="None" />
|
||||
RenderOptions.BitmapInterpolationMode="None"
|
||||
IsVisible="{Binding MemoryVm.TerrainImage, Converter={x:Static ObjectConverters.IsNull}}" />
|
||||
<TextBlock Text="Idle"
|
||||
IsVisible="{Binding MinimapImage, Converter={x:Static ObjectConverters.IsNull}}"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center"
|
||||
FontSize="12" Foreground="#484f58" />
|
||||
<!-- Memory terrain overlay (when memory reader is enabled) -->
|
||||
<Image Source="{Binding MemoryVm.TerrainImage}" Stretch="Uniform"
|
||||
RenderOptions.BitmapInterpolationMode="None"
|
||||
IsVisible="{Binding MemoryVm.TerrainImage, Converter={x:Static ObjectConverters.IsNotNull}}" />
|
||||
</Grid>
|
||||
</DockPanel>
|
||||
</Border>
|
||||
|
|
@ -730,28 +735,38 @@
|
|||
<Button Content="Probe IGS" Command="{Binding ProbeExecuteCommand}"
|
||||
Padding="10,4" FontWeight="Bold" />
|
||||
</StackPanel>
|
||||
<StackPanel Orientation="Horizontal" Spacing="6" Margin="0,4,0,0">
|
||||
<WrapPanel Orientation="Horizontal" Margin="0,4,0,0">
|
||||
<TextBlock Text="Vitals:" Foreground="#8b949e" FontSize="11"
|
||||
VerticalAlignment="Center" />
|
||||
VerticalAlignment="Center" Margin="0,0,6,4" />
|
||||
<TextBox Text="{Binding VitalHp}" Watermark="HP"
|
||||
Width="60" FontFamily="Consolas" FontSize="11" />
|
||||
Width="60" FontFamily="Consolas" FontSize="11" Margin="0,0,6,4" />
|
||||
<TextBox Text="{Binding VitalMana}" Watermark="Mana"
|
||||
Width="60" FontFamily="Consolas" FontSize="11" />
|
||||
Width="60" FontFamily="Consolas" FontSize="11" Margin="0,0,6,4" />
|
||||
<TextBox Text="{Binding VitalEs}" Watermark="ES"
|
||||
Width="60" FontFamily="Consolas" FontSize="11" />
|
||||
Width="60" FontFamily="Consolas" FontSize="11" Margin="0,0,6,4" />
|
||||
<Button Content="Scan Components" Command="{Binding ScanComponentsExecuteCommand}"
|
||||
Padding="10,4" FontWeight="Bold" />
|
||||
Padding="10,4" FontWeight="Bold" Margin="0,0,6,4" />
|
||||
<Button Content="Deep Scan" Command="{Binding DeepScanExecuteCommand}"
|
||||
Padding="10,4" FontWeight="Bold" />
|
||||
Padding="10,4" FontWeight="Bold" Margin="0,0,6,4" />
|
||||
<Button Content="Diagnose Vitals" Command="{Binding DiagnoseVitalsExecuteCommand}"
|
||||
Padding="10,4" FontWeight="Bold" />
|
||||
Padding="10,4" FontWeight="Bold" Margin="0,0,6,4" />
|
||||
<Button Content="Diagnose Entity" Command="{Binding DiagnoseEntityExecuteCommand}"
|
||||
Padding="10,4" FontWeight="Bold" />
|
||||
Padding="10,4" FontWeight="Bold" Margin="0,0,6,4" />
|
||||
<Button Content="Scan Position" Command="{Binding ScanPositionExecuteCommand}"
|
||||
Padding="10,4" FontWeight="Bold" />
|
||||
Padding="10,4" FontWeight="Bold" Margin="0,0,6,4" />
|
||||
<Button Content="Scan Entities" Command="{Binding ScanEntitiesExecuteCommand}"
|
||||
Padding="10,4" FontWeight="Bold" />
|
||||
</StackPanel>
|
||||
Padding="10,4" FontWeight="Bold" Margin="0,0,6,4" />
|
||||
<Button Content="Scan Comp Lookup" Command="{Binding ScanCompLookupExecuteCommand}"
|
||||
Padding="10,4" FontWeight="Bold" Margin="0,0,6,4" />
|
||||
<Button Content="Scan Terrain" Command="{Binding ScanTerrainExecuteCommand}"
|
||||
Padding="10,4" FontWeight="Bold" Margin="0,0,6,4" />
|
||||
<Button Content="Scan Loading" Command="{Binding ScanAreaLoadingExecuteCommand}"
|
||||
Padding="10,4" FontWeight="Bold" Margin="0,0,6,4" />
|
||||
<Button Content="Diff Scan" Command="{Binding ScanDiffExecuteCommand}"
|
||||
Padding="10,4" FontWeight="Bold" Margin="0,0,6,4" />
|
||||
<Button Content="Scan ActiveVec" Command="{Binding ScanActiveVecExecuteCommand}"
|
||||
Padding="10,4" FontWeight="Bold" Margin="0,0,6,4" />
|
||||
</WrapPanel>
|
||||
<TextBox Text="{Binding ScanResult, Mode=OneWay}" FontFamily="Consolas"
|
||||
FontSize="10" Foreground="#e6edf3" Background="#0d1117"
|
||||
BorderBrush="#30363d" BorderThickness="1" CornerRadius="4"
|
||||
|
|
|
|||
BIN
terrain.png
Normal file
BIN
terrain.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.4 KiB |
Loading…
Add table
Add a link
Reference in a new issue