lots working good, minimap / rotation / follow / entities

This commit is contained in:
Boki 2026-03-01 21:29:44 -05:00
parent 69a8eaea62
commit 1ba7c39c30
11 changed files with 2496 additions and 99 deletions

36
components.json Normal file
View 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
View 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"
]

View file

@ -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,

View file

@ -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>

View 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

View 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);
}
}
}
}

View file

@ -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;

View file

@ -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()
{

View file

@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB