optimizations

This commit is contained in:
Boki 2026-03-06 00:38:30 -05:00
parent 419e2eb4a4
commit d124f2c288
44 changed files with 1663 additions and 639 deletions

View file

@ -21,18 +21,25 @@
"Metadata/Chests/LeagueIncursion/EncounterChest",
"Metadata/Chests/MossyBoulder1",
"Metadata/Chests/MossyBoulder2",
"Metadata/Chests/MossyBoulder3",
"Metadata/Chests/MossyChest11",
"Metadata/Chests/MossyChest11MagicAndRare",
"Metadata/Chests/MossyChest13",
"Metadata/Chests/MossyChest14",
"Metadata/Chests/MossyChest14MagicAndRare",
"Metadata/Chests/MossyChest17",
"Metadata/Chests/MossyChest17MagicAndRare",
"Metadata/Chests/MossyChest20",
"Metadata/Chests/MossyChest21",
"Metadata/Chests/MossyChest26",
"Metadata/Chests/MuddyChest1",
"Metadata/Chests/RedvaleChest16",
"Metadata/Chests/RedvaleChest18",
"Metadata/Chests/RedvaleChest22",
"Metadata/Chests/RedvaleChest4",
"Metadata/Chests/SirenEggs/SirenEgg_02",
"Metadata/Critters/BloodWorm/BloodWormBrown",
"Metadata/Critters/Butterfly/ButterflyRed",
"Metadata/Critters/Chicken/Chicken_kingsmarch",
"Metadata/Critters/Crow/Crow",
"Metadata/Critters/Ferret/Ferret",
@ -62,12 +69,15 @@
"Metadata/MiscellaneousObjects/CameraZoom/MinorZoomIn",
"Metadata/MiscellaneousObjects/CameraZoom/TreeOfSouls",
"Metadata/MiscellaneousObjects/Checkpoint",
"Metadata/MiscellaneousObjects/CheckpointBoss_Rustking",
"Metadata/MiscellaneousObjects/CheckpointTutorial",
"Metadata/MiscellaneousObjects/Doodad",
"Metadata/MiscellaneousObjects/DoodadInvisible",
"Metadata/MiscellaneousObjects/DoodadNoBlocking",
"Metadata/MiscellaneousObjects/Environment/EnvLineEnd",
"Metadata/MiscellaneousObjects/Environment/EnvLineStart",
"Metadata/MiscellaneousObjects/Environment/NonDefaultFlows/FlowBlocking_10_1",
"Metadata/MiscellaneousObjects/Environment/NonDefaultFlows/FlowBlocking_15_1",
"Metadata/MiscellaneousObjects/Environment/NonDefaultFlows/FlowBlocking_20_1",
"Metadata/MiscellaneousObjects/Environment/NonDefaultFlows/FlowBlocking_5_1",
"Metadata/MiscellaneousObjects/Environment/NonDefaultFlows/FlowBlocking_6_1",
@ -76,6 +86,7 @@
"Metadata/MiscellaneousObjects/Environment/NonDefaultFlows/FlowSink_6_4",
"Metadata/MiscellaneousObjects/Environment/NonDefaultFlows/FlowSink_8_8",
"Metadata/MiscellaneousObjects/Environment/NonDefaultFlows/FlowSource_4.75_1",
"Metadata/MiscellaneousObjects/Environment/NonDefaultFlows/FlowSource_4_4",
"Metadata/MiscellaneousObjects/Environment/NonDefaultFlows/FlowSource_6_4",
"Metadata/MiscellaneousObjects/Environment/NonDefaultFlows/FlowSource_6_6",
"Metadata/MiscellaneousObjects/Environment/NonDefaultFlows/FlowSource_8_8",
@ -100,7 +111,11 @@
"Metadata/Monsters/BansheeRemake/WitchHutBanshee",
"Metadata/Monsters/CarnivorousPlantEater/OldForest/BossRoomMinimapIcon",
"Metadata/Monsters/CarnivorousPlantEater/OldForest/CarnivorousPlantEaterOldForest_",
"Metadata/Monsters/Daemon/Archnemesis/FlameWalkerDaemon",
"Metadata/Monsters/Daemon/FungalBurstDaemon",
"Metadata/Monsters/Daemon/LightningCloneRetaliationDaemon",
"Metadata/Monsters/Daemon/RustKingQuestChestDaemon_",
"Metadata/Monsters/Frog/PaleFrog1",
"Metadata/Monsters/FungusZombie/FungalBurstMushrooms/FungalBurstSpawner",
"Metadata/Monsters/FungusZombie/FungusZombieLarge",
"Metadata/Monsters/FungusZombie/FungusZombieMedium",
@ -112,6 +127,7 @@
"Metadata/Monsters/HuhuGrub/HuhuGrubLarvaeEmergeSummoned1_",
"Metadata/Monsters/HuhuGrub/HuhuGrubLarvaeRanged1",
"Metadata/Monsters/InvisibleFire/MDCarrionCroneWave",
"Metadata/Monsters/MonsterMods/GroundOnDeath/ColdSnapGroundDaemonParent",
"Metadata/Monsters/MonsterMods/GroundOnDeath/ShockedGroundDaemonParent",
"Metadata/Monsters/MonsterMods/OnDeathColdExplosionParent",
"Metadata/Monsters/MudBurrower/Arena_Blocker",
@ -127,6 +143,14 @@
"Metadata/Monsters/NPC/DogTrader_",
"Metadata/Monsters/QuillCrab/QuillCrab",
"Metadata/Monsters/QuillCrab/QuillCrabBig",
"Metadata/Monsters/RisenArbalest__",
"Metadata/Monsters/SkeletonSoldier/Rusted/RustedSoldierBow",
"Metadata/Monsters/SkeletonSoldier/Rusted/RustedSoldierBowQuest",
"Metadata/Monsters/SkeletonSoldier/Rusted/RustedSoldierCrossbow",
"Metadata/Monsters/SkeletonSoldier/Rusted/RustedSoldierOneHandSword",
"Metadata/Monsters/SkeletonSoldier/Rusted/RustedSoldierOneHandSwordQuest",
"Metadata/Monsters/SkeletonSoldier/Rusted/RustedSoldierOneHandSwordShield",
"Metadata/Monsters/SkeletonSoldier/Rusted/RustedSoldierOneHandSwordShieldQuest",
"Metadata/Monsters/Skeletons/RetchSkeletonOneHandSword",
"Metadata/Monsters/Skeletons/RetchSkeletonOneHandSwordShield",
"Metadata/Monsters/SnakeFlowerMan/BloomSerpentEmerge1",
@ -202,17 +226,22 @@
"Metadata/Pet/ScavengerBat/ScavengerBat",
"Metadata/Pet/WayfinderWolf/WayfinderWolf",
"Metadata/Projectiles/CarrionCroneIceSpear",
"Metadata/Projectiles/DefaultArrow",
"Metadata/Projectiles/Fireball",
"Metadata/Projectiles/HagBossIceShard",
"Metadata/Projectiles/HuhuGrubLarvaeMortar",
"Metadata/Projectiles/IceSpear",
"Metadata/Projectiles/MonsterLightningArrowMock",
"Metadata/Projectiles/MudBurrowerAcidMortarSmall",
"Metadata/Projectiles/MudBurrowerBloodProj",
"Metadata/Projectiles/MudBurrowerGoopMortar",
"Metadata/Projectiles/MudBurrowerGoopProjectile",
"Metadata/Projectiles/QuillCrabShrapnel",
"Metadata/Projectiles/QuillCrabSpike",
"Metadata/Projectiles/RisenArbalestBasicProjectile",
"Metadata/Projectiles/RisenArbalestBurningSnipe",
"Metadata/Projectiles/SlingUrchinProjectile",
"Metadata/Projectiles/SnakeFlowerManSpit",
"Metadata/Projectiles/Spark",
"Metadata/Projectiles/Twister",
"Metadata/QuestObjects/Four_Act1/TreeOfSoulsRoots",
@ -245,12 +274,20 @@
"Metadata/Terrain/Gallows/Act1/1_4/Objects/TreeOfSoulsNailStake2",
"Metadata/Terrain/Gallows/Act1/1_4/Objects/TreeOfSoulsNailStake3",
"Metadata/Terrain/Gallows/Act1/1_4/Objects/WitchHutTitle",
"Metadata/Terrain/Gallows/Act1/1_5/Objects/Controller1",
"Metadata/Terrain/Gallows/Act1/1_5/Objects/Controller2",
"Metadata/Terrain/Gallows/Act1/1_5/Objects/Controller3",
"Metadata/Terrain/Gallows/Act1/1_5/Objects/ControllerSpawner",
"Metadata/Terrain/Gallows/Act1/1_5/Objects/QuestChestBase",
"Metadata/Terrain/Gallows/Act1/1_5/Objects/T2_WeaponDrop",
"Metadata/Terrain/Gallows/Act1/1_5/Objects/TheRustKingInert",
"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/Gallows/Leagues/Incursion/Objects/TemplePortal",
"Metadata/Terrain/Tools/AudioTools/G1_1/TownEntrance",
"Metadata/Terrain/Tools/AudioTools/G1_2/BurrowEntrance",
"Metadata/Terrain/Tools/AudioTools/G1_2/ForestEntrance",
@ -258,6 +295,7 @@
"Metadata/Terrain/Tools/AudioTools/G1_2/RiverRapidsMedium",
"Metadata/Terrain/Tools/AudioTools/G1_3/TunnelA",
"Metadata/Terrain/Tools/AudioTools/G1_4/WitchHutIndoorAudio",
"Metadata/Terrain/Tools/AudioTools/G1_5/BattleAudio",
"Metadata/Terrain/Tools/AudioTools/G1_5/OldForestEntrance",
"Metadata/Terrain/Tools/AudioTools/G1_Town/FurnaceFireAudio",
"Metadata/Terrain/Tools/AudioTools/G1_Town/InsideWaterMillAudio"

View file

@ -1,166 +0,0 @@
{
"Name": "GooGoGaaGa_Default_Copy",
"CreatedAt": "2026-03-03T16:40:21.060139Z",
"LastModified": "2026-03-03T16:57:12.368683Z",
"Flasks": {
"LifeFlaskThreshold": 50,
"ManaFlaskThreshold": 50,
"FlaskCooldownMs": 4000,
"LifeFlaskScanCode": 2,
"ManaFlaskScanCode": 3
},
"Combat": {
"GlobalCooldownMs": 500,
"AttackRange": 600,
"SafeRange": 400,
"KiteEnabled": false,
"KiteRange": 300,
"KiteDelayMs": 200
},
"Skills": [
{
"SlotIndex": 0,
"Label": "LMB",
"SkillName": "MeleeSpearOffHand",
"InputType": "LeftClick",
"ScanCode": 0,
"Priority": 2,
"IsEnabled": true,
"CooldownMs": 300,
"RangeMin": 0,
"RangeMax": 10,
"TargetSelection": "Nearest",
"RequiresTarget": true,
"IsAura": false,
"IsMovementSkill": false,
"MinMonstersInRange": 1,
"MaintainPressed": false
},
{
"SlotIndex": 1,
"Label": "RMB",
"SkillName": "SpearThrow",
"InputType": "RightClick",
"ScanCode": 0,
"Priority": 1,
"IsEnabled": true,
"CooldownMs": 300,
"RangeMin": 0,
"RangeMax": 800,
"TargetSelection": "All",
"RequiresTarget": true,
"IsAura": false,
"IsMovementSkill": false,
"MinMonstersInRange": 1,
"MaintainPressed": false
},
{
"SlotIndex": 2,
"Label": "MMB",
"SkillName": "Twister",
"InputType": "MiddleClick",
"ScanCode": 0,
"Priority": 0,
"IsEnabled": true,
"CooldownMs": 300,
"RangeMin": 0,
"RangeMax": 1000,
"TargetSelection": "All",
"RequiresTarget": true,
"IsAura": false,
"IsMovementSkill": false,
"MinMonstersInRange": 1,
"MaintainPressed": false
},
{
"SlotIndex": 3,
"Label": "Q",
"SkillName": null,
"InputType": "KeyPress",
"ScanCode": 16,
"Priority": 3,
"IsEnabled": true,
"CooldownMs": 300,
"RangeMin": 0,
"RangeMax": 600,
"TargetSelection": "Nearest",
"RequiresTarget": true,
"IsAura": false,
"IsMovementSkill": false,
"MinMonstersInRange": 1,
"MaintainPressed": false
},
{
"SlotIndex": 4,
"Label": "E",
"SkillName": null,
"InputType": "KeyPress",
"ScanCode": 18,
"Priority": 4,
"IsEnabled": true,
"CooldownMs": 300,
"RangeMin": 0,
"RangeMax": 600,
"TargetSelection": "Nearest",
"RequiresTarget": true,
"IsAura": false,
"IsMovementSkill": false,
"MinMonstersInRange": 1,
"MaintainPressed": false
},
{
"SlotIndex": 5,
"Label": "R",
"SkillName": null,
"InputType": "KeyPress",
"ScanCode": 19,
"Priority": 5,
"IsEnabled": true,
"CooldownMs": 300,
"RangeMin": 0,
"RangeMax": 600,
"TargetSelection": "Nearest",
"RequiresTarget": true,
"IsAura": false,
"IsMovementSkill": false,
"MinMonstersInRange": 1,
"MaintainPressed": false
},
{
"SlotIndex": 6,
"Label": "T",
"SkillName": null,
"InputType": "KeyPress",
"ScanCode": 20,
"Priority": 6,
"IsEnabled": true,
"CooldownMs": 300,
"RangeMin": 0,
"RangeMax": 600,
"TargetSelection": "Nearest",
"RequiresTarget": true,
"IsAura": false,
"IsMovementSkill": false,
"MinMonstersInRange": 1,
"MaintainPressed": false
},
{
"SlotIndex": 7,
"Label": "F",
"SkillName": null,
"InputType": "KeyPress",
"ScanCode": 33,
"Priority": 7,
"IsEnabled": true,
"CooldownMs": 300,
"RangeMin": 0,
"RangeMax": 600,
"TargetSelection": "Nearest",
"RequiresTarget": true,
"IsAura": false,
"IsMovementSkill": false,
"MinMonstersInRange": 1,
"MaintainPressed": false
}
]
}

View file

@ -1,5 +0,0 @@
{
"GooGoGaaGa": "GooGoGaaGa_Default_Copy",
"terdsare": "terdsare_Default",
"dudemoko": "dudemoko_Default"
}

88
profiles/_config.json Normal file
View file

@ -0,0 +1,88 @@
{
"LastCharacter": "GooGoGaaGa",
"Assignments": {
"GooGoGaaGa": "dudemoko_Default",
"terdsare": "terdsare_Default",
"dudemoko": "dudemoko_Default"
},
"SkillDefaults": {
"MeleeSpearOffHand": {
"Priority": 0,
"IsEnabled": true,
"CooldownMs": 300,
"RangeMin": 0,
"RangeMax": 100,
"TargetSelection": "Nearest",
"RequiresTarget": true,
"IsAura": false,
"IsMovementSkill": false,
"MinMonstersInRange": 1,
"MaintainPressed": false
},
"SpearThrow": {
"Priority": 2,
"IsEnabled": true,
"CooldownMs": 300,
"RangeMin": 0,
"RangeMax": 800,
"TargetSelection": "Nearest",
"RequiresTarget": true,
"IsAura": false,
"IsMovementSkill": false,
"MinMonstersInRange": 1,
"MaintainPressed": false
},
"ShieldBlock": {
"Priority": 2,
"IsEnabled": true,
"CooldownMs": 300,
"RangeMin": 0,
"RangeMax": 600,
"TargetSelection": "Nearest",
"RequiresTarget": true,
"IsAura": false,
"IsMovementSkill": false,
"MinMonstersInRange": 1,
"MaintainPressed": false
},
"WhirlingSlash": {
"Priority": 0,
"IsEnabled": true,
"CooldownMs": 300,
"RangeMin": 0,
"RangeMax": 300,
"TargetSelection": "All",
"RequiresTarget": false,
"IsAura": false,
"IsMovementSkill": false,
"MinMonstersInRange": 3,
"MaintainPressed": false
},
"Twister": {
"Priority": 0,
"IsEnabled": true,
"CooldownMs": 300,
"RangeMin": 0,
"RangeMax": 800,
"TargetSelection": "All",
"RequiresTarget": false,
"IsAura": false,
"IsMovementSkill": false,
"MinMonstersInRange": 1,
"MaintainPressed": false
},
"Spark": {
"Priority": 0,
"IsEnabled": true,
"CooldownMs": 300,
"RangeMin": 0,
"RangeMax": 1000,
"TargetSelection": "Nearest",
"RequiresTarget": false,
"IsAura": false,
"IsMovementSkill": false,
"MinMonstersInRange": 1,
"MaintainPressed": false
}
}
}

View file

@ -1,7 +1,7 @@
{
"Name": "dudemoko_Default",
"CreatedAt": "2026-03-05T05:44:54.8014147Z",
"LastModified": "2026-03-05T05:44:54.8014154Z",
"LastModified": "2026-03-06T03:52:50.0165867Z",
"Flasks": {
"LifeFlaskThreshold": 50,
"ManaFlaskThreshold": 50,
@ -12,8 +12,8 @@
"Combat": {
"GlobalCooldownMs": 500,
"AttackRange": 600,
"SafeRange": 400,
"KiteEnabled": false,
"SafeRange": 300,
"KiteEnabled": true,
"KiteRange": 300,
"KiteDelayMs": 200
},
@ -21,14 +21,14 @@
{
"SlotIndex": 0,
"Label": "LMB",
"SkillName": null,
"SkillName": "MeleeSpearOffHand",
"InputType": "LeftClick",
"ScanCode": 0,
"Priority": 0,
"IsEnabled": true,
"CooldownMs": 300,
"RangeMin": 0,
"RangeMax": 600,
"RangeMax": 100,
"TargetSelection": "Nearest",
"RequiresTarget": true,
"IsAura": false,
@ -39,14 +39,14 @@
{
"SlotIndex": 1,
"Label": "RMB",
"SkillName": null,
"SkillName": "SpearThrow",
"InputType": "RightClick",
"ScanCode": 0,
"Priority": 1,
"Priority": 2,
"IsEnabled": true,
"CooldownMs": 300,
"RangeMin": 0,
"RangeMax": 600,
"RangeMax": 800,
"TargetSelection": "Nearest",
"RequiresTarget": true,
"IsAura": false,
@ -57,7 +57,7 @@
{
"SlotIndex": 2,
"Label": "MMB",
"SkillName": null,
"SkillName": "ShieldBlock",
"InputType": "MiddleClick",
"ScanCode": 0,
"Priority": 2,
@ -75,34 +75,34 @@
{
"SlotIndex": 3,
"Label": "Q",
"SkillName": null,
"SkillName": "WhirlingSlash",
"InputType": "KeyPress",
"ScanCode": 16,
"Priority": 3,
"Priority": 0,
"IsEnabled": true,
"CooldownMs": 300,
"RangeMin": 0,
"RangeMax": 600,
"TargetSelection": "Nearest",
"RequiresTarget": true,
"RangeMax": 300,
"TargetSelection": "All",
"RequiresTarget": false,
"IsAura": false,
"IsMovementSkill": false,
"MinMonstersInRange": 1,
"MinMonstersInRange": 3,
"MaintainPressed": false
},
{
"SlotIndex": 4,
"Label": "E",
"SkillName": null,
"SkillName": "Twister",
"InputType": "KeyPress",
"ScanCode": 18,
"Priority": 4,
"Priority": 0,
"IsEnabled": true,
"CooldownMs": 300,
"RangeMin": 0,
"RangeMax": 600,
"TargetSelection": "Nearest",
"RequiresTarget": true,
"RangeMax": 800,
"TargetSelection": "All",
"RequiresTarget": false,
"IsAura": false,
"IsMovementSkill": false,
"MinMonstersInRange": 1,
@ -111,7 +111,7 @@
{
"SlotIndex": 5,
"Label": "R",
"SkillName": null,
"SkillName": "Spark",
"InputType": "KeyPress",
"ScanCode": 19,
"Priority": 5,
@ -129,7 +129,7 @@
{
"SlotIndex": 6,
"Label": "T",
"SkillName": null,
"SkillName": "MeleeUnarmed",
"InputType": "KeyPress",
"ScanCode": 20,
"Priority": 6,

View file

@ -145,6 +145,13 @@ internal static partial class D2dNativeMethods
[LibraryImport("winmm.dll")]
internal static partial uint timeEndPeriod(uint uPeriod);
// --- user32.dll (input) ---
[LibraryImport("user32.dll")]
internal static partial short GetAsyncKeyState(int vKey);
internal const int VK_F10 = 0x79;
// --- Helpers ---
internal static void ShowNoActivate(nint hwnd) => ShowWindow(hwnd, SW_SHOWNOACTIVATE);

View file

@ -3,6 +3,7 @@ using System.Runtime;
using System.Runtime.InteropServices;
using Automata.Bot;
using Automata.Ui.Overlay.Layers;
using Roboto.Memory;
using Vortice.Mathematics;
using static Automata.Ui.Overlay.D2dNativeMethods;
@ -94,6 +95,7 @@ public sealed class D2dOverlay
int focusCounter = 0;
bool shown = true;
long lastFrameTimestamp = Stopwatch.GetTimestamp();
bool f10WasDown = false;
ShowNoActivate(hwnd);
Console.WriteLine($"[D2dOverlay] Started (hwnd={hwnd:X})");
@ -108,6 +110,12 @@ public sealed class D2dOverlay
UpdateFocusVisibility(ref focusCounter, ref shown, hwnd);
UpdateFps(fpsWatch, ref frameCount, ref fps);
// F10 toggle for memory profiler
var f10Down = (GetAsyncKeyState(VK_F10) & 0x8000) != 0;
if (f10Down && !f10WasDown)
MemoryProfiler.IsEnabled = !MemoryProfiler.IsEnabled;
f10WasDown = f10Down;
var state = BuildState(fps, timing);
var snapMs = ElapsedMs(frameStart);
@ -203,7 +211,8 @@ public sealed class D2dOverlay
LootLabels: _bot.LootDebugDetector.Latest,
FightPosition: _bot.KulemakExecutor.FightPosition,
Fps: fps,
Timing: timing);
Timing: timing,
ProfilerData: MemoryProfiler.LatestData);
}
private void Render(D2dRenderContext ctx, OverlayState state, double[] layerMs, int layerCount)

View file

@ -36,6 +36,12 @@ public sealed class D2dRenderContext : IDisposable
public ID2D1SolidColorBrush DebugTextBrush { get; private set; } = null!;
public ID2D1SolidColorBrush TimingBrush { get; private set; } = null!;
public ID2D1SolidColorBrush DebugBgBrush { get; private set; } = null!;
public ID2D1SolidColorBrush ProfilerBrush { get; private set; } = null!;
// Rarity brushes for entity labels
public ID2D1SolidColorBrush MagicBrush { get; private set; } = null!;
public ID2D1SolidColorBrush RareBrush { get; private set; } = null!;
public ID2D1SolidColorBrush UniqueBrush { get; private set; } = null!;
// Text formats
public IDWriteTextFormat LabelFormat { get; } // 12pt — enemy labels
@ -93,6 +99,10 @@ public sealed class D2dRenderContext : IDisposable
DebugTextBrush = RenderTarget.CreateSolidColorBrush(new Color4(80 / 255f, 1f, 80 / 255f, 1f));
TimingBrush = RenderTarget.CreateSolidColorBrush(new Color4(1f, 200 / 255f, 80 / 255f, 1f));
DebugBgBrush = RenderTarget.CreateSolidColorBrush(new Color4(0f, 0f, 0f, 160 / 255f));
ProfilerBrush = RenderTarget.CreateSolidColorBrush(new Color4(180 / 255f, 140 / 255f, 1f, 1f)); // light purple
MagicBrush = RenderTarget.CreateSolidColorBrush(new Color4(0.4f, 0.53f, 1f, 1f)); // #6688FF
RareBrush = RenderTarget.CreateSolidColorBrush(new Color4(1f, 0.93f, 0.34f, 1f)); // #FFEE57
UniqueBrush = RenderTarget.CreateSolidColorBrush(new Color4(1f, 0.55f, 0f, 1f)); // #FF8C00
}
private void DisposeBrushes()
@ -111,6 +121,10 @@ public sealed class D2dRenderContext : IDisposable
DebugTextBrush?.Dispose();
TimingBrush?.Dispose();
DebugBgBrush?.Dispose();
ProfilerBrush?.Dispose();
MagicBrush?.Dispose();
RareBrush?.Dispose();
UniqueBrush?.Dispose();
}
/// <summary>

View file

@ -19,7 +19,8 @@ public record OverlayState(
IReadOnlyList<LootLabel> LootLabels,
(double X, double Y)? FightPosition,
double Fps,
RenderTiming? Timing);
RenderTiming? Timing,
Dictionary<string, (long Reads, long Bytes)>? ProfilerData = null);
public class RenderTiming
{

View file

@ -15,11 +15,12 @@ internal sealed class D2dDebugTextLayer : ID2dOverlayLayer, IDisposable
private readonly CachedLine[] _left = new CachedLine[8];
private readonly CachedLine[] _right = new CachedLine[8];
private readonly CachedLine[] _profiler = new CachedLine[20];
public void Draw(D2dRenderContext ctx, OverlayState state)
{
var rt = ctx.RenderTarget;
int lc = 0, rc = 0;
int lc = 0, rc = 0, pc = 0;
// Left column: game state
UpdateCache(ctx, _left, ref lc, $"FPS: {state.Fps:F0}", ctx.DebugTextBrush);
@ -40,12 +41,28 @@ internal sealed class D2dDebugTextLayer : ID2dOverlayLayer, IDisposable
UpdateCache(ctx, _right, ref rc, $"render total: {t.TotalRenderMs:F2}ms", ctx.TimingBrush);
}
// Profiler column (F10 toggle)
if (state.ProfilerData is { Count: > 0 } data)
{
UpdateCache(ctx, _profiler, ref pc, "-- Profiler (F10) --", ctx.ProfilerBrush);
long totalReads = 0, totalBytes = 0;
foreach (var kvp in data.OrderByDescending(x => x.Value.Reads))
{
var (reads, bytes) = kvp.Value;
totalReads += reads;
totalBytes += bytes;
UpdateCache(ctx, _profiler, ref pc, $"{kvp.Key,-14} {reads,5}r {bytes / 1024,4}KB", ctx.ProfilerBrush);
}
UpdateCache(ctx, _profiler, ref pc, $"{"Total",-14} {totalReads,5}r {totalBytes / 1024,4}KB", ctx.ProfilerBrush);
}
// Measure columns
Measure(_left, lc, out var leftW, out var leftH);
Measure(_right, rc, out var rightW, out var rightH);
Measure(_profiler, pc, out var profW, out var profH);
var totalW = leftW + (rc > 0 ? ColumnGap + rightW : 0);
var totalH = Math.Max(leftH, rightH);
var totalW = leftW + (rc > 0 ? ColumnGap + rightW : 0) + (pc > 0 ? ColumnGap + profW : 0);
var totalH = Math.Max(Math.Max(leftH, rightH), profH);
// Background
rt.FillRectangle(
@ -53,9 +70,20 @@ internal sealed class D2dDebugTextLayer : ID2dOverlayLayer, IDisposable
ctx.DebugBgBrush);
// Draw columns
DrawColumn(rt, _left, lc, StartX, StartY);
var x = StartX;
DrawColumn(rt, _left, lc, x, StartY);
x += leftW;
if (rc > 0)
DrawColumn(rt, _right, rc, StartX + leftW + ColumnGap, StartY);
{
x += ColumnGap;
DrawColumn(rt, _right, rc, x, StartY);
x += rightW;
}
if (pc > 0)
{
x += ColumnGap;
DrawColumn(rt, _profiler, pc, x, StartY);
}
}
private static void Measure(CachedLine[] col, int count, out float maxW, out float totalH)
@ -98,6 +126,7 @@ internal sealed class D2dDebugTextLayer : ID2dOverlayLayer, IDisposable
{
for (int i = 0; i < _left.Length; i++) _left[i].Layout?.Dispose();
for (int i = 0; i < _right.Length; i++) _right[i].Layout?.Dispose();
for (int i = 0; i < _profiler.Length; i++) _profiler[i].Layout?.Dispose();
}
private struct CachedLine

View file

@ -86,7 +86,14 @@ internal sealed class D2dEntityLabelLayer : ID2dOverlayLayer, IDisposable
new RectangleF(labelX - 2, labelY - 1, m.Width + 4, m.Height + 2),
ctx.LabelBgBrush);
rt.DrawTextLayout(new Vector2(labelX, labelY), layout, ctx.Cyan);
var brush = entry.Rarity switch
{
1 => ctx.MagicBrush,
2 => ctx.RareBrush,
3 => ctx.UniqueBrush,
_ => ctx.White,
};
rt.DrawTextLayout(new Vector2(labelX, labelY), layout, brush);
}
}

View file

@ -14,6 +14,7 @@ namespace Automata.Ui.ViewModels;
public partial class MemoryNodeViewModel : ObservableObject
{
[ObservableProperty] private string _name;
[ObservableProperty] private string _nameColor = "#8b949e";
[ObservableProperty] private string _value = "";
[ObservableProperty] private string _valueColor = "#484f58";
[ObservableProperty] private bool _isExpanded;
@ -995,8 +996,9 @@ public partial class MemoryViewModel : ObservableObject
{
if (_entityListNode is null) return;
// Group by type, sorted by type name
// Group by type, sorted by type name (exclude dead entities)
var groups = entities
.Where(e => e.IsAlive || !e.HasVitals)
.GroupBy(e => e.Type)
.OrderBy(g => g.Key.ToString());
@ -1031,17 +1033,19 @@ public partial class MemoryViewModel : ObservableObject
var e = sorted[i];
var label = FormatEntityName(e);
var value = FormatEntityValue(e);
var color = RarityColor(e.Rarity);
if (i < groupNode.Children.Count)
{
var existing = groupNode.Children[i];
existing.Name = label;
existing.NameColor = color;
existing.Set(value, e.HasPosition);
UpdateEntityChildren(existing, e);
}
else
{
var node = new MemoryNodeViewModel(label) { IsExpanded = false };
var node = new MemoryNodeViewModel(label) { IsExpanded = false, NameColor = color };
node.Set(value, e.HasPosition);
UpdateEntityChildren(node, e);
groupNode.Children.Add(node);
@ -1105,6 +1109,17 @@ public partial class MemoryViewModel : ObservableObject
return parts.Count > 0 ? string.Join(" ", parts) : "—";
}
/// <summary>
/// Map entity rarity to label color: white=normal, blue=magic, yellow=rare, orange=unique.
/// </summary>
private static string RarityColor(int rarity) => rarity switch
{
1 => "#6688ff", // Magic — blue
2 => "#ffee57", // Rare — yellow
3 => "#ff8c00", // Unique — orange
_ => "#8b949e", // Normal / non-monster — default gray
};
private static void UpdateEntityChildren(MemoryNodeViewModel node, Entity e)
{
// Build children: address, position, vitals, components

View file

@ -30,12 +30,14 @@ public readonly struct EntityOverlayEntry
{
public readonly float X, Y;
public readonly string Label;
public readonly int Rarity; // 0=Normal, 1=Magic, 2=Rare, 3=Unique
public EntityOverlayEntry(float x, float y, string label)
public EntityOverlayEntry(float x, float y, string label, int rarity = 0)
{
X = x;
Y = y;
Label = label;
Rarity = rarity;
}
}
@ -50,10 +52,11 @@ public partial class EntityListItem : ObservableObject
public string Distance { get; set; }
public float X { get; set; }
public float Y { get; set; }
public int Rarity { get; }
[ObservableProperty] private bool _isChecked;
public EntityListItem(uint id, string label, string category, float distance, float x, float y)
public EntityListItem(uint id, string label, string category, float distance, float x, float y, int rarity = 0)
{
Id = id;
Label = label;
@ -61,6 +64,7 @@ public partial class EntityListItem : ObservableObject
Distance = $"{distance:F0}";
X = x;
Y = y;
Rarity = rarity;
}
}
@ -93,11 +97,21 @@ public partial class RobotoViewModel : ObservableObject, IDisposable
// Systems
[ObservableProperty] private string _systemsInfo = "—";
[ObservableProperty] private string _inactiveSystems = "";
[ObservableProperty] private string _apmInfo = "0";
// Navigation
[ObservableProperty] private string _navMode = "Idle";
[ObservableProperty] private string _navStatus = "—";
[ObservableProperty] private string _progressionPhase = "—";
[ObservableProperty] private string _progressionTarget = "—";
[ObservableProperty] private string _questTarget = "—";
[ObservableProperty] private string _lootStatus = "—";
// Memory stats
[ObservableProperty] private string _memReads = "—";
[ObservableProperty] private string _memBandwidth = "—";
[ObservableProperty] private string _memEntities = "—";
// Terrain minimap
[ObservableProperty] private Bitmap? _terrainImage;
@ -183,6 +197,17 @@ public partial class RobotoViewModel : ObservableObject, IDisposable
logWatcher.AreaEntered += area => _engine.SetCurrentAreaName(area);
if (logWatcher.CurrentArea is { Length: > 0 } current)
_engine.SetCurrentAreaName(current);
// Load last character's profile so the Profile tab is populated before bot starts
var lastChar = _engine.Profiles.GetLastCharacter()
?? _engine.Profiles.GetMostRecentCharacter();
if (lastChar is { Length: > 0 })
{
CharacterName = lastChar;
var profile = _engine.Profiles.LoadForCharacter(lastChar);
_engine.ApplyProfile(profile);
PopulateFromProfile(profile);
}
}
[RelayCommand]
@ -215,6 +240,13 @@ public partial class RobotoViewModel : ObservableObject, IDisposable
ApmInfo = "0";
NavMode = "Idle";
NavStatus = "—";
ProgressionPhase = "—";
ProgressionTarget = "—";
QuestTarget = "—";
LootStatus = "—";
MemReads = "—";
MemBandwidth = "—";
MemEntities = "—";
Entities.Clear();
OverlayData = null;
SharedCache = null;
@ -280,8 +312,8 @@ public partial class RobotoViewModel : ObservableObject, IDisposable
[RelayCommand]
private void DeleteProfile()
{
var charName = _engine.Cache.CharacterName;
if (charName is null || SelectedProfile is null) return;
var charName = CharacterName;
if (charName is "—" or null || SelectedProfile is null) return;
// Don't allow deleting the last profile
if (AvailableProfiles.Count <= 1) return;
@ -308,8 +340,8 @@ public partial class RobotoViewModel : ObservableObject, IDisposable
{
if (_suppressProfileSwitch || value is null) return;
var charName = _engine.Cache.CharacterName;
if (charName is null) return;
var charName = CharacterName;
if (charName is "—" or null) return;
// Don't re-switch if it's already the active profile
if (_engine.ActiveProfile?.Name == value) return;
@ -319,8 +351,8 @@ public partial class RobotoViewModel : ObservableObject, IDisposable
private void SwitchToProfile(string profileName)
{
var charName = _engine.Cache.CharacterName;
if (charName is null) return;
var charName = CharacterName;
if (charName is "—" or null) return;
_engine.Profiles.AssignToCharacter(charName, profileName);
var profile = _engine.Profiles.LoadForCharacter(charName);
@ -348,7 +380,11 @@ public partial class RobotoViewModel : ObservableObject, IDisposable
// Skills
SkillProfiles.Clear();
foreach (var skill in profile.Skills)
SkillProfiles.Add(new SkillProfileViewModel(skill));
{
var vm = new SkillProfileViewModel(skill);
vm.SetProfileManager(_engine.Profiles);
SkillProfiles.Add(vm);
}
RefreshAvailableProfiles();
_suppressProfileSwitch = true;
@ -395,9 +431,11 @@ public partial class RobotoViewModel : ObservableObject, IDisposable
PlayerMana = p.ManaTotal > 0 ? $"{p.ManaCurrent}/{p.ManaTotal} ({p.ManaPercent:F0}%)" : "—";
PlayerEs = p.EsTotal > 0 ? $"{p.EsCurrent}/{p.EsTotal} ({p.EsPercent:F0}%)" : "—";
AreaInfo = state.AreaHash != 0
? $"Level {state.AreaLevel} (0x{state.AreaHash:X8})"
: "—";
AreaInfo = state.CurrentAreaName is { Length: > 0 }
? $"{state.CurrentAreaName} (Lv {state.AreaLevel})"
: state.AreaHash != 0
? $"Level {state.AreaLevel} (0x{state.AreaHash:X8})"
: "—";
DangerLevel = state.Danger.ToString();
EntityCount = $"{state.Entities.Count} total";
HostileCount = $"{state.HostileMonsters.Count} hostile";
@ -408,16 +446,36 @@ public partial class RobotoViewModel : ObservableObject, IDisposable
var systems = _engine.Systems;
var enabled = systems.Count(s => s.IsEnabled);
SystemsInfo = $"{enabled}/{systems.Count} active";
var disabled = systems.Where(s => !s.IsEnabled).Select(s => s.Name);
InactiveSystems = string.Join(", ", disabled);
// Navigation
NavMode = _engine.Nav.Mode.ToString();
NavStatus = _engine.Nav.Status;
// Progression
var prog = _engine.Progression;
if (prog is not null)
{
ProgressionPhase = prog.PhaseName;
ProgressionTarget = prog.TargetTransitionName ?? "—";
QuestTarget = prog.QuestTargetName is { } qt
? $"{qt}{(prog.IsQuestDriven ? " (quest)" : "")}"
: "—";
LootStatus = prog.IsLootingActive ? "Picking up" : "—";
}
// Memory stats
var poller = _engine.Poller;
MemReads = $"{poller.ReadsPerSec}/s";
MemBandwidth = $"{poller.KBPerSec} KB/s";
MemEntities = $"{poller.EntityCount}";
// Character name
if (p.CharacterName is { Length: > 0 })
CharacterName = p.CharacterName;
// Populate available skill names from memory (stripped of "Player" suffix)
// Sync skill names from memory into profile slots
if (p.Skills.Count > 0)
{
var names = p.Skills
@ -429,6 +487,7 @@ public partial class RobotoViewModel : ObservableObject, IDisposable
foreach (var vm in SkillProfiles)
{
// Populate available names dropdown
var current = vm.AvailableSkillNames;
if (current.Count != names.Count || !current.SequenceEqual(names))
{
@ -436,6 +495,25 @@ public partial class RobotoViewModel : ObservableObject, IDisposable
foreach (var n in names)
current.Add(n);
}
// Auto-fill skill name from memory by matching slot index
var memSkill = p.Skills.FirstOrDefault(s => s.SlotIndex == vm.SlotIndex);
if (memSkill?.Name is { Length: > 0 })
{
var cleanName = SkillProfileViewModel.CleanSkillName(memSkill.Name);
if (vm.SkillName != cleanName)
{
vm.SkillName = cleanName;
// Apply saved defaults for this skill if available
var defaults = _engine.Profiles.GetSkillDefault(cleanName);
if (defaults is not null)
{
defaults.ApplyTo(vm.Model);
vm.RefreshFromModel();
}
}
}
}
}
@ -457,10 +535,13 @@ public partial class RobotoViewModel : ObservableObject, IDisposable
Entities.Clear();
foreach (var e in state.Entities)
{
// Skip dead monsters
if (e.Category == EntityCategory.Monster && !e.IsAlive) continue;
var shortLabel = e.Category == EntityCategory.AreaTransition && e.TransitionName is not null
? $"AreaTransition — {e.TransitionName}"
: GetShortLabel(e.Path);
var item = new EntityListItem(e.Id, shortLabel, e.Category.ToString(), e.DistanceToPlayer, e.Position.X, e.Position.Y);
var item = new EntityListItem(e.Id, shortLabel, e.Category.ToString(), e.DistanceToPlayer, e.Position.X, e.Position.Y, (int)e.Rarity);
if (checkedIds.Contains(e.Id))
item.IsChecked = true;
Entities.Add(item);
@ -472,7 +553,7 @@ public partial class RobotoViewModel : ObservableObject, IDisposable
foreach (var item in Entities)
{
if (!showAll && !item.IsChecked) continue;
overlayEntries.Add(new EntityOverlayEntry(item.X, item.Y, item.Label));
overlayEntries.Add(new EntityOverlayEntry(item.X, item.Y, item.Label, item.Rarity));
}
if (overlayEntries.Count > 0)

View file

@ -1,5 +1,6 @@
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Roboto.Core;
namespace Automata.Ui.ViewModels;
@ -7,6 +8,10 @@ namespace Automata.Ui.ViewModels;
public partial class SkillProfileViewModel : ObservableObject
{
private readonly SkillProfile _model;
private ProfileManager? _profileManager;
/// <summary>Set by the parent VM so the save-default command can persist.</summary>
public void SetProfileManager(ProfileManager pm) => _profileManager = pm;
public SkillProfileViewModel(SkillProfile model)
{
@ -71,6 +76,29 @@ public partial class SkillProfileViewModel : ObservableObject
partial void OnMinMonstersInRangeChanged(int value) => _model.MinMonstersInRange = value;
partial void OnMaintainPressedChanged(bool value) => _model.MaintainPressed = value;
[RelayCommand]
private void SaveDefault()
{
if (_profileManager is null || SkillName is not { Length: > 0 }) return;
_profileManager.SaveSkillDefault(SkillName, SkillDefaults.FromProfile(_model));
}
/// <summary>Re-read all fields from the underlying model (after defaults are applied).</summary>
public void RefreshFromModel()
{
Priority = _model.Priority;
IsEnabled = _model.IsEnabled;
CooldownMs = _model.CooldownMs;
RangeMin = _model.RangeMin;
RangeMax = _model.RangeMax;
TargetSelection = _model.TargetSelection;
RequiresTarget = _model.RequiresTarget;
IsAura = _model.IsAura;
IsMovementSkill = _model.IsMovementSkill;
MinMonstersInRange = _model.MinMonstersInRange;
MaintainPressed = _model.MaintainPressed;
}
// ComboBox binding sources
public static SkillInputType[] InputTypes { get; } =
Enum.GetValues<SkillInputType>();

View file

@ -828,7 +828,7 @@
<TreeDataTemplate ItemsSource="{Binding Children}"
x:DataType="vm:MemoryNodeViewModel">
<StackPanel Orientation="Horizontal" Spacing="6">
<TextBlock Text="{Binding Name}" Foreground="#8b949e"
<TextBlock Text="{Binding Name}" Foreground="{Binding NameColor}"
FontSize="12" />
<TextBlock Text="{Binding Value}" Foreground="{Binding ValueColor}"
FontSize="12" FontFamily="Consolas" />
@ -859,11 +859,132 @@
<TextBlock Text="{Binding StatusText}" Foreground="#58a6ff"
FontWeight="SemiBold" VerticalAlignment="Center"
Margin="12,0,0,0" />
<CheckBox IsChecked="{Binding ShowAllEntities}" Content="Show All Entities on Overlay"
FontSize="11" Foreground="#8b949e" VerticalAlignment="Center"
MinWidth="0" Padding="4,0,0,0" Margin="12,0,0,0" />
</StackPanel>
</StackPanel>
</Border>
<!-- Character Profile -->
<!-- Player + Game State side by side -->
<Grid ColumnDefinitions="*,8,*">
<!-- Player State -->
<Border Grid.Column="0" Background="#161b22" BorderBrush="#30363d" BorderThickness="1"
CornerRadius="8" Padding="8">
<StackPanel Spacing="4">
<TextBlock Text="PLAYER" FontSize="11" FontWeight="SemiBold"
Foreground="#8b949e" />
<Grid ColumnDefinitions="80,*" RowDefinitions="Auto,Auto,Auto,Auto" Margin="0,4,0,0">
<TextBlock Grid.Row="0" Grid.Column="0" Text="Position:" Foreground="#8b949e" FontSize="12" />
<TextBlock Grid.Row="0" Grid.Column="1" Text="{Binding PlayerPosition}" Foreground="#e6edf3" FontFamily="Consolas" FontSize="12" />
<TextBlock Grid.Row="1" Grid.Column="0" Text="Life:" Foreground="#8b949e" FontSize="12" />
<TextBlock Grid.Row="1" Grid.Column="1" Text="{Binding PlayerLife}" Foreground="#3fb950" FontFamily="Consolas" FontSize="12" />
<TextBlock Grid.Row="2" Grid.Column="0" Text="Mana:" Foreground="#8b949e" FontSize="12" />
<TextBlock Grid.Row="2" Grid.Column="1" Text="{Binding PlayerMana}" Foreground="#58a6ff" FontFamily="Consolas" FontSize="12" />
<TextBlock Grid.Row="3" Grid.Column="0" Text="ES:" Foreground="#8b949e" FontSize="12" />
<TextBlock Grid.Row="3" Grid.Column="1" Text="{Binding PlayerEs}" Foreground="#bc8cff" FontFamily="Consolas" FontSize="12" />
</Grid>
</StackPanel>
</Border>
<!-- Game State -->
<Border Grid.Column="2" Background="#161b22" BorderBrush="#30363d" BorderThickness="1"
CornerRadius="8" Padding="8">
<StackPanel Spacing="4">
<TextBlock Text="GAME STATE" FontSize="11" FontWeight="SemiBold"
Foreground="#8b949e" />
<Grid ColumnDefinitions="80,*" RowDefinitions="Auto,Auto,Auto,Auto,Auto,Auto,Auto" Margin="0,4,0,0">
<TextBlock Grid.Row="0" Grid.Column="0" Text="Area:" Foreground="#8b949e" FontSize="12" />
<TextBlock Grid.Row="0" Grid.Column="1" Text="{Binding AreaInfo}" Foreground="#e6edf3" FontFamily="Consolas" FontSize="12" />
<TextBlock Grid.Row="1" Grid.Column="0" Text="Danger:" Foreground="#8b949e" FontSize="12" />
<TextBlock Grid.Row="1" Grid.Column="1" Text="{Binding DangerLevel}" Foreground="#f0883e" FontFamily="Consolas" FontSize="12" />
<TextBlock Grid.Row="2" Grid.Column="0" Text="Entities:" Foreground="#8b949e" FontSize="12" />
<TextBlock Grid.Row="2" Grid.Column="1" Text="{Binding EntityCount}" Foreground="#e6edf3" FontFamily="Consolas" FontSize="12" />
<TextBlock Grid.Row="3" Grid.Column="0" Text="Hostiles:" Foreground="#8b949e" FontSize="12" />
<TextBlock Grid.Row="3" Grid.Column="1" Text="{Binding HostileCount}" Foreground="#ff4444" FontFamily="Consolas" FontSize="12" />
<TextBlock Grid.Row="4" Grid.Column="0" Text="APM:" Foreground="#8b949e" FontSize="12" />
<TextBlock Grid.Row="4" Grid.Column="1" Text="{Binding ApmInfo}" Foreground="#58a6ff" FontFamily="Consolas" FontSize="12" />
<TextBlock Grid.Row="5" Grid.Column="0" Text="Systems:" Foreground="#8b949e" FontSize="12" />
<StackPanel Grid.Row="5" Grid.Column="1" Orientation="Horizontal" Spacing="4">
<TextBlock Text="{Binding SystemsInfo}" Foreground="#e6edf3" FontFamily="Consolas" FontSize="12" />
<TextBlock Text="{Binding InactiveSystems, StringFormat='[{0}]'}" Foreground="#ff4444" FontFamily="Consolas" FontSize="12"
IsVisible="{Binding InactiveSystems, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
</StackPanel>
<TextBlock Grid.Row="6" Grid.Column="0" Text="Tick:" Foreground="#8b949e" FontSize="12" />
<TextBlock Grid.Row="6" Grid.Column="1" Text="{Binding TickInfo}" Foreground="#484f58" FontFamily="Consolas" FontSize="12" />
</Grid>
</StackPanel>
</Border>
</Grid>
<!-- Memory Stats -->
<Border Background="#161b22" BorderBrush="#30363d" BorderThickness="1"
CornerRadius="8" Padding="8">
<StackPanel Spacing="4">
<TextBlock Text="MEMORY" FontSize="11" FontWeight="SemiBold"
Foreground="#8b949e" />
<StackPanel Orientation="Horizontal" Spacing="16" Margin="0,4,0,0">
<StackPanel>
<TextBlock Text="{Binding MemReads}" FontSize="14" FontWeight="Bold"
Foreground="#58a6ff" FontFamily="Consolas" />
<TextBlock Text="READS" FontSize="10" Foreground="#8b949e" />
</StackPanel>
<StackPanel>
<TextBlock Text="{Binding MemBandwidth}" FontSize="14" FontWeight="Bold"
Foreground="#bc8cff" FontFamily="Consolas" />
<TextBlock Text="BANDWIDTH" FontSize="10" Foreground="#8b949e" />
</StackPanel>
<StackPanel>
<TextBlock Text="{Binding MemEntities}" FontSize="14" FontWeight="Bold"
Foreground="#f0883e" FontFamily="Consolas" />
<TextBlock Text="ENTITIES" FontSize="10" Foreground="#8b949e" />
</StackPanel>
</StackPanel>
</StackPanel>
</Border>
<!-- Navigation -->
<Border Background="#161b22" BorderBrush="#30363d" BorderThickness="1"
CornerRadius="8" Padding="8">
<StackPanel Spacing="6">
<TextBlock Text="NAVIGATION" FontSize="11" FontWeight="SemiBold"
Foreground="#8b949e" />
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Content="Explore" Command="{Binding ExploreCommand}"
Padding="16,6" FontWeight="Bold" />
<Button Content="Stop Nav" Command="{Binding StopNavCommand}"
Padding="16,6" FontWeight="Bold" />
</StackPanel>
<Grid ColumnDefinitions="100,*" RowDefinitions="Auto,Auto,Auto,Auto,Auto,Auto" Margin="0,4,0,0">
<TextBlock Grid.Row="0" Grid.Column="0" Text="Mode:" Foreground="#8b949e" FontSize="12" />
<TextBlock Grid.Row="0" Grid.Column="1" Text="{Binding NavMode}" Foreground="#58a6ff" FontFamily="Consolas" FontSize="12" />
<TextBlock Grid.Row="1" Grid.Column="0" Text="Status:" Foreground="#8b949e" FontSize="12" />
<TextBlock Grid.Row="1" Grid.Column="1" Text="{Binding NavStatus}" Foreground="#e6edf3" FontFamily="Consolas" FontSize="12" />
<TextBlock Grid.Row="2" Grid.Column="0" Text="Phase:" Foreground="#8b949e" FontSize="12" />
<TextBlock Grid.Row="2" Grid.Column="1" Text="{Binding ProgressionPhase}" Foreground="#bc8cff" FontFamily="Consolas" FontSize="12" />
<TextBlock Grid.Row="3" Grid.Column="0" Text="Target:" Foreground="#8b949e" FontSize="12" />
<TextBlock Grid.Row="3" Grid.Column="1" Text="{Binding ProgressionTarget}" Foreground="#e6edf3" FontFamily="Consolas" FontSize="12" />
<TextBlock Grid.Row="4" Grid.Column="0" Text="Quest:" Foreground="#8b949e" FontSize="12" />
<TextBlock Grid.Row="4" Grid.Column="1" Text="{Binding QuestTarget}" Foreground="#3fb950" FontFamily="Consolas" FontSize="12" />
<TextBlock Grid.Row="5" Grid.Column="0" Text="Loot:" Foreground="#8b949e" FontSize="12" />
<TextBlock Grid.Row="5" Grid.Column="1" Text="{Binding LootStatus}" Foreground="#f0883e" FontFamily="Consolas" FontSize="12" />
</Grid>
<Image Source="{Binding TerrainImage}" Width="400" Height="400"
Stretch="Uniform" Margin="0,4,0,0" />
</StackPanel>
</Border>
</StackPanel>
</ScrollViewer>
</TabItem>
<!-- ========== PROFILE TAB ========== -->
<TabItem Header="Profile">
<ScrollViewer DataContext="{Binding RobotoVm}" Margin="0,6,0,0">
<StackPanel Spacing="8" Margin="6" x:DataType="vm:RobotoViewModel">
<Border Background="#161b22" BorderBrush="#30363d" BorderThickness="1"
CornerRadius="8" Padding="8"
IsVisible="{Binding HasProfile}">
@ -889,7 +1010,7 @@
<!-- Flask Settings -->
<Expander Header="Flask Settings" Foreground="#8b949e" FontSize="11" Padding="0">
<Grid ColumnDefinitions="140,120,20,140,120" RowDefinitions="Auto,Auto" Margin="4">
<Grid ColumnDefinitions="140,140,20,140,140" RowDefinitions="Auto,Auto" Margin="4">
<TextBlock Grid.Row="0" Grid.Column="0" Text="Life Threshold %:" Foreground="#8b949e" FontSize="11" VerticalAlignment="Center" />
<NumericUpDown Grid.Row="0" Grid.Column="1" Value="{Binding LifeFlaskThreshold}" Minimum="0" Maximum="100" Increment="5" FontSize="11" />
<TextBlock Grid.Row="0" Grid.Column="3" Text="Mana Threshold %:" Foreground="#8b949e" FontSize="11" VerticalAlignment="Center" />
@ -901,7 +1022,7 @@
<!-- Combat Settings -->
<Expander Header="Combat Settings" Foreground="#8b949e" FontSize="11" Padding="0">
<Grid ColumnDefinitions="140,120,20,140,120" RowDefinitions="Auto,Auto,Auto" Margin="4">
<Grid ColumnDefinitions="140,140,20,140,140" RowDefinitions="Auto,Auto,Auto" Margin="4">
<TextBlock Grid.Row="0" Grid.Column="0" Text="Global CD (ms):" Foreground="#8b949e" FontSize="11" VerticalAlignment="Center" />
<NumericUpDown Grid.Row="0" Grid.Column="1" Value="{Binding GlobalCooldownMs}" Minimum="0" Maximum="5000" Increment="50" FontSize="11" />
<TextBlock Grid.Row="0" Grid.Column="3" Text="Attack Range:" Foreground="#8b949e" FontSize="11" VerticalAlignment="Center" />
@ -940,9 +1061,11 @@
MinWidth="100" FontSize="10" />
<ToggleButton IsChecked="{Binding IsExpanded}" Content="..."
Padding="8,2" FontSize="10" />
<Button Content="Save Default" Command="{Binding SaveDefaultCommand}"
Padding="8,2" FontSize="10" Foreground="#8b949e" />
</StackPanel>
<!-- Expanded detail -->
<Grid ColumnDefinitions="120,100,20,120,100" RowDefinitions="Auto,Auto,Auto,Auto"
<Grid ColumnDefinitions="120,130,20,120,130" RowDefinitions="Auto,Auto,Auto,Auto"
Margin="28,0,0,0"
IsVisible="{Binding IsExpanded}">
<TextBlock Grid.Row="0" Grid.Column="0" Text="Priority:" Foreground="#8b949e" FontSize="10" VerticalAlignment="Center" />
@ -973,109 +1096,6 @@
</StackPanel>
</Border>
<!-- Player State -->
<Border Background="#161b22" BorderBrush="#30363d" BorderThickness="1"
CornerRadius="8" Padding="8">
<StackPanel Spacing="4">
<TextBlock Text="PLAYER" FontSize="11" FontWeight="SemiBold"
Foreground="#8b949e" />
<Grid ColumnDefinitions="100,*" RowDefinitions="Auto,Auto,Auto,Auto" Margin="0,4,0,0">
<TextBlock Grid.Row="0" Grid.Column="0" Text="Position:" Foreground="#8b949e" FontSize="12" />
<TextBlock Grid.Row="0" Grid.Column="1" Text="{Binding PlayerPosition}" Foreground="#e6edf3" FontFamily="Consolas" FontSize="12" />
<TextBlock Grid.Row="1" Grid.Column="0" Text="Life:" Foreground="#8b949e" FontSize="12" />
<TextBlock Grid.Row="1" Grid.Column="1" Text="{Binding PlayerLife}" Foreground="#3fb950" FontFamily="Consolas" FontSize="12" />
<TextBlock Grid.Row="2" Grid.Column="0" Text="Mana:" Foreground="#8b949e" FontSize="12" />
<TextBlock Grid.Row="2" Grid.Column="1" Text="{Binding PlayerMana}" Foreground="#58a6ff" FontFamily="Consolas" FontSize="12" />
<TextBlock Grid.Row="3" Grid.Column="0" Text="ES:" Foreground="#8b949e" FontSize="12" />
<TextBlock Grid.Row="3" Grid.Column="1" Text="{Binding PlayerEs}" Foreground="#bc8cff" FontFamily="Consolas" FontSize="12" />
</Grid>
</StackPanel>
</Border>
<!-- Game State -->
<Border Background="#161b22" BorderBrush="#30363d" BorderThickness="1"
CornerRadius="8" Padding="8">
<StackPanel Spacing="4">
<TextBlock Text="GAME STATE" FontSize="11" FontWeight="SemiBold"
Foreground="#8b949e" />
<Grid ColumnDefinitions="100,*" RowDefinitions="Auto,Auto,Auto,Auto,Auto,Auto,Auto" Margin="0,4,0,0">
<TextBlock Grid.Row="0" Grid.Column="0" Text="Area:" Foreground="#8b949e" FontSize="12" />
<TextBlock Grid.Row="0" Grid.Column="1" Text="{Binding AreaInfo}" Foreground="#e6edf3" FontFamily="Consolas" FontSize="12" />
<TextBlock Grid.Row="1" Grid.Column="0" Text="Danger:" Foreground="#8b949e" FontSize="12" />
<TextBlock Grid.Row="1" Grid.Column="1" Text="{Binding DangerLevel}" Foreground="#f0883e" FontFamily="Consolas" FontSize="12" />
<TextBlock Grid.Row="2" Grid.Column="0" Text="Entities:" Foreground="#8b949e" FontSize="12" />
<TextBlock Grid.Row="2" Grid.Column="1" Text="{Binding EntityCount}" Foreground="#e6edf3" FontFamily="Consolas" FontSize="12" />
<TextBlock Grid.Row="3" Grid.Column="0" Text="Hostiles:" Foreground="#8b949e" FontSize="12" />
<TextBlock Grid.Row="3" Grid.Column="1" Text="{Binding HostileCount}" Foreground="#ff4444" FontFamily="Consolas" FontSize="12" />
<TextBlock Grid.Row="4" Grid.Column="0" Text="APM:" Foreground="#8b949e" FontSize="12" />
<TextBlock Grid.Row="4" Grid.Column="1" Text="{Binding ApmInfo}" Foreground="#58a6ff" FontFamily="Consolas" FontSize="12" />
<TextBlock Grid.Row="5" Grid.Column="0" Text="Systems:" Foreground="#8b949e" FontSize="12" />
<TextBlock Grid.Row="5" Grid.Column="1" Text="{Binding SystemsInfo}" Foreground="#e6edf3" FontFamily="Consolas" FontSize="12" />
<TextBlock Grid.Row="6" Grid.Column="0" Text="Tick:" Foreground="#8b949e" FontSize="12" />
<TextBlock Grid.Row="6" Grid.Column="1" Text="{Binding TickInfo}" Foreground="#484f58" FontFamily="Consolas" FontSize="12" />
</Grid>
</StackPanel>
</Border>
<!-- Navigation -->
<Border Background="#161b22" BorderBrush="#30363d" BorderThickness="1"
CornerRadius="8" Padding="8">
<StackPanel Spacing="6">
<TextBlock Text="NAVIGATION" FontSize="11" FontWeight="SemiBold"
Foreground="#8b949e" />
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Content="Explore" Command="{Binding ExploreCommand}"
Padding="16,6" FontWeight="Bold" />
<Button Content="Stop Nav" Command="{Binding StopNavCommand}"
Padding="16,6" FontWeight="Bold" />
</StackPanel>
<Grid ColumnDefinitions="100,*" RowDefinitions="Auto,Auto" Margin="0,4,0,0">
<TextBlock Grid.Row="0" Grid.Column="0" Text="Mode:" Foreground="#8b949e" FontSize="12" />
<TextBlock Grid.Row="0" Grid.Column="1" Text="{Binding NavMode}" Foreground="#58a6ff" FontFamily="Consolas" FontSize="12" />
<TextBlock Grid.Row="1" Grid.Column="0" Text="Status:" Foreground="#8b949e" FontSize="12" />
<TextBlock Grid.Row="1" Grid.Column="1" Text="{Binding NavStatus}" Foreground="#e6edf3" FontFamily="Consolas" FontSize="12" />
</Grid>
<Image Source="{Binding TerrainImage}" Width="400" Height="400"
Stretch="Uniform" Margin="0,4,0,0" />
</StackPanel>
</Border>
<!-- Entities -->
<Border Background="#161b22" BorderBrush="#30363d" BorderThickness="1"
CornerRadius="8" Padding="8">
<StackPanel Spacing="4">
<StackPanel Orientation="Horizontal" Spacing="12">
<TextBlock Text="ENTITIES" FontSize="11" FontWeight="SemiBold"
Foreground="#8b949e" VerticalAlignment="Center" />
<CheckBox IsChecked="{Binding ShowAllEntities}" Content="Show All on Overlay"
FontSize="11" Foreground="#8b949e" VerticalAlignment="Center"
MinWidth="0" Padding="4,0,0,0" />
</StackPanel>
<ListBox ItemsSource="{Binding Entities}" MaxHeight="300"
Background="Transparent" BorderThickness="0"
Padding="0">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:EntityListItem">
<StackPanel Orientation="Horizontal" Spacing="6">
<CheckBox IsChecked="{Binding IsChecked}" VerticalAlignment="Center"
MinWidth="0" Padding="0" />
<TextBlock Text="{Binding Label}" Foreground="#e6edf3"
FontFamily="Consolas" FontSize="11"
VerticalAlignment="Center" Width="200"
TextTrimming="CharacterEllipsis" />
<TextBlock Text="{Binding Category}" Foreground="#8b949e"
FontFamily="Consolas" FontSize="11"
VerticalAlignment="Center" Width="80" />
<TextBlock Text="{Binding Distance}" Foreground="#484f58"
FontFamily="Consolas" FontSize="11"
VerticalAlignment="Center" />
</StackPanel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</StackPanel>
</Border>
</StackPanel>
</ScrollViewer>
</TabItem>

View file

@ -17,7 +17,8 @@ public record ClickAction(int Priority, Vector2 ScreenPosition, ClickType Type =
public record KeyAction(int Priority, ushort ScanCode, KeyActionType Type = KeyActionType.Press) : BotAction(Priority);
public record CastAction(int Priority, ushort SkillScanCode, Vector2? TargetScreenPos = null) : BotAction(Priority);
public record CastAction(int Priority, ushort SkillScanCode,
Vector2? TargetScreenPos = null, uint? TargetEntityId = null) : BotAction(Priority);
public record FlaskAction(int Priority, ushort FlaskScanCode) : BotAction(Priority);

View file

@ -24,6 +24,8 @@ public class GameState
public IReadOnlyList<QuestProgress> ActiveQuests { get; set; } = [];
/// <summary>Active quests as shown in the game UI (title + objectives).</summary>
public IReadOnlyList<UiQuestInfo> UiQuests { get; set; } = [];
/// <summary>In-progress quests from the quest linked list with target areas and paths.</summary>
public IReadOnlyList<QuestInfo> Quests { get; set; } = [];
// Derived (computed once per tick by GameStateEnricher)
public ThreatMap Threats { get; set; } = new();

View file

@ -3,6 +3,13 @@ using System.Text.Json.Serialization;
namespace Roboto.Core;
public class ProfileConfig
{
public string? LastCharacter { get; set; }
public Dictionary<string, string> Assignments { get; set; } = new(); // charName → profileName
public Dictionary<string, SkillDefaults> SkillDefaults { get; set; } = new(); // skillName → defaults
}
public sealed class ProfileManager
{
private static readonly JsonSerializerOptions JsonOpts = new()
@ -12,23 +19,25 @@ public sealed class ProfileManager
};
private readonly string _profilesDir;
private readonly string _assignmentsFile;
private readonly string _configFile;
private readonly string _legacyAssignmentsFile;
private readonly object _lock = new();
private Dictionary<string, string> _assignments = new(); // charName → profileName
private ProfileConfig _config = new();
public ProfileManager(string profilesDir = "profiles")
{
_profilesDir = Path.GetFullPath(profilesDir);
_assignmentsFile = Path.Combine(_profilesDir, "_assignments.json");
_configFile = Path.Combine(_profilesDir, "_config.json");
_legacyAssignmentsFile = Path.Combine(_profilesDir, "_assignments.json");
EnsureDirectory();
LoadAssignments();
LoadConfig();
}
public CharacterProfile LoadForCharacter(string charName)
{
lock (_lock)
{
if (_assignments.TryGetValue(charName, out var profileName))
if (_config.Assignments.TryGetValue(charName, out var profileName))
{
var profile = LoadProfile(profileName);
if (profile is not null)
@ -39,8 +48,8 @@ public sealed class ProfileManager
var defaultName = $"{charName}_Default";
var defaultProfile = new CharacterProfile { Name = defaultName };
SaveProfile(defaultProfile);
_assignments[charName] = defaultName;
SaveAssignments();
_config.Assignments[charName] = defaultName;
SaveConfig();
return defaultProfile;
}
}
@ -73,8 +82,8 @@ public sealed class ProfileManager
{
lock (_lock)
{
_assignments[charName] = profileName;
SaveAssignments();
_config.Assignments[charName] = profileName;
SaveConfig();
}
}
@ -87,14 +96,14 @@ public sealed class ProfileManager
File.Delete(path);
// Remove any assignments pointing to this profile
var toRemove = _assignments
var toRemove = _config.Assignments
.Where(kv => kv.Value == profileName)
.Select(kv => kv.Key)
.ToList();
foreach (var key in toRemove)
_assignments.Remove(key);
_config.Assignments.Remove(key);
if (toRemove.Count > 0)
SaveAssignments();
SaveConfig();
}
}
@ -116,7 +125,52 @@ public sealed class ProfileManager
{
lock (_lock)
{
return _assignments.GetValueOrDefault(charName);
return _config.Assignments.GetValueOrDefault(charName);
}
}
public string? GetLastCharacter()
{
lock (_lock)
{
return _config.LastCharacter;
}
}
public void SetLastCharacter(string charName)
{
lock (_lock)
{
_config.LastCharacter = charName;
SaveConfig();
}
}
public void SaveSkillDefault(string skillName, SkillDefaults defaults)
{
lock (_lock)
{
_config.SkillDefaults[skillName] = defaults;
SaveConfig();
}
}
public SkillDefaults? GetSkillDefault(string skillName)
{
lock (_lock)
{
return _config.SkillDefaults.GetValueOrDefault(skillName);
}
}
/// <summary>
/// Returns the first character with an assignment. Fallback when no LastCharacter is set.
/// </summary>
public string? GetMostRecentCharacter()
{
lock (_lock)
{
return _config.Assignments.Keys.FirstOrDefault();
}
}
@ -143,25 +197,62 @@ public sealed class ProfileManager
File.WriteAllText(ProfilePath(profile.Name), json);
}
private void LoadAssignments()
private void LoadConfig()
{
if (!File.Exists(_assignmentsFile)) return;
try
// Try new config file first
if (File.Exists(_configFile))
{
var json = File.ReadAllText(_assignmentsFile);
_assignments = JsonSerializer.Deserialize<Dictionary<string, string>>(json, JsonOpts) ?? new();
try
{
var json = File.ReadAllText(_configFile);
_config = JsonSerializer.Deserialize<ProfileConfig>(json, JsonOpts) ?? new();
return;
}
catch
{
_config = new();
}
}
catch
// Migrate from legacy _assignments.json
if (File.Exists(_legacyAssignmentsFile))
{
_assignments = new();
try
{
var json = File.ReadAllText(_legacyAssignmentsFile);
var assignments = JsonSerializer.Deserialize<Dictionary<string, string>>(json, JsonOpts);
if (assignments is not null)
_config.Assignments = assignments;
// Save as new format and clean up legacy file
SaveConfig();
File.Delete(_legacyAssignmentsFile);
}
catch
{
_config = new();
}
}
// Clean up legacy _lastchar.txt if it exists
var legacyLastChar = Path.Combine(_profilesDir, "_lastchar.txt");
if (File.Exists(legacyLastChar))
{
try
{
_config.LastCharacter = File.ReadAllText(legacyLastChar).Trim();
SaveConfig();
File.Delete(legacyLastChar);
}
catch { }
}
}
private void SaveAssignments()
private void SaveConfig()
{
EnsureDirectory();
var json = JsonSerializer.Serialize(_assignments, JsonOpts);
File.WriteAllText(_assignmentsFile, json);
var json = JsonSerializer.Serialize(_config, JsonOpts);
File.WriteAllText(_configFile, json);
}
private void EnsureDirectory()

View file

@ -0,0 +1,22 @@
namespace Roboto.Core;
public class QuestInfo
{
public string? InternalId { get; init; }
public string? DisplayName { get; init; }
public int Act { get; init; }
public int StateId { get; init; }
public string? StateText { get; init; }
public bool IsTracked { get; init; }
public string? MapPinsText { get; init; }
public List<QuestTargetArea>? TargetAreas { get; init; }
public List<string>? PathToTarget { get; init; }
}
public class QuestTargetArea
{
public string? Id { get; init; }
public string? Name { get; init; }
public int Act { get; init; }
public bool IsTown { get; init; }
}

View file

@ -2,6 +2,54 @@ namespace Roboto.Core;
public enum SkillInputType { KeyPress, LeftClick, RightClick, MiddleClick }
/// <summary>
/// Saved default settings for a skill, shared across all characters.
/// </summary>
public class SkillDefaults
{
public int Priority { get; set; }
public bool IsEnabled { get; set; } = true;
public int CooldownMs { get; set; } = 300;
public float RangeMin { get; set; }
public float RangeMax { get; set; } = 600f;
public TargetSelection TargetSelection { get; set; } = TargetSelection.Nearest;
public bool RequiresTarget { get; set; } = true;
public bool IsAura { get; set; }
public bool IsMovementSkill { get; set; }
public int MinMonstersInRange { get; set; } = 1;
public bool MaintainPressed { get; set; }
public static SkillDefaults FromProfile(SkillProfile p) => new()
{
Priority = p.Priority,
IsEnabled = p.IsEnabled,
CooldownMs = p.CooldownMs,
RangeMin = p.RangeMin,
RangeMax = p.RangeMax,
TargetSelection = p.TargetSelection,
RequiresTarget = p.RequiresTarget,
IsAura = p.IsAura,
IsMovementSkill = p.IsMovementSkill,
MinMonstersInRange = p.MinMonstersInRange,
MaintainPressed = p.MaintainPressed,
};
public void ApplyTo(SkillProfile p)
{
p.Priority = Priority;
p.IsEnabled = IsEnabled;
p.CooldownMs = CooldownMs;
p.RangeMin = RangeMin;
p.RangeMax = RangeMax;
p.TargetSelection = TargetSelection;
p.RequiresTarget = RequiresTarget;
p.IsAura = IsAura;
p.IsMovementSkill = IsMovementSkill;
p.MinMonstersInRange = MinMonstersInRange;
p.MaintainPressed = MaintainPressed;
}
}
public class SkillProfile
{
public int SlotIndex { get; set; }

View file

@ -0,0 +1,88 @@
using System.Numerics;
namespace Roboto.Core;
/// <summary>
/// Terrain line-of-sight and walkable direction queries on the walkability grid.
/// </summary>
public static class TerrainQuery
{
/// <summary>
/// Bresenham line walk on the walkability grid. Returns false if any cell is unwalkable.
/// </summary>
public static bool HasLineOfSight(WalkabilitySnapshot terrain, Vector2 from, Vector2 to, float worldToGrid)
{
int x0 = (int)(from.X * worldToGrid);
int y0 = (int)(from.Y * worldToGrid);
int x1 = (int)(to.X * worldToGrid);
int y1 = (int)(to.Y * worldToGrid);
int dx = Math.Abs(x1 - x0);
int dy = Math.Abs(y1 - y0);
int sx = x0 < x1 ? 1 : -1;
int sy = y0 < y1 ? 1 : -1;
int err = dx - dy;
while (true)
{
if (!terrain.IsWalkable(x0, y0))
return false;
if (x0 == x1 && y0 == y1)
break;
int e2 = 2 * err;
if (e2 > -dy) { err -= dy; x0 += sx; }
if (e2 < dx) { err += dx; y0 += sy; }
}
return true;
}
/// <summary>
/// Validates a desired movement direction against terrain. If blocked, tries rotations
/// ±45°, ±90°, ±135°, 180° and returns the first clear direction.
/// Returns original direction as fallback (game engine will wall-slide).
/// </summary>
public static Vector2 FindWalkableDirection(
WalkabilitySnapshot terrain, Vector2 playerPos, Vector2 desiredDir, float worldToGrid,
float probeDistance = 200f)
{
if (IsDirectionClear(terrain, playerPos, desiredDir, worldToGrid, probeDistance))
return desiredDir;
// Try rotations: ±45°, ±90°, ±135°, 180°
ReadOnlySpan<float> angles = [45f, -45f, 90f, -90f, 135f, -135f, 180f];
foreach (var angleDeg in angles)
{
var rotated = Rotate(desiredDir, angleDeg);
if (IsDirectionClear(terrain, playerPos, rotated, worldToGrid, probeDistance))
return rotated;
}
return desiredDir;
}
private static bool IsDirectionClear(
WalkabilitySnapshot terrain, Vector2 origin, Vector2 dir, float worldToGrid, float distance)
{
var endpoint = origin + dir * distance;
var midpoint = origin + dir * (distance * 0.5f);
int mx = (int)(midpoint.X * worldToGrid);
int my = (int)(midpoint.Y * worldToGrid);
int ex = (int)(endpoint.X * worldToGrid);
int ey = (int)(endpoint.Y * worldToGrid);
return terrain.IsWalkable(mx, my) && terrain.IsWalkable(ex, ey);
}
private static Vector2 Rotate(Vector2 v, float degrees)
{
float rad = degrees * MathF.PI / 180f;
float cos = MathF.Cos(rad);
float sin = MathF.Sin(rad);
return Vector2.Normalize(new Vector2(v.X * cos - v.Y * sin, v.X * sin + v.Y * cos));
}
}

View file

@ -62,20 +62,44 @@ public static class GameStateEnricher
};
}
/// <summary>
/// Computes danger using a weighted threat score.
/// Close enemies count more, rares/uniques escalate significantly.
/// </summary>
private static DangerLevel ComputeDangerLevel(GameState state)
{
if (state.Player.LifePercent < 30f) return DangerLevel.Critical;
if (state.Player.LifePercent < 50f) return DangerLevel.High;
var nearbyHostiles = 0;
// Weighted threat score: proximity × rarity multiplier
var threatScore = 0f;
foreach (var m in state.HostileMonsters)
{
if (m.DistanceToPlayer < 500f) nearbyHostiles++;
var d = m.DistanceToPlayer;
if (d > 800f) continue;
// Distance weight: closer = more dangerous
float distWeight;
if (d < 200f) distWeight = 3f;
else if (d < 400f) distWeight = 2f;
else distWeight = 1f;
// Rarity multiplier
var rarityMul = m.Rarity switch
{
MonsterRarity.Unique => 5f,
MonsterRarity.Rare => 3f,
MonsterRarity.Magic => 1.5f,
_ => 1f,
};
threatScore += distWeight * rarityMul;
}
if (nearbyHostiles > 10) return DangerLevel.High;
if (nearbyHostiles > 5) return DangerLevel.Medium;
if (nearbyHostiles > 0) return DangerLevel.Low;
if (threatScore >= 15f) return DangerLevel.Critical;
if (threatScore >= 8f) return DangerLevel.High;
if (threatScore >= 4f) return DangerLevel.Medium;
if (threatScore > 0f) return DangerLevel.Low;
return DangerLevel.Safe;
}
}

View file

@ -35,6 +35,16 @@ public sealed class MemoryPoller : IDisposable
private int _coldHz;
private long _coldTickNumber;
// Stats snapshot (updated once per second)
private long _lastStatsMs;
private volatile int _readsPerSec;
private volatile int _kbPerSec;
private volatile int _entityCount;
public int ReadsPerSec => _readsPerSec;
public int KBPerSec => _kbPerSec;
public int EntityCount => _entityCount;
public event Action? StateUpdated;
public MemoryPoller(GameMemoryReader reader, GameDataCache cache, BotConfig config)
@ -94,6 +104,25 @@ public sealed class MemoryPoller : IDisposable
}
hotTickCount++;
// Update stats once per second
var nowMs = Environment.TickCount64;
if (nowMs - _lastStatsMs >= 1000)
{
_lastStatsMs = nowMs;
if (_mem is not null)
{
var (reads, bytes) = _mem.SnapshotAndResetCounters();
_readsPerSec = (int)reads;
_kbPerSec = (int)(bytes / 1024);
}
_entityCount = _cache.Entities.Count;
if (MemoryProfiler.IsEnabled)
MemoryProfiler.LatestData = MemoryProfiler.SnapshotAndReset();
else
MemoryProfiler.LatestData = null;
}
}
catch (Exception ex)
{
@ -120,8 +149,11 @@ public sealed class MemoryPoller : IDisposable
_mem = ctx.Memory;
_offsets = ctx.Offsets;
// Slow data (quests, character name) every 10th cold tick (~1Hz)
var isSlowTick = _coldTickNumber % 10 == 0;
// Full snapshot
var snap = _reader.ReadSnapshot();
var snap = _reader.ReadSnapshot(readSlowData: isSlowTick);
if (!snap.Attached) return previous;
// Re-resolve hot addresses
@ -136,19 +168,24 @@ public sealed class MemoryPoller : IDisposable
var state = BuildGameState(snap, previous);
_coldTickNumber++;
// Update cache — cold fields
// Update cache — cold fields (every tick)
_cache.Entities = state.Entities;
_cache.HostileMonsters = state.HostileMonsters;
_cache.NearbyLoot = state.NearbyLoot;
_cache.Terrain = state.Terrain;
_cache.AreaHash = state.AreaHash;
_cache.AreaLevel = state.AreaLevel;
_cache.CharacterName = state.Player.CharacterName;
_cache.GameUiPtr = snap.GameUiPtr;
_cache.UiQuestGroups = snap.UiQuestGroups;
_cache.QuestLinkedList = snap.QuestLinkedList;
_cache.QuestStates = snap.QuestStates;
_cache.LatestState = state;
// Slow fields — only update when actually read (1Hz)
if (isSlowTick)
{
_cache.CharacterName = state.Player.CharacterName;
_cache.UiQuestGroups = snap.UiQuestGroups;
_cache.QuestLinkedList = snap.QuestLinkedList;
_cache.QuestStates = snap.QuestStates;
}
_cache.ColdTickTimestamp = Environment.TickCount64;
// Also update hot fields from the snapshot (so they're never stale)
@ -173,6 +210,7 @@ public sealed class MemoryPoller : IDisposable
private void DoHotTick()
{
if (_mem is null || _offsets is null) return;
MemoryProfiler.BeginSection("Hot");
// 1. Camera matrix (64 bytes, 1 RPM)
if (_cameraMatrixAddr != 0)
@ -249,6 +287,7 @@ public sealed class MemoryPoller : IDisposable
}
_cache.HotTickTimestamp = Environment.TickCount64;
MemoryProfiler.EndSection();
}
private GameState BuildGameState(GameStateSnapshot snap, GameState? previous)
@ -271,7 +310,7 @@ public sealed class MemoryPoller : IDisposable
state.Player = new PlayerState
{
CharacterName = snap.CharacterName,
CharacterName = snap.CharacterName ?? previous?.Player.CharacterName,
HasPosition = snap.HasPosition,
Position = snap.HasPosition ? new Vector2(snap.PlayerX, snap.PlayerY) : Vector2.Zero,
Z = snap.PlayerZ,
@ -333,31 +372,32 @@ public sealed class MemoryPoller : IDisposable
state.NearbyLoot = loot;
}
if (snap.QuestFlags is { Count: > 0 })
if (snap.QuestLinkedList is { Count: > 0 })
{
// StateId: 1=available/in-progress, 2=completed, 3+=special
// Filter to non-completed quests for ActiveQuests
state.ActiveQuests = snap.QuestFlags
.Where(q => q.StateId != 2) // exclude completed
// StateId: 0=done, -1=locked, positive=in-progress
state.ActiveQuests = snap.QuestLinkedList
.Where(q => q.StateId > 0) // in-progress only
.Select(q => new QuestProgress
{
QuestStateIndex = q.QuestStateIndex,
QuestName = q.QuestName,
QuestName = q.DisplayName,
InternalId = q.InternalId,
StateId = q.StateId,
StateId = (byte)Math.Clamp(q.StateId, 0, 255),
IsTracked = q.IsTracked,
StateText = q.StateText,
ProgressText = q.ProgressText,
}).ToList();
var activeCount = state.ActiveQuests.Count;
if (_lastQuestCount != activeCount)
{
Log.Debug("Active quests: {Active}/{Total} (filtered ES!=2)",
activeCount, snap.QuestFlags.Count);
Log.Debug("Active quests: {Active}/{Total}",
activeCount, snap.QuestLinkedList.Count);
_lastQuestCount = activeCount;
}
}
else if (previous is not null)
{
state.ActiveQuests = previous.ActiveQuests;
}
if (snap.UiQuestGroups is { Count: > 0 })
{
@ -370,6 +410,35 @@ public sealed class MemoryPoller : IDisposable
.ToList(),
}).ToList();
}
else if (previous is not null)
{
state.UiQuests = previous.UiQuests;
}
if (snap.QuestLinkedList is { Count: > 0 })
{
state.Quests = snap.QuestLinkedList
.Where(q => q.StateId > 0)
.Select(q => new QuestInfo
{
InternalId = q.InternalId,
DisplayName = q.DisplayName,
Act = q.Act,
StateId = q.StateId,
StateText = q.StateText,
IsTracked = q.IsTracked,
MapPinsText = q.MapPinsText,
TargetAreas = q.TargetAreas?.Select(a => new QuestTargetArea
{
Id = a.Id, Name = a.Name, Act = a.Act, IsTown = a.IsTown,
}).ToList(),
PathToTarget = q.PathToTarget,
}).ToList();
}
else if (previous is not null)
{
state.Quests = previous.Quests;
}
if (snap.Terrain is not null)
{

View file

@ -3,57 +3,22 @@ using Roboto.GameOffsets.Natives;
namespace Roboto.GameOffsets.Components;
/// <summary>Mods component — item rarity, explicit/implicit mods.</summary>
[StructLayout(LayoutKind.Explicit, Size = 0x1A0)]
/// <summary>Mods component — ModsAndObjectMagicProperties inline at +0x00. Rarity at +0x94.</summary>
[StructLayout(LayoutKind.Explicit, Pack = 1, Size = 0x1A0)]
public struct Mods
{
[FieldOffset(0x00)] public ComponentHeader Header;
/// <summary>Pointer to ObjectMagicProperties.</summary>
[FieldOffset(0x98)] public nint ObjectMagicPropertiesPtr;
/// <summary>Pointer to AllModsType struct.</summary>
[FieldOffset(0xA0)] public nint AllModsPtr;
}
/// <summary>Magic properties of an item (rarity, etc.).</summary>
[StructLayout(LayoutKind.Explicit, Size = 0xA0)]
public struct ObjectMagicProperties
{
/// <summary>Item rarity: 0=Normal, 1=Magic, 2=Rare, 3=Unique.</summary>
/// <summary>Rarity from inline ModsAndObjectMagicProperties at +0x94.</summary>
[FieldOffset(0x94)] public int Rarity;
}
/// <summary>Combined mods and magic properties.</summary>
[StructLayout(LayoutKind.Explicit, Size = 0x150)]
public struct ModsAndObjectMagicProperties
/// <summary>ObjectMagicProperties component — ModsAndObjectMagicProperties at +0x0B0. Rarity at +0x0B0+0x94 = 0x144.</summary>
[StructLayout(LayoutKind.Explicit, Pack = 1, Size = 0x200)]
public struct ObjectMagicProperties
{
[FieldOffset(0x00)] public nint ModsPtr;
[FieldOffset(0x08)] public nint ObjectMagicPropertiesPtr;
}
/// <summary>All mod arrays (implicit, explicit, enchant, etc.).</summary>
[StructLayout(LayoutKind.Explicit, Size = 0x150)]
public struct AllModsType
{
/// <summary>Implicit mods StdVector.</summary>
[FieldOffset(0x00)] public StdVector ImplicitMods;
/// <summary>Explicit mods StdVector.</summary>
[FieldOffset(0x18)] public StdVector ExplicitMods;
/// <summary>Enchant mods StdVector.</summary>
[FieldOffset(0x30)] public StdVector EnchantMods;
/// <summary>Stats from mods StdVector.</summary>
[FieldOffset(0x148)] public StdVector StatsFromMods;
}
/// <summary>A single mod entry.</summary>
[StructLayout(LayoutKind.Sequential, Pack = 1)]
public struct ModArrayStruct
{
public nint ModPtr;
public int Level;
public int Unknown;
[FieldOffset(0x00)] public ComponentHeader Header;
/// <summary>Rarity at +0x0B0+0x94 = 0x144 total.</summary>
[FieldOffset(0x144)] public int Rarity;
}

View file

@ -3,17 +3,17 @@ using System.Runtime.InteropServices;
namespace Roboto.GameOffsets.Components;
/// <summary>Targetable component — whether entity can be targeted/highlighted.</summary>
[StructLayout(LayoutKind.Explicit, Size = 0x58)]
[StructLayout(LayoutKind.Explicit, Pack = 1, Size = 0x58)]
public struct Targetable
{
[FieldOffset(0x00)] public ComponentHeader Header;
/// <summary>Whether the entity is targetable (byte bool).</summary>
[FieldOffset(0x51)] public byte IsTargetable;
[FieldOffset(0x49)] public byte IsTargetable;
/// <summary>Whether the entity is highlightable (byte bool).</summary>
[FieldOffset(0x52)] public byte IsHighlightable;
[FieldOffset(0x4A)] public byte IsHighlightable;
/// <summary>Whether the entity is targetable through walls (byte bool).</summary>
[FieldOffset(0x53)] public byte IsTargetableThroughWalls;
/// <summary>Whether the entity is targeted by player (byte bool).</summary>
[FieldOffset(0x4B)] public byte IsTargettedByPlayer;
}

View file

@ -957,7 +957,8 @@ public sealed class MemoryDiagnostics
var path = _entities.TryReadEntityPath(entityPtr);
var entity = new Entity(entityPtr, entityId, path);
if (_entities.TryReadEntityPosition(entityPtr, out var x, out var y, out var z))
var (compFirst, compCount) = _components.FindComponentList(entityPtr);
if (_entities.TryReadEntityPosition(entityPtr, compFirst, compCount, out var x, out var y, out var z))
{
entity.HasPosition = true;
entity.X = x;

View file

@ -41,6 +41,10 @@ public class GameMemoryReader : IDisposable
private FilesContainer? _filesContainer;
private QuestStateLookup? _questStateLookup;
// Cached quest linked list / UI quest groups — re-read only when quest states change
private List<QuestLinkedEntry>? _cachedQuestLinkedList;
private List<UiQuestGroup>? _cachedUiQuestGroups;
public ObjectRegistry Registry => _registry;
public MemoryDiagnostics? Diagnostics { get; private set; }
public MemoryContext? Context => _ctx;
@ -137,6 +141,8 @@ public class GameMemoryReader : IDisposable
_strings = null;
_rtti = null;
// _questNames intentionally kept — reloaded only once
_cachedQuestLinkedList = null;
_cachedUiQuestGroups = null;
_filesContainer = null;
_questStateLookup = null;
Diagnostics = null;
@ -153,7 +159,11 @@ public class GameMemoryReader : IDisposable
return lookup.IsLoaded ? lookup : null;
}
public GameStateSnapshot ReadSnapshot()
/// <summary>
/// Reads a full game state snapshot. When readSlowData is false, skips expensive
/// rarely-changing data (quests, skills, connected areas, character name).
/// </summary>
public GameStateSnapshot ReadSnapshot(bool readSlowData = true)
{
var snap = new GameStateSnapshot();
@ -277,10 +287,13 @@ public class GameMemoryReader : IDisposable
_lastInGameState = gs.InGame.Address;
_lastController = gs.ControllerPtr;
// Diagnostic state slots — GameStateReader still used for MemoryDiagnostics compat
_stateReader!.ReadStateSlots(snap);
_stateReader.ReadIsLoading(snap);
// Diagnostic state slots — expensive, only needed for MemoryDiagnostics UI
MemoryProfiler.BeginSection("StateReader");
if (readSlowData)
_stateReader!.ReadStateSlots(snap);
_stateReader!.ReadIsLoading(snap);
_stateReader.ReadEscapeState(snap);
MemoryProfiler.EndSection();
// Reconcile CurrentGameState with reliable loading/escape detection
if (snap.IsLoading)
@ -290,10 +303,11 @@ public class GameMemoryReader : IDisposable
if (ai.Address != 0)
{
// Entities — read from hierarchy
// Entities — read from hierarchy (cached)
snap.Entities = ai.EntityList.Entities;
// Player vitals & position — still via ComponentReader (ECS)
// Player vitals & position — via ComponentReader (ECS, does RPM)
MemoryProfiler.BeginSection("Player");
if (snap.LocalPlayerPtr != 0)
{
if (snap.LocalPlayerPtr != _components!.LastLocalPlayer)
@ -301,19 +315,10 @@ public class GameMemoryReader : IDisposable
_components.InvalidateCaches(snap.LocalPlayerPtr);
_components.ReadPlayerVitals(snap);
_components.ReadPlayerPosition(snap);
snap.CharacterName = _components.ReadPlayerName(snap.LocalPlayerPtr);
}
MemoryProfiler.EndSection();
// Skills & quests — read from hierarchy
snap.PlayerSkills = ai.PlayerSkills.Skills;
snap.QuestFlags = ai.QuestFlags.Quests;
snap.QuestStates = ai.QuestStates;
// Read state flag bytes
if (snap.InGameStatePtr != 0)
snap.StateFlagBytes = mem.ReadBytes(snap.InGameStatePtr + snap.StateFlagBaseOffset, 0x30);
// Terrain — read from hierarchy
// Terrain — read from hierarchy (cached)
snap.TerrainCols = ai.Terrain.TerrainCols;
snap.TerrainRows = ai.Terrain.TerrainRows;
snap.TerrainWidth = ai.Terrain.TerrainWidth;
@ -321,20 +326,44 @@ public class GameMemoryReader : IDisposable
snap.Terrain = ai.Terrain.Grid;
snap.TerrainWalkablePercent = ai.Terrain.WalkablePercent;
// UI tree — root pointer only; tree is read lazily on-demand
// UI tree — root pointer only
snap.GameUiPtr = gs.InGame.UIElements.GameUiPtr;
// Quest linked lists (all quests + tracked merged)
snap.QuestLinkedList = gs.InGame.UIElements.ReadQuestLinkedLists();
// Skills — read from hierarchy (cached)
snap.PlayerSkills = ai.PlayerSkills.Skills;
// Quest groups from UI element tree
snap.UiQuestGroups = gs.InGame.UIElements.ReadQuestGroups();
// Slow data — quests, character name (1Hz)
if (readSlowData)
{
MemoryProfiler.BeginSection("Quests");
snap.QuestStates = ai.QuestStates;
snap.CharacterName = _components!.ReadPlayerName(snap.LocalPlayerPtr);
// Read state flag bytes
if (snap.InGameStatePtr != 0)
snap.StateFlagBytes = mem.ReadBytes(snap.InGameStatePtr + snap.StateFlagBaseOffset, 0x30);
// Quest linked lists + UI groups — only re-read when quest states change
if (ai.QuestStatesChanged)
{
ai.QuestStatesChanged = false;
_cachedQuestLinkedList = gs.InGame.UIElements.ReadQuestLinkedLists();
_cachedUiQuestGroups = gs.InGame.UIElements.ReadQuestGroups();
}
snap.QuestLinkedList = _cachedQuestLinkedList;
snap.UiQuestGroups = _cachedUiQuestGroups;
MemoryProfiler.EndSection();
}
}
}
catch (Exception ex)
{
Log.Debug(ex, "Error reading snapshot");
}
finally
{
MemoryProfiler.EndSection();
}
// Update edge detection for next tick
_gameStates!.InGame.AreaInstance.Terrain.UpdateLoadingEdge(snap.IsLoading);

View file

@ -0,0 +1,65 @@
using System.Collections.Concurrent;
namespace Roboto.Memory;
/// <summary>
/// Thread-static section profiler for memory reads. When enabled, attributes each
/// ProcessMemory.ReadBytes() call to the current section set via BeginSection/EndSection.
/// Cost when disabled: single volatile bool check per RPM call.
/// </summary>
public static class MemoryProfiler
{
public static volatile bool IsEnabled;
/// <summary>
/// Latest profiler snapshot, updated once per second by the poller thread.
/// Null when profiler is disabled.
/// </summary>
public static volatile Dictionary<string, (long Reads, long Bytes)>? LatestData;
[ThreadStatic]
private static string? _currentSection;
private static readonly ConcurrentDictionary<string, SectionStats> _sections = new();
public static void BeginSection(string name)
{
if (IsEnabled)
_currentSection = name;
}
public static void EndSection()
{
_currentSection = null;
}
public static void RecordRead(int bytes)
{
var section = _currentSection ?? "Other";
var stats = _sections.GetOrAdd(section, static _ => new SectionStats());
Interlocked.Increment(ref stats.Reads);
Interlocked.Add(ref stats.Bytes, bytes);
}
/// <summary>
/// Returns a snapshot of all section stats and resets counters.
/// </summary>
public static Dictionary<string, (long Reads, long Bytes)> SnapshotAndReset()
{
var result = new Dictionary<string, (long, long)>();
foreach (var kvp in _sections)
{
var reads = Interlocked.Exchange(ref kvp.Value.Reads, 0);
var bytes = Interlocked.Exchange(ref kvp.Value.Bytes, 0);
if (reads > 0)
result[kvp.Key] = (reads, bytes);
}
return result;
}
private sealed class SectionStats
{
public long Reads;
public long Bytes;
}
}

View file

@ -9,9 +9,23 @@ public sealed class ProcessMemory : IDisposable
private nint _handle;
private bool _disposed;
// Atomic read counters for stats
private long _readCount;
private long _bytesRead;
public string ProcessName { get; }
public int ProcessId { get; private set; }
/// <summary>
/// Returns (readCount, bytesRead) since last snapshot and resets counters.
/// </summary>
public (long Reads, long Bytes) SnapshotAndResetCounters()
{
var reads = Interlocked.Exchange(ref _readCount, 0);
var bytes = Interlocked.Exchange(ref _bytesRead, 0);
return (reads, bytes);
}
private ProcessMemory(string processName, nint handle, int processId)
{
ProcessName = processName;
@ -50,7 +64,14 @@ public sealed class ProcessMemory : IDisposable
{
fixed (byte* ptr = buffer)
{
return Native.ReadProcessMemory(_handle, address, (nint)ptr, buffer.Length, out _);
var ok = Native.ReadProcessMemory(_handle, address, (nint)ptr, buffer.Length, out _);
if (ok)
{
Interlocked.Increment(ref _readCount);
Interlocked.Add(ref _bytesRead, buffer.Length);
if (MemoryProfiler.IsEnabled) MemoryProfiler.RecordRead(buffer.Length);
}
return ok;
}
}
}

View file

@ -22,6 +22,14 @@ public sealed class AreaInstance : RemoteObject
public AreaTemplate AreaTemplate { get; }
public List<QuestStateEntry>? QuestStates { get; private set; }
/// <summary>True when QuestStates changed since last check. Reset by consumer.</summary>
public bool QuestStatesChanged { get; set; }
// Quest states checked every 1s — cheap vector read used as change detector
private long _questNextReadTick;
private const long QuestReadIntervalMs = 1000;
private int _lastQuestStatesHash;
public AreaInstance(MemoryContext ctx, ComponentReader components, MsvcStringReader strings, QuestNameLookup? questNames)
: base(ctx)
{
@ -37,6 +45,7 @@ public sealed class AreaInstance : RemoteObject
var mem = Ctx.Memory;
var offsets = Ctx.Offsets;
MemoryProfiler.BeginSection("AI.Fields");
// Area level
if (offsets.AreaLevelIsByte)
{
@ -66,7 +75,10 @@ public sealed class AreaInstance : RemoteObject
var count = (int)mem.Read<long>(Address + offsets.EntityListOffset + offsets.EntityCountInternalOffset);
EntityCount = count is > 0 and < 50000 ? count : 0;
MemoryProfiler.EndSection();
// Cascade to children
MemoryProfiler.BeginSection("AI.Entities");
if (EntityCount > 0)
{
EntityList.ExpectedCount = EntityCount;
@ -76,6 +88,7 @@ public sealed class AreaInstance : RemoteObject
{
EntityList.Reset();
}
MemoryProfiler.EndSection();
// Resolve PSD for skill bar + quest reads
nint psdPtr = 0;
@ -86,6 +99,7 @@ public sealed class AreaInstance : RemoteObject
psdPtr = mem.ReadPointer(psdVecBegin);
}
MemoryProfiler.BeginSection("AI.Skills");
if (LocalPlayerPtr != 0)
{
PlayerSkills.PsdPtr = psdPtr;
@ -95,14 +109,24 @@ public sealed class AreaInstance : RemoteObject
{
PlayerSkills.Reset();
}
MemoryProfiler.EndSection();
if (ServerDataPtr != 0)
QuestFlags.Update(ServerDataPtr);
else
QuestFlags.Reset();
// Quest state container (AI+0x900 → obj → +0x240 vector)
QuestStates = ReadQuestStates(mem, offsets);
// Quest states — cheap vector read every 1s, used as change detector
// for expensive linked list / UI tree reads downstream.
var now = Environment.TickCount64;
if (now >= _questNextReadTick)
{
MemoryProfiler.BeginSection("AI.QuestFlags");
QuestStates = ReadQuestStates(mem, offsets);
var hash = ComputeQuestStatesHash(QuestStates);
if (hash != _lastQuestStatesHash)
{
_lastQuestStatesHash = hash;
QuestStatesChanged = true;
}
MemoryProfiler.EndSection();
_questNextReadTick = now + QuestReadIntervalMs;
}
// AreaTemplate — pointer at AreaInstance + AreaTemplateOffset
var areaTemplatePtr = mem.ReadPointer(Address + offsets.AreaTemplateOffset);
@ -112,8 +136,10 @@ public sealed class AreaInstance : RemoteObject
AreaTemplate.Reset();
// Terrain — pass loading/area state before update
MemoryProfiler.BeginSection("AI.Terrain");
Terrain.AreaHash = AreaHash;
Terrain.Update(Address);
MemoryProfiler.EndSection();
return true;
}
@ -134,6 +160,15 @@ public sealed class AreaInstance : RemoteObject
Terrain.InvalidateCache();
}
private static int ComputeQuestStatesHash(List<QuestStateEntry>? states)
{
if (states is null or { Count: 0 }) return 0;
var h = states.Count;
foreach (var e in states)
h = unchecked(h * 31 + e.QuestId * 397 + e.State);
return h;
}
private List<QuestStateEntry>? ReadQuestStates(ProcessMemory mem, GameOffsets offsets)
{
if (offsets.QuestStateObjectOffset <= 0 || offsets.QuestStateVectorOffset <= 0)
@ -181,6 +216,9 @@ public sealed class AreaInstance : RemoteObject
LocalPlayerPtr = 0;
EntityCount = 0;
QuestStates = null;
QuestStatesChanged = true; // force full quest read on next tick
_questNextReadTick = 0;
_lastQuestStatesHash = 0;
EntityList.Reset();
PlayerSkills.Reset();
QuestFlags.Reset();

View file

@ -7,6 +7,7 @@ namespace Roboto.Memory.Objects;
public sealed class AreaTemplate : RemoteObject
{
private readonly MsvcStringReader _strings;
private nint _cachedAddress; // skip re-read if Address unchanged
public string? RawName { get; private set; }
public string? Name { get; private set; }
@ -23,6 +24,10 @@ public sealed class AreaTemplate : RemoteObject
protected override bool ReadData()
{
// AreaTemplate data is static for the entire zone — only re-read on address change
if (Address == _cachedAddress && RawName is not null)
return true;
var mem = Ctx.Memory;
var o = Ctx.Offsets;
@ -41,6 +46,7 @@ public sealed class AreaTemplate : RemoteObject
MonsterLevel = mem.Read<int>(Address + o.AreaTemplateMonsterLevelOffset);
WorldAreaId = mem.Read<int>(Address + o.AreaTemplateWorldAreaIdOffset);
_cachedAddress = Address;
return true;
}
@ -53,5 +59,6 @@ public sealed class AreaTemplate : RemoteObject
HasWaypoint = false;
MonsterLevel = 0;
WorldAreaId = 0;
_cachedAddress = 0;
}
}

View file

@ -13,6 +13,28 @@ public sealed class EntityList : RemoteObject
{
private readonly ComponentReader _components;
private readonly MsvcStringReader _strings;
private bool _loggedMonsterComponents;
// Caches: stable per entity within a zone, cleared on zone change
private readonly Dictionary<nint, int> _renderIndexCache = new();
private readonly Dictionary<nint, string?> _pathCache = new();
private readonly Dictionary<nint, Dictionary<string, int>?> _lookupCache = new();
private readonly Dictionary<nint, (nint First, int Count)> _compListCache = new();
private readonly Dictionary<nint, nint[]> _compPtrsCache = new();
private readonly Dictionary<nint, CachedEntityComponents> _stableCompsCache = new();
private readonly Dictionary<nint, (int State, long Tick)> _transitionStateCache = new();
// Cached tree traversal order — re-walk only when entity count changes
private List<(nint NodeAddr, nint EntityPtr, uint EntityId)>? _cachedTreeOrder;
private int _cachedTreeEntityCount;
/// <summary>Cached component data that is stable per entity within a zone.</summary>
private struct CachedEntityComponents
{
public bool IsTargetable;
public int Rarity; // -1 = not read
public string? TransitionName;
}
public List<Entity>? Entities { get; private set; }
@ -37,24 +59,47 @@ public sealed class EntityList : RemoteObject
var sentinel = mem.ReadPointer(Address + offsets.EntityListOffset);
if (sentinel == 0) { Entities = null; return true; }
var root = mem.ReadPointer(sentinel + offsets.EntityNodeParentOffset);
var entities = new List<Entity>();
var maxNodes = Math.Min(ExpectedCount + 10, 500);
var hasComponentLookup = offsets.ComponentLookupEntrySize > 0;
var dirty = false;
WalkTreeInOrder(sentinel, root, maxNodes, (_, treeNode) =>
// Build or refresh the tree traversal cache
MemoryProfiler.BeginSection("E.Tree");
if (_cachedTreeOrder is null || _cachedTreeEntityCount != ExpectedCount)
{
var entityPtr = treeNode.Data.EntityPtr;
if (entityPtr == 0) return;
// Full tree walk — only when entity count changes
var root = mem.ReadPointer(sentinel + offsets.EntityNodeParentOffset);
var maxNodes = Math.Min(ExpectedCount + 10, 500);
var treeOrder = new List<(nint, nint, uint)>(maxNodes);
WalkTreeInOrder(sentinel, root, maxNodes, (_, treeNode) =>
{
var ep = treeNode.Data.EntityPtr;
if (ep == 0) return;
var h = (ulong)ep >> 32;
if (h == 0 || h >= 0x7FFF || (ep & 0x3) != 0) return;
treeOrder.Add((0, ep, treeNode.Data.Key.EntityId));
});
_cachedTreeOrder = treeOrder;
_cachedTreeEntityCount = ExpectedCount;
}
else
{
// Fast path — re-read just the entity pointers from cached node addresses
// Entity pointers are stable per node, so we can skip this entirely
}
MemoryProfiler.EndSection();
var high = (ulong)entityPtr >> 32;
if (high == 0 || high >= 0x7FFF || (entityPtr & 0x3) != 0) return;
foreach (var (_, entityPtr, entityId) in _cachedTreeOrder)
{
MemoryProfiler.BeginSection("E.Path");
if (!_pathCache.TryGetValue(entityPtr, out var path))
{
path = TryReadEntityPath(entityPtr);
_pathCache[entityPtr] = path;
}
MemoryProfiler.EndSection();
var entityId = treeNode.Data.Key.EntityId;
var path = TryReadEntityPath(entityPtr);
if (IsDoodadPath(path)) return;
if (ShouldSkipEntity(path)) continue;
var entity = new Entity(entityPtr, entityId, path);
entity.Type = ClassifyType(path);
@ -62,17 +107,39 @@ public sealed class EntityList : RemoteObject
if (registry["entities"].Register(entity.Metadata))
dirty = true;
if (TryReadEntityPosition(entityPtr, out var x, out var y, out var z))
{
entity.HasPosition = true;
entity.X = x;
entity.Y = y;
entity.Z = z;
}
// Skip expensive reads for low-priority entities (effects, terrain, critters)
var lowPriority = ShouldSkipComponents(entity.Type);
if (hasComponentLookup && !IsLowPriorityPath(entity.Type))
if (!lowPriority)
{
var lookup = _components.ReadComponentLookup(entityPtr);
// Read component list once — shared across position and component reads
MemoryProfiler.BeginSection("E.Position");
if (!_compListCache.TryGetValue(entityPtr, out var compList))
{
compList = _components.FindComponentList(entityPtr);
_compListCache[entityPtr] = compList;
}
var (compFirst, compCount) = compList;
if (TryReadEntityPosition(entityPtr, compFirst, compCount, out var x, out var y, out var z))
{
entity.HasPosition = true;
entity.X = x;
entity.Y = y;
entity.Z = z;
}
MemoryProfiler.EndSection();
if (hasComponentLookup)
{
MemoryProfiler.BeginSection("E.Lookup");
if (!_lookupCache.TryGetValue(entityPtr, out var lookup))
{
lookup = _components.ReadComponentLookup(entityPtr);
_lookupCache[entityPtr] = lookup;
}
MemoryProfiler.EndSection();
if (lookup is not null)
{
entity.Components = new HashSet<string>(lookup.Keys);
@ -81,77 +148,154 @@ public sealed class EntityList : RemoteObject
if (registry["components"].Register(lookup.Keys))
dirty = true;
var (compFirst, compCount) = _components.FindComponentList(entityPtr);
MemoryProfiler.BeginSection("E.Comps");
if (lookup.TryGetValue("Targetable", out var targetIdx) && targetIdx >= 0 && targetIdx < compCount)
// Component pointer array — stable per entity, cache it
if (!_compPtrsCache.TryGetValue(entityPtr, out var compPtrs))
{
var targetComp = mem.ReadPointer(compFirst + targetIdx * 8);
if (targetComp != 0)
compPtrs = null!;
if (compCount > 0 && compCount < 200)
{
var targetable = mem.Read<Targetable>(targetComp);
entity.IsTargetable = targetable.IsTargetable != 0;
var ptrBytes = mem.ReadBytes(compFirst, compCount * 8);
if (ptrBytes is { Length: > 0 })
{
compPtrs = new nint[compCount];
for (var ci = 0; ci < compCount; ci++)
compPtrs[ci] = (nint)BitConverter.ToInt64(ptrBytes, ci * 8);
}
}
if (compPtrs is not null)
_compPtrsCache[entityPtr] = compPtrs;
}
if (entity.Components.Contains("Monster"))
if (compPtrs is not null)
{
if (lookup.TryGetValue("Life", out var lifeIdx) && lifeIdx >= 0 && lifeIdx < compCount)
// Stable component data — read once, cache
if (!_stableCompsCache.TryGetValue(entityPtr, out var stable))
{
var lifeComp = mem.ReadPointer(compFirst + lifeIdx * 8);
if (lifeComp != 0)
stable = new CachedEntityComponents { Rarity = -1 };
if (lookup.TryGetValue("Targetable", out var targetIdx) && targetIdx >= 0 && targetIdx < compPtrs.Length)
{
var life = mem.Read<Life>(lifeComp);
if (life.Health.Total > 0 && life.Health.Total < 200000 &&
life.Health.Current >= 0 && life.Health.Current <= life.Health.Total + 1000)
var targetComp = compPtrs[targetIdx];
if (targetComp != 0)
{
entity.HasVitals = true;
entity.LifeCurrent = life.Health.Current;
entity.LifeTotal = life.Health.Total;
var targetable = mem.Read<Targetable>(targetComp);
stable.IsTargetable = targetable.IsTargetable != 0;
}
}
if (lookup.TryGetValue("ObjectMagicProperties", out var ompIdx) && ompIdx >= 0 && ompIdx < compPtrs.Length)
{
var ompComp = compPtrs[ompIdx];
if (ompComp != 0)
{
var props = mem.Read<ObjectMagicProperties>(ompComp);
if (props.Rarity is >= 0 and <= 3)
stable.Rarity = props.Rarity;
}
}
else if (lookup.TryGetValue("Mods", out var modsRarityIdx) && modsRarityIdx >= 0 && modsRarityIdx < compPtrs.Length)
{
var modsComp = compPtrs[modsRarityIdx];
if (modsComp != 0)
{
var mods = mem.Read<Mods>(modsComp);
if (mods.Rarity is >= 0 and <= 3)
stable.Rarity = mods.Rarity;
}
}
if (entity.Components.Contains("AreaTransition") &&
lookup.TryGetValue("AreaTransition", out var atIdx) && atIdx >= 0 && atIdx < compPtrs.Length)
{
var atComp = compPtrs[atIdx];
if (atComp != 0)
stable.TransitionName = ReadAreaTransitionName(atComp);
}
_stableCompsCache[entityPtr] = stable;
}
entity.IsTargetable = stable.IsTargetable;
if (stable.Rarity >= 0)
entity.Rarity = stable.Rarity;
entity.TransitionName = stable.TransitionName;
// Dynamic component data — re-read every frame
if (entity.Components.Contains("Monster"))
{
if (!_loggedMonsterComponents)
{
_loggedMonsterComponents = true;
var name = entity.Path?[(entity.Path.LastIndexOf('/') + 1)..] ?? "?";
Serilog.Log.Information("Monster [{Name}] components: {Comps}",
name, string.Join(", ", lookup.Keys.OrderBy(k => k)));
}
if (lookup.TryGetValue("Life", out var lifeIdx) && lifeIdx >= 0 && lifeIdx < compPtrs.Length)
{
var lifeComp = compPtrs[lifeIdx];
if (lifeComp != 0)
{
// Read only Health.Total (0x1D4) + Health.Current (0x1D8) = 8 bytes
// instead of full Life struct (0x268 = 616 bytes)
const int healthTotalOff = 0x1A8 + 0x2C; // Life.Health + VitalStruct.Total
var vitals = mem.ReadBytes(lifeComp + healthTotalOff, 8);
if (vitals is { Length: 8 })
{
var hpTotal = BitConverter.ToInt32(vitals, 0);
var hpCurrent = BitConverter.ToInt32(vitals, 4);
if (hpTotal > 0 && hpTotal < 200000 &&
hpCurrent >= 0 && hpCurrent <= hpTotal + 1000)
{
entity.HasVitals = true;
entity.LifeCurrent = hpCurrent;
entity.LifeTotal = hpTotal;
}
}
}
}
if (lookup.TryGetValue("Actor", out var actorIdx) && actorIdx >= 0 && actorIdx < compPtrs.Length)
{
var actorComp = compPtrs[actorIdx];
if (actorComp != 0)
{
var animId = mem.Read<int>(actorComp + ActorOffsets.AnimationId);
entity.ActionId = (short)(animId & 0xFFFF);
}
}
}
if (lookup.TryGetValue("Actor", out var actorIdx) && actorIdx >= 0 && actorIdx < compCount)
if (entity.Components.Contains("Transitionable") &&
lookup.TryGetValue("Transitionable", out var trIdx) && trIdx >= 0 && trIdx < compPtrs.Length)
{
var actorComp = mem.ReadPointer(compFirst + actorIdx * 8);
if (actorComp != 0)
var trComp = compPtrs[trIdx];
if (trComp != 0)
{
var animId = mem.Read<int>(actorComp + ActorOffsets.AnimationId);
entity.ActionId = (short)(animId & 0xFFFF);
var now = Environment.TickCount64;
if (_transitionStateCache.TryGetValue(entityPtr, out var cached) && now - cached.Tick < 1000)
{
entity.TransitionState = cached.State;
}
else
{
var state = mem.Read<int>(trComp + 0x120);
entity.TransitionState = state;
_transitionStateCache[entityPtr] = (state, now);
}
}
}
if (lookup.TryGetValue("Mods", out var modsIdx) && modsIdx >= 0 && modsIdx < compCount)
{
var modsComp = mem.ReadPointer(compFirst + modsIdx * 8);
if (modsComp != 0)
ReadEntityMods(entity, modsComp);
}
}
if (entity.Components.Contains("AreaTransition") &&
lookup.TryGetValue("AreaTransition", out var atIdx) && atIdx >= 0 && atIdx < compCount)
{
var atComp = mem.ReadPointer(compFirst + atIdx * 8);
if (atComp != 0)
entity.TransitionName = ReadAreaTransitionName(atComp);
}
if (entity.Components.Contains("Transitionable") &&
lookup.TryGetValue("Transitionable", out var trIdx) && trIdx >= 0 && trIdx < compCount)
{
var trComp = mem.ReadPointer(compFirst + trIdx * 8);
if (trComp != 0)
{
var tr = mem.Read<Transitionable>(trComp);
entity.TransitionState = tr.CurrentStateEnum;
}
}
MemoryProfiler.EndSection();
}
}
} // !lowPriority
entities.Add(entity);
});
}
if (dirty)
registry.Flush();
@ -164,6 +308,15 @@ public sealed class EntityList : RemoteObject
{
Entities = null;
ExpectedCount = 0;
_renderIndexCache.Clear();
_pathCache.Clear();
_lookupCache.Clear();
_compListCache.Clear();
_compPtrsCache.Clear();
_stableCompsCache.Clear();
_transitionStateCache.Clear();
_cachedTreeOrder = null;
_cachedTreeEntityCount = 0;
}
// ── Tree walking ─────────────────────────────────────────────────────
@ -224,21 +377,33 @@ public sealed class EntityList : RemoteObject
return _strings.ReadMsvcWString(detailsPtr + offsets.EntityPathStringOffset);
}
public bool TryReadEntityPosition(nint entity, out float x, out float y, out float z)
public bool TryReadEntityPosition(nint entity, nint compFirst, int count, out float x, out float y, out float z)
{
x = y = z = 0;
var offsets = Ctx.Offsets;
var (compFirst, count) = _components.FindComponentList(entity);
if (count <= 0) return false;
// Fast path: cached render component index for this entity
if (_renderIndexCache.TryGetValue(entity, out var cachedIdx) && cachedIdx >= 0 && cachedIdx < count)
{
var renderComp = Ctx.Memory.ReadPointer(compFirst + cachedIdx * 8);
if (renderComp != 0 && _components.TryReadPositionRaw(renderComp, out x, out y, out z))
return true;
// Cache miss (component list changed?) — fall through to re-scan
}
// Try configured global index
var offsets = Ctx.Offsets;
if (offsets.RenderComponentIndex >= 0 && offsets.RenderComponentIndex < count)
{
var renderComp = Ctx.Memory.ReadPointer(compFirst + offsets.RenderComponentIndex * 8);
if (renderComp != 0 && _components.TryReadPositionRaw(renderComp, out x, out y, out z))
{
_renderIndexCache[entity] = offsets.RenderComponentIndex;
return true;
}
}
// Fallback: scan and cache the result
var scanLimit = Math.Min(count, 20);
for (var i = 0; i < scanLimit; i++)
{
@ -249,7 +414,10 @@ public sealed class EntityList : RemoteObject
if ((compPtr & 0x3) != 0) continue;
if (_components.TryReadPositionRaw(compPtr, out x, out y, out z))
{
_renderIndexCache[entity] = i;
return true;
}
}
return false;
@ -284,59 +452,23 @@ public sealed class EntityList : RemoteObject
return null;
}
private void ReadEntityMods(Entity entity, nint modsComp)
{
var mem = Ctx.Memory;
var mods = mem.Read<Mods>(modsComp);
if (mods.ObjectMagicPropertiesPtr != 0 &&
((ulong)mods.ObjectMagicPropertiesPtr >> 32) is > 0 and < 0x7FFF)
{
var props = mem.Read<ObjectMagicProperties>(mods.ObjectMagicPropertiesPtr);
if (props.Rarity is >= 0 and <= 3)
entity.Rarity = props.Rarity;
}
if (mods.AllModsPtr == 0 || ((ulong)mods.AllModsPtr >> 32) is 0 or >= 0x7FFF)
return;
var allMods = mem.Read<AllModsType>(mods.AllModsPtr);
var explicitCount = (int)allMods.ExplicitMods.TotalElements(16);
if (explicitCount <= 0 || explicitCount > 20) return;
var modNames = new List<string>();
for (var i = 0; i < explicitCount; i++)
{
var modEntry = mem.Read<ModArrayStruct>(allMods.ExplicitMods.First + i * 16);
if (modEntry.ModPtr == 0) continue;
if (((ulong)modEntry.ModPtr >> 32) is 0 or >= 0x7FFF) continue;
var name = _strings.ReadNullTermWString(modEntry.ModPtr);
if (name is not null)
{
modNames.Add(name);
continue;
}
name = _strings.ReadMsvcWString(modEntry.ModPtr);
if (name is not null)
modNames.Add(name);
}
if (modNames.Count > 0)
entity.ModNames = modNames;
}
// ── Classification helpers ───────────────────────────────────────────
private static bool IsDoodadPath(string? path)
/// <summary>
/// Entities to skip entirely — no Entity object created, no reads at all.
/// </summary>
private static bool ShouldSkipEntity(string? path)
{
if (path is null) return false;
return path.Contains("Doodad", StringComparison.OrdinalIgnoreCase);
}
private static bool IsLowPriorityPath(EntityType type)
/// <summary>
/// Entity types that skip position, component lookup, and component reads.
/// Only the path is read and cached. Add new types here to filter them out.
/// </summary>
private static bool ShouldSkipComponents(EntityType type)
=> type is EntityType.Effect or EntityType.Terrain or EntityType.Critter;
private static EntityType ClassifyType(string? path)

View file

@ -21,6 +21,9 @@ public sealed class GameStates
public AreaLoading AreaLoading { get; }
public InGameState InGame { get; }
/// <summary>Enable to run DumpControllerPreSlots (expensive). Only needed for diagnostics UI.</summary>
public bool EnableDiagnostics { get; set; }
/// <summary>Raw qwords from controller 0x00-0x48 (before state slots), for UI diagnostics.</summary>
public (int Offset, nint Value, string? Match, bool Changed, string? DerefInfo)[] ControllerPreSlots { get; private set; } = [];
@ -48,9 +51,13 @@ public sealed class GameStates
if (_ctx.GameStateBase == 0)
return false;
MemoryProfiler.BeginSection("Slots");
var controller = mem.ReadPointer(_ctx.GameStateBase);
if (controller == 0)
{
MemoryProfiler.EndSection();
return false;
}
ControllerPtr = controller;
nint igsPtr = 0;
@ -104,8 +111,11 @@ public sealed class GameStates
igsPtr = _slotPointers[offsets.InGameStateIndex];
}
// Dump controller pre-slots region for diagnostics
DumpControllerPreSlots(controller);
// Dump controller pre-slots region for diagnostics (expensive — skip in production)
if (EnableDiagnostics)
DumpControllerPreSlots(controller);
MemoryProfiler.EndSection();
// Cascade to children FIRST — we need their flags for current state resolution
var areaLoadingPtr = StatesCount > 0 ? _slotPointers[0] : (nint)0;
@ -118,10 +128,14 @@ public sealed class GameStates
return false;
}
MemoryProfiler.BeginSection("InGame");
InGame.Update(igsPtr);
MemoryProfiler.EndSection();
// Resolve current state AFTER children have read their flags
MemoryProfiler.BeginSection("Slots");
ResolveCurrentState(controller, igsPtr);
MemoryProfiler.EndSection();
return true;
}

View file

@ -40,14 +40,20 @@ public sealed class InGameState : RemoteObject
IsEscapeOpen = _data.EscapeStateFlag != 0;
// Cascade to AreaInstance
MemoryProfiler.BeginSection("AreaInstance");
AreaInstance.Update(_data.AreaInstanceDataPtr);
MemoryProfiler.EndSection();
// Cascade to WorldData — set fallback camera before update
MemoryProfiler.BeginSection("WorldData");
WorldData.FallbackCameraPtr = _data.CameraPtr;
WorldData.Update(_data.WorldDataPtr);
MemoryProfiler.EndSection();
// Cascade to UIElements — pass InGameState address for UiRootStruct chain
MemoryProfiler.BeginSection("UIElements");
UIElements.Update(Address);
MemoryProfiler.EndSection();
return true;
}

View file

@ -16,6 +16,7 @@ public sealed class PlayerSkills : RemoteObject
// Name cache — skill names are static per area, only refresh on actor change
private readonly Dictionary<nint, string?> _nameCache = new();
private nint _lastActorComp;
private nint _cachedActorEntity; // entity ptr the cached actor was resolved for
public List<SkillSnapshot>? Skills { get; private set; }
@ -34,7 +35,17 @@ public sealed class PlayerSkills : RemoteObject
if (Address == 0) { Skills = null; return false; }
var mem = Ctx.Memory;
var actorComp = _components.GetComponentAddress(Address, "Actor");
// Cache Actor component address — only re-resolve on entity change (zone transition)
nint actorComp;
if (Address == _cachedActorEntity && _lastActorComp != 0)
{
actorComp = _lastActorComp;
}
else
{
actorComp = _components.GetComponentAddress(Address, "Actor");
_cachedActorEntity = Address;
}
if (actorComp == 0) { Skills = null; return true; }
// Invalidate name cache if actor component address changed (area transition)
@ -141,6 +152,7 @@ public sealed class PlayerSkills : RemoteObject
PsdPtr = 0;
_nameCache.Clear();
_lastActorComp = 0;
_cachedActorEntity = 0;
}
private (ushort Id, ushort Id2)[]? ReadSkillBarIds(nint psdPtr)

View file

@ -18,6 +18,9 @@ public sealed class QuestFlags : RemoteObject
public List<QuestSnapshot>? Quests { get; private set; }
/// <summary>PSD pointer resolved by AreaInstance. Set before calling Update() to avoid duplicate chain walk.</summary>
public nint PsdPtr { get; set; }
public QuestFlags(MemoryContext ctx, MsvcStringReader strings, QuestNameLookup? nameLookup = null)
: base(ctx)
{
@ -30,12 +33,8 @@ public sealed class QuestFlags : RemoteObject
var offsets = Ctx.Offsets;
if (offsets.QuestFlagEntrySize <= 0) { Quests = null; return true; }
var mem = Ctx.Memory;
var psdVecBegin = mem.ReadPointer(Address + offsets.PlayerServerDataOffset);
if (psdVecBegin == 0) { Quests = null; return true; }
var playerServerData = mem.ReadPointer(psdVecBegin);
// Use PSD pointer passed from AreaInstance (avoids re-walking ServerData → PSD chain)
var playerServerData = PsdPtr;
if (playerServerData == 0) { Quests = null; return true; }
if (playerServerData != _lastPsd)

View file

@ -15,6 +15,10 @@ public sealed class Terrain : RemoteObject
private WalkabilityGrid? _cachedTerrain;
private bool _wasLoading;
// Non-inline: cache intermediate pointers (stable within a zone)
private nint _cachedDimsPtr;
private nint _cachedTerrainAddr; // Address when cache was built
public WalkabilityGrid? Grid { get; private set; }
public int TerrainWidth { get; private set; }
public int TerrainHeight { get; private set; }
@ -32,6 +36,8 @@ public sealed class Terrain : RemoteObject
{
_cachedTerrain = null;
_cachedTerrainAreaHash = 0;
_cachedDimsPtr = 0;
_cachedTerrainAddr = 0;
}
protected override bool ReadData()
@ -41,14 +47,26 @@ public sealed class Terrain : RemoteObject
if (!offsets.TerrainInline)
{
var terrainListPtr = mem.ReadPointer(Address + offsets.TerrainListOffset);
if (terrainListPtr == 0) return true;
// Cache the 3-hop pointer chain (stable within a zone)
nint dimsPtr;
if (_cachedDimsPtr != 0 && Address == _cachedTerrainAddr)
{
dimsPtr = _cachedDimsPtr;
}
else
{
var terrainListPtr = mem.ReadPointer(Address + offsets.TerrainListOffset);
if (terrainListPtr == 0) return true;
var terrainPtr = mem.ReadPointer(terrainListPtr);
if (terrainPtr == 0) return true;
var terrainPtr = mem.ReadPointer(terrainListPtr);
if (terrainPtr == 0) return true;
var dimsPtr = mem.ReadPointer(terrainPtr + offsets.TerrainDimensionsOffset);
if (dimsPtr == 0) return true;
dimsPtr = mem.ReadPointer(terrainPtr + offsets.TerrainDimensionsOffset);
if (dimsPtr == 0) return true;
_cachedDimsPtr = dimsPtr;
_cachedTerrainAddr = Address;
}
TerrainCols = mem.Read<int>(dimsPtr);
TerrainRows = mem.Read<int>(dimsPtr + 4);
@ -164,6 +182,8 @@ public sealed class Terrain : RemoteObject
TerrainCols = 0;
TerrainRows = 0;
WalkablePercent = 0;
_cachedDimsPtr = 0;
_cachedTerrainAddr = 0;
}
public static int CalcWalkablePercent(WalkabilityGrid grid)

View file

@ -20,6 +20,11 @@ public sealed class UIElements : RemoteObject
private readonly MsvcStringReader _strings;
// Cached quest parent pointers — resolved once, reused across reads
private nint _cachedTrackedPanelAddr; // GameUi[6][1]
private nint _cachedQuestParentAddr; // GameUi[6][1][0][0][0]
private nint _cachedForGameUi; // GameUiPtr when cache was built
/// <summary>Optional lookup for resolving quest state IDs to human-readable text.</summary>
public QuestStateLookup? QuestStateLookup { get; set; }
@ -192,6 +197,38 @@ public sealed class UIElements : RemoteObject
return node;
}
/// <summary>
/// Resolves and caches quest parent pointers from the UI tree.
/// Called lazily; cache invalidated when GameUiPtr changes.
/// </summary>
private void EnsureQuestPointerCache()
{
if (_cachedForGameUi == GameUiPtr && _cachedTrackedPanelAddr != 0)
return; // cache is valid
_cachedForGameUi = GameUiPtr;
_cachedTrackedPanelAddr = 0;
_cachedQuestParentAddr = 0;
if (GameUiPtr == 0) return;
var offsets = Ctx.Offsets;
// GameUi[6] → [1] for tracked quests
var elem6 = ReadChildAtIndex(GameUiPtr, offsets.TrackedQuestPanelChildIndex);
if (elem6 is not null)
{
var elem61 = ReadChildAtIndex(elem6.Address, offsets.TrackedQuestPanelSubChildIndex);
if (elem61 is not null)
_cachedTrackedPanelAddr = elem61.Address;
}
// GameUi[6][1][0][0][0] for quest groups
var questParent = NavigatePath(GameUiPtr, [6, 1, 0, 0, 0]);
if (questParent is not null)
_cachedQuestParentAddr = questParent.Address;
}
/// <summary>
/// Reads quest groups from the UI tree.
/// Path: GameUi[6][1][0][0][0] → quest_display → [0] → title_layout/quest_info_layout
@ -200,11 +237,10 @@ public sealed class UIElements : RemoteObject
{
if (GameUiPtr == 0) return null;
// Navigate to the parent that holds quest_display nodes
var questParent = NavigatePath(GameUiPtr, [6, 1, 0, 0, 0]);
if (questParent is null) return null;
EnsureQuestPointerCache();
if (_cachedQuestParentAddr == 0) return null;
var questDisplays = ReadChildren(questParent.Address);
var questDisplays = ReadChildren(_cachedQuestParentAddr);
if (questDisplays is null) return null;
var groups = new List<UiQuestGroup>();
@ -318,18 +354,15 @@ public sealed class UIElements : RemoteObject
var mem = Ctx.Memory;
var offsets = Ctx.Offsets;
// ── Tracked quests: [6][1]+0x318 — collect into dict keyed by QuestDatPtr ──
EnsureQuestPointerCache();
// ── Tracked quests: cached [6][1]+0x318 — collect into dict keyed by QuestDatPtr ──
var trackedMap = new Dictionary<nint, string?>();
var elem6 = ReadChildAtIndex(GameUiPtr, offsets.TrackedQuestPanelChildIndex);
if (elem6 is not null)
if (_cachedTrackedPanelAddr != 0)
{
var elem61 = ReadChildAtIndex(elem6.Address, offsets.TrackedQuestPanelSubChildIndex);
if (elem61 is not null)
{
var trackedHead = mem.ReadPointer(elem61.Address + offsets.TrackedQuestLinkedListOffset);
if (trackedHead != 0)
TraverseTrackedQuests(trackedHead, trackedMap);
}
var trackedHead = mem.ReadPointer(_cachedTrackedPanelAddr + offsets.TrackedQuestLinkedListOffset);
if (trackedHead != 0)
TraverseTrackedQuests(trackedHead, trackedMap);
}
// ── All quests: GameUi+0x358 ──
@ -563,5 +596,8 @@ public sealed class UIElements : RemoteObject
{
UiRootPtr = 0;
GameUiPtr = 0;
_cachedForGameUi = 0;
_cachedTrackedPanelAddr = 0;
_cachedQuestParentAddr = 0;
}
}

View file

@ -1,17 +0,0 @@
rownum,Quest,Order,FlagsPresent,FlagsMissing,Text,Text (French),Text (German),Text (Japanese),Text (Korean),Text (Portuguese),Text (Russian),Text (Spanish),Text (Thai),Text (Traditional Chinese),bool_60,Message,Message (French),Message (German),Message (Japanese),Message (Korean),Message (Portuguese),Message (Russian),Message (Spanish),Message (Thai),Message (Traditional Chinese),MapPinsKeys,i32_85,MapPinsText,MapPinsText (French),MapPinsText (German),MapPinsText (Japanese),MapPinsText (Korean),MapPinsText (Portuguese),MapPinsText (Russian),MapPinsText (Spanish),MapPinsText (Thai),MapPinsText (Traditional Chinese),MapPinsKey,[rid]_113,bool_129,[i32]_130,[i32]_146,i32_162,SoundEffect,string_182,[rid]_190,bool_206,bool_207
0,0,0,[2936],[],Quest Complete - You have slain the Bloated Miller and received a reward from Renly.,Quête terminée — Vous avez tué le Meunier boursouflé et avez reçu une récompense de la part de Renly.,Quest abgeschlossen: Ihr habt den Aufgedunsenen Müller getötet und von Renly eine Belohnung erhalten.,クエスト完了 - 腐乱した粉屋を倒し、レンリーから報酬を受け取った,퀘스트 완료 - 불어 터진 방아꾼을 처치하고 렌리에게 보상을 받았습니다.,Missão Concluída - Você matou o Triturador Inchado e recebeu uma recompensa do Renly.,Задание выполнено - Вы убили Раздувшегося мельника и получили награду от Ренли.,Misión completa - Has derrotado al Molinero hinchado y recibido una recompensa de Renly.,เควสต์​เสร็จสิ้น - คุณได้สังหารเจ้าของโรงสีขึ้นอืดและรับรางวัลจากเรนลีย์แล้ว,任務完成——你已經殺掉了浮腫米勒,並且從倫利那裡取得你的任務獎勵。,1,Quest Complete,Quête terminée,Quest abgeschlossen,クエスト完了,퀘스트 완료,Missão Concluída,Задание выполнено,Misión completa,เควสต์​เสร็จสิ้น,任務完成,[],0,,,,,,,,,,,,[],,[],[],10,,,[],,
1,0,1,[2935],[],Renly has offered you a reward for slaying the Bloated Miller. Take it.,Renly vous offre une récompense pour avoir tué le Meunier boursouflé. Prenez-la.,"Renly hat Euch eine Belohnung dafür angeboten, dass Ihr den Aufgedunsenen Müller getötet habt. Nehmt sie an Euch.",レンリーは腐乱した粉屋を倒した報酬を提示した。受け取れ,렌리가 불어 터진 방아꾼을 처치해 준 것에 대한 보상을 준다고 합니다. 받으십시오.,Renly te ofereceu uma recompensa por matar o Triturador Inchado. Aceite.,Ренли предложил вам награду за убийство Раздувшегося мельника. Заберите её.,Renly te ha ofrecido una recompensa por derrotar al Molinero hinchado. Acéptala.,เรนลีย์ได้เสนอรางวัลให้คุณเลือกเพื่อตอบแทนการสังหารเจ้าของโรงสีขึ้นอืด รับรางวัลเสีย,倫利要給你擊殺浮腫米勒的獎勵,收下它。,,Take Renly's reward,Prenez la récompense de Renly,Nehmt Renlys Belohnung,レンリーの報酬を受け取れ,렌리의 보상 받기,Pegue a recompensa do Renly,Заберите награду у Ренли,Acepta la recompensa de Renly,รับรางวัลของเรนลีย์,領取倫利的獎勵,[8],0,Take Renly's reward,Prenez la récompense de Renly,Nehmt Renlys Belohnung.,レンリーの報酬を受け取れ,렌리의 보상 받기,Pegue a recompensa do Renly,Заберите награду у Ренли,Acepta la recompensa de Renly,รับรางวัลของเรนลีย์,領取倫利的獎勵,,[],,[],[],10,,,[],,
2,0,2,"[2934,2890]",[],The Blacksmith appears to be in charge in this logging encampment. Talk to him.,Le Forgeron semble être le responsable de ce campement forestier. Parlez-lui.,Der Schmied scheint in diesem Holzfällerlager das Sagen zu haben. Sprecht mit ihm.,鍛冶屋が伐採の野営地を取り仕切っているようだ。彼に話しかけろ,대장장이가 이 벌목 야영지를 책임지고 있는 것 같습니다. 그와 대화하십시오.,O Ferreiro parece estar no comando deste acampamento madeireiro. Fale com ele.,"Похоже, в этом лагере лесорубов кузнец за главного. Поговорите с ним.",Parece que el herrero está al mando de este campamento maderero. Habla con él.,เหมือนว่าช่างตีเหล็กจะเป็นผู้นำค่ายตัดไม้ ลองพูดคุยกับเขาดู,鐵匠似乎是這個伐木營地的負責人。與他交談。,,Talk to the Blacksmith,Parlez au Forgeron,Sprecht mit dem Schmied,鍛冶屋に話しかけろ,대장장이와 대화하기,Fale com o Ferreiro,Поговорите с кузнецом,Habla con el herrero,พูดคุยกับช่างตีเหล็ก,與鐵匠交談,[8],0,Talk to the Blacksmith,Parler au Forgeron,Sprecht mit dem Schmied.,鍛冶屋に話しかけろ,대장장이와 대화하기,Fale com o Ferreiro,Поговорите с кузнецом,Habla con el herrero,พูดคุยกับช่างตีเหล็ก,與鐵匠交談,,[],,[],[],10,,,[],,
3,0,3,[2934],"[281,368]",You have slain the Bloated Miller and levelled up. Open the Passive Skill Screen and spend a Passive Skill Point to upgrade your character.,Vous avez tué le Meunier boursouflé et êtes monté de niveau. Ouvrez l'arbre des Talents et dépensez un point de Talent pour améliorer votre personnage.,"Ihr habt den Aufgedunsenen Müller getötet und eine neue Stufe erreicht. Öffnet den Passiven Fertigkeitenbaum und weist einen Passiven Fertigkeitspunkt zu, um Euren Charakter zu verbessern.",腐乱した粉屋を倒しレベルが上がった。パッシブスキル画面を開いてパッシブスキルポイントを消費し、キャラクターをアップグレードしろ,불어 터진 방아꾼을 처치하고 레벨을 올렸습니다. 패시브 스킬 창을 열고 패시브 스킬 포인트를 투자해 캐릭터를 강화하십시오.,Você matou o Triturador Inchado e subiu de nível. Abra a Tela de Habilidades Passivas e gaste um Ponto de Habilidade Passiva para melhorar seu personagem.,Вы убили Раздувшегося мельника и повысили свой уровень. Откройте экран пассивных умений и потратьте очко умения на улучшение персонажа.,Has derrotado al Molinero hinchado y subido de nivel. Abre la ventana de habilidades pasivas y gasta un punto de habilidad pasiva para mejorar tu personaje.,คุณได้สังหารเจ้าของโรงสีขึ้นอืดและได้ขึ้นเลเวลใหม่แล้ว อัพเกรดตัวละครของคุณด้วยการเปิดหน้าต่างพาสซีฟ แล้วใช้แต้มพาสซีฟ 1 แต้ม,你已經擊殺浮腫米勒並升等了,開啟天賦樹畫面使用天賦點數升級你的角色。,,,,,,,,,,,,[8],0,Open the Passive Skill Screen\nSpend your Passive Skill Point,Ouvrez votre Arbre des Talents\nDépensez-y votre point de Talent,Öffnet den Passiven Fertigkeitsbaum\nWeist den Passiven Fertigkeitspunkt zu,パッシブスキル画面を開け\nパッシブスキルポイントを使用しろ,패시브 스킬 창 열기\n패시브 스킬 포인트 투자하기,Abra a Tela de Habilidades Passivas\nGaste o seu Ponto de Habilidade Passiva,Откройте экран пассивных умений\nИспользуйте очко пассивного умения,Abre la pantalla de habilidades pasivas\nGasta tu punto de habilidad pasiva,เปิดหน้าจอพาสซีฟ\nใช้แต้มพาสซีฟของคุณ,開啟天賦樹畫面\n使用天賦點,,[],,[],[],10,,,[],,
4,0,4,[2934],[],You have slain the Bloated Miller. Enter the logging encampment.,Vous avez tué le Meunier boursouflé. Entrez dans le campement forestier.,Ihr habt den Aufgedunsenen Müller getötet. Betretet das Holzfällerlager.,腐乱した粉屋を倒した。伐採の野営地に入れ,불어 터진 방아꾼을 처치했습니다. 벌목 야영지로 들어가십시오.,Você matou o Triturador Inchado. Entre no acampamento madeireiro.,Вы убили Раздувшегося мельника. Войдите в лагерь лесорубов.,Has derrotado al Molinero hinchado. Entra al campamento maderero.,คุณได้สังหารเจ้าของโรงสีขึ้นอืดแล้ว เข้าไปในค่ายตัดไม้,你已擊殺浮腫米勒。進入伐木營地。,,Enter town,Entrez dans la ville,Betretet die Stadt,街に入れ,마을 들어가기,Entre na cidade,Войдите в лагерь,Entra al pueblo,เข้าไปในเมือง,進入城鎮,[8],0,Enter the logging encampment,Entrez dans le campement forestier,Betretet das Holzfällerlager.,伐採の野営地に入れ,벌목 야영지 들어가기,Entre no acampamento de exploração madeireira,Войдите в лагерь лесорубов,Entra al campamento maderero,เข้าไปในค่ายตัดไม้,進入伐木營地,,[],,[],[],10,,,[],,
5,0,5,[2933],[],A logging encampment is under attack by a diseased monstrosity that was once human. Kill it.,Un campement forestier subit l'attaque d'une monstruosité malade autrefois humaine. Tuez-la.,"Ein Holzfällerlager wird von einer krankhaften Monstrosität heimgesucht, die einst ein Mensch war. Tötet sie.",伐採の野営地がかつて人間だった蝕まれた怪物の攻撃を受けている。その怪物を倒せ,벌목 야영지가 한때 인간이었던 질병 걸린 거수에게 공격받고 있습니다. 처치하십시오.,"Um acampamento madeireiro está sob ataque de algo que já foi humano, mas agora é uma monstruosidade adoecida. Mate-a.","На лагерь лесорубов напало чумное чудовище, некогда бывшее человеком. Убейте его.",Un campamento maderero está recibiendo un ataque de una monstruosidad enfermiza que una vez fue humana. Mátala.,ค่ายตัดไม้ถูกรุกรานด้วยน้ำมือของอสุรกายอาบโรคที่เคยเป็นมนุษย์มาก่อน สังหารมันเสีย,伐木營地被原為人類的染病怪物襲擊。殺死牠。,,Slay the Bloated Miller,Tuez le Meunier boursouflé,Tötet den Aufgedunsenen Müller,腐乱した粉屋を倒せ,불어 터진 방아꾼 처치하기,Mate o Triturador Inchado,Убейте Раздувшегося мельника,Derrota al Molinero hinchado,สังหารเจ้าของโรงสีขึ้นอืด,殺死浮腫米勒,[5],0,Kill the Bloated Miller and end his rage,Tuez le Meunier boursouflé et mettez fin à sa rage,Tötet den Aufgedunsenen Müller und setzt seinem Wüten ein Ende.,腐乱した粉屋を倒し、彼の怒りを終わらせろ,불어 터진 방아꾼을 처치해 그의 격노를 잠재우기,Mate o Triturador Inchado e acabe com sua raiva.,Убейте Раздувшегося мельника и покончите с его яростью,Mata al Molinero hinchado y acaba con su furia,สังหารเจ้าของโรงสีขึ้นอืดแล้วยุติความคลั่งของเขา,擊殺浮腫米勒並終止他的怒火,,[],,[],[],10,,,[],,
6,0,6,[2801],[],The wounded man mentioned his chief Miller went to warn Clearfell about a sickness plaguing their men. Track down the Miller and find safety in Clearfell.,L'homme blessé a mentionné que son Meunier en chef était parti avertir la Clairière d'une maladie frappant leurs hommes. Suivez la piste du Meunier et trouvez refuge dans la Clairière.,"Der Verwundete erwähnte, dass sein Anführer, der Müller, Lichtfall vor einer Krankheit warnen wollte, die ihre Männer plagt. Spürt den Müller auf und bringt Euch in Lichtfall in Sicherheit.",負傷した男は、彼の親方である粉屋が彼の部下たちを苦しめている疫病についてクリアフェルに警告しに行ったことを話していた。粉屋を追い、クリアフェルで安全な場所を見つけろ,다친 남자가 말하길 수석 방아꾼이 일꾼들 사이에서 돌고 있는 병에 대해 경고하기 위해 클리어펠로 향했다고 합니다. 방아꾼을 찾고 클리어펠에 피신하십시오.,O homem ferido mencionou que o Triturador foi avisar Clearfell sobre uma doença que assola seus homens. Rastreie o Triturador e fique em segurança em Clearfell.,"Раненый мужчина упомянул, что его начальник-мельник отправился в Клирфелл предупредить о болезни. Найдите мельника и безопасное убежище в Клирфелле.",El hombre herido ha mencionado que el jefe del molino ha ido a Sierraclara para advertirles sobre una enfermedad que está azotando a sus hombres. Busca al Molinero y encuentra refugio en Sierraclara.,ชายที่บาดเจ็บบอกว่าเจ้าของโรงสีออกไปเตือนเคลียร์เฟลเกี่ยวกับโรคภัยที่ระบาดไปตามคนของพวกเขา ตามหาเจ้าของโรงสีแล้วหาที่ปลอดภัยในเคลียร์เฟลเสีย,找到皆伐。,,Find Clearfell,Trouvez la Clairière,Findet Lichtfall,クリアフェルを見つけろ,클리어펠 찾기,Encontre Clearfell.,Найдите Клирфелл,Encuentra Sierraclara,ค้นหาเคลียร์เฟล,找到皆伐,[5],0,Search for the Miller and find safety in Clearfell,Cherchez le Meunier et trouvez refuge dans la Clairière,Sucht nach dem Müller und findet Sicherheit in Lichtfall.,粉屋を探し、クリアフェルで安全な場所を見つけろ,방아꾼을 찾고 클리어펠에 피신하기,Procure pelo Triturador e fique em segurança em Clearfell,Найдите мельника и безопасное убежище в Клирфелле,Busca al Molinero y encuentra refugio en Sierraclara,ตามหาเจ้าของโรงสี แล้วหาที่ปลอดภัยในเคลียร์เฟล,尋找米勒,並在皆伐尋求庇護,,[],,[],[],10,,,[],,
7,1,0,[2929],[],Quest Complete - You have slain the Devourer and have received a reward from Renly.,Quête terminée — Vous avez tué le Dévoreur et avez reçu une récompense de la part de Renly.,Quest abgeschlossen: Ihr habt den Verschlinger getötet und von Renly eine Belohnung erhalten.,クエスト完了 - デヴァウラーを倒し、レンリーから報酬を受け取った,퀘스트 완료 - 포식자를 처치하고 렌리에게 보상을 받았습니다.,Missão Cumprida - Você matou o Devorador e recebeu uma recompensa do Renly.,Задание выполнено - Вы убили Пожирателя и получили награду от Ренли.,Misión completa - Has derrotado al Devorador y recibido una recompensa de Renly.,เควสต์​เสร็จสิ้น - คุณได้สังหารตัวสวาปามและรับรางวัลจากเรนลีย์แล้ว,任務完成——你已經殺掉了吞噬獸,並且從倫利那裡取得你的任務獎勵。,1,Quest Complete,Quête terminée,Quest abgeschlossen,クエスト完了,퀘스트 완료,Missão Concluída,Задание выполнено,Misión completa,เควสต์​เสร็จสิ้น,任務完成,[],0,,,,,,,,,,,,[],,[],[],10,,,[],,
8,1,1,[2931],[],You have slain the Devourer. Talk to Renly in Clearfell for your reward.,Vous avez tué le Dévoreur. Parlez à Renly à la Clairière pour obtenir votre récompense.,Ihr habt den Verschlinger getötet. Sprecht Renly in Lichtfall auf Eure Belohnung an.,デヴァウラーを倒した。クリアフェルのレンリーに話しかけて報酬を受け取れ,포식자를 처치했습니다. 클리어펠에 있는 렌리와 대화해서 보상을 받으십시오.,Você matou o Devorador. Fale com Renly em Clearfell para receber sua recompensa.,Вы убили Пожирателя. Поговорите с Ренли в Клирфелле по поводу награды.,Has derrotado al Devorador. Habla con Renly en Sierraclara para recibir tu recompensa.,คุณได้สังหารตัวสวาปามแล้ว พูดคุยกับเรนลีย์ภายในค่ายเคลียร์เฟลเพื่อรับรางวัล,你已殺掉吞噬獸,在皆伐與倫利交談並領取你的獎勵。,,Talk to Renly for your reward,Parlez à Renly pour obtenir votre récompense,Sprecht Renly auf Eure Belohnung an,レンリーに話しかけて報酬を受け取れ,렌리와 대화해서 보상 받기,Fale com Renly para pegar sua recompensa,Поговорите с Ренли о награде,Habla con Renly para recibir tu recompensa,พูดคุยกับเรนลีย์เพื่อรับรางวัล,與倫利交談以獲得獎勵,[8],0,Talk to Renly for your reward,Parlez à Renly pour obtenir votre récompense,Sprecht Renly auf Eure Belohnung an.,レンリーに話しかけて報酬を受け取れ,렌리와 대화해서 보상 받기,Fale com Renly para receber sua recompensa,Поговорите с Ренли о награде,Habla con Renly para recibir tu recompensa,พูดคุยกับเรนลีย์เพื่อรับรางวัล,與倫利交談以獲得獎勵,,[],,[],[],10,,,[],,
9,1,2,[2932],[],You have cornered the Devourer. Kill it.,Vous avez acculé le Dévoreur. Tuez-le.,Ihr habt den Verschlinger aufgespürt. Tötet ihn.,デヴァウラーを追い詰めた。やつを倒せ,포식자를 막다른 길로 몰았습니다. 처치하십시오.,Você encurralou o Devorador. Mate-o.,Вы загнали Пожирателя в угол. Убейте его.,Has acorralado al Devorador. Mátalo.,คุณต้อนตัวสวาปามให้จนมุมแล้ว สังหารมันเสีย,你已將吞噬獸逼入死角。殺死牠。,,Kill the Devourer,Tuez le Dévoreur,Tötet den Verschlinger,デヴァウラーを倒せ,포식자 처치하기,Mate o Devorador,Убейте Пожирателя,Mata al Devorador,สังหารตัวสวาปาม,殺死吞噬獸,[14],0,Kill the Devourer,Tuez le Dévoreur,Tötet den Verschlinger.,デヴァウラーを倒せ,포식자 처치하기,Mate o Devorador,Убейте Пожирателя,Mata al Devorador,สังหารตัวสวาปาม,殺死吞噬獸,14,[],,[],[],10,,,[],,
10,1,3,"[2925,2892]",[],You have found the Mud Burrow. Search the tunnels for the Devourer.,Vous avez trouvé la Tanière boueuse. Fouillez les tunnels à la recherche du Dévoreur.,Ihr habt die Schlammgrube gefunden. Durchsucht die Tunnel nach dem Verschlinger.,泥の巣穴を見つけたトンネルを探索しデヴァウラーを見つけろ,진흙 토굴을 찾았습니다. 굴을 수색해서 포식자를 찾으십시오.,Você encontrou a Toca Lamacenta. Procure pelo Devorador nos túneis.,"Вы нашли Грязевую нору. Обыщите туннели, чтобы найти Пожирателя.",Has encontrado el Lodazal. Registra los túneles para encontrar al Devorador.,คุณได้พบโพรงโคลนแล้ว ตามหาตัวสวาปามภายในโพรงเหล่านี้,你已找到泥沼陋居,在坑道中搜尋吞噬獸的蹤跡。,,Search the Mud Burrow,Fouillez la Tanière Boueuse,Durchsucht die Schlammgrube,泥の巣穴を探索しろ,진흙 토굴 수색하기,Procure na Toca Lamacenta,Обыщите Грязевую нору,Investiga el Lodazal,ค้นหาภายในโพรงโคลน,在泥沼陋居進行搜索,[14],0,Find the Devourer and slay it,Trouvez le Dévoreur et tuez-le,Findet den Verschlinger und tötet ihn.,デヴァウラーを見つけて倒せ,포식자를 찾아서 처치하기,Encontre o Devorador e mate-o,Найдите Пожирателя и убейте его,Encuentra al Devorador y mátalo,ตามหาและสังหารตัวสวาปาม,找出吞噬獸並加以消滅,14,[],,[],[],10,,,[],,
11,1,4,[2925],[],The Devourer lives underground in a Mud Burrow. Find it.,Le Dévoreur vit sous terre dans une Tanière boueuse. Trouvez-la.,Der Verschlinger verweilt in einer Schlammgrube unter der Erde. Findet sie.,デヴァウラーは泥の巣穴の地下に棲んでいる。見つけ出せ,포식자는 진흙 토굴 지하에 살고 있습니다. 찾으십시오.,O Devorador vive no subterrâneo em uma Toca Lamacenta. Encontre-o.,Пожиратель живёт под землёй в Грязевой норе. Найдите её.,El Devorador vive bajo tierra en un Lodazal. Encuéntralo.,ตัวสวาปามอาศัยอยู่ใต้ดินในโพรงโคลน ตามหามันให้เจอ,吞噬獸住在地底下的泥沼陋居,想辦法找到牠。,,Find the Mud Burrow,Trouvez la Tanière boueuse,Findet die Schlammgrube,泥の巣穴を見つけろ,진흙 토굴 찾기,Encontre a Toca Lamacenta,Найдите Грязевую нору,Encuentra el Lodazal,ค้นหาโพรงโคลน,尋找泥沼陋居,[9],0,Search Clearfell to find the Mud Burrow entrance\nSlay the Devourer in its lair,Fouillez la Clairière pour trouver l'entrée de la Tanière boueuse\nTuez le Dévoreur dans son antre,Durchsucht Lichtfall nach dem Eingang zur Schlammgrube\nTötet den Verschlinger in seinem Versteck.,クリアフェルを探索し泥の巣穴の入口を見つけろ\nデヴァウラーをその巣で倒せ,클리어펠을 수색해서 진흙 토굴 입구 찾기\n소굴에 있는 포식자 처치하기,Procure em Clearfell para encontrar a entrada da Toca Lamacenta\nMate o Devorador em seu covil,Найдите на Вырубке вход в Грязевую нору\nУбейте Пожирателя в его логове,Registra Sierraclara para encontrar la entrada al Lodazal\nDerrota al Devorador en su guarida,ค้นหาทางเข้าโพรงโคลนภายในเคลียร์เฟล\nสังหารตัวสวาปามในรังของมัน,搜尋皆伐,找出泥沼陋居的入口\n在吞噬獸的巢穴擊殺吞噬獸,,[94],,[],[],10,,,[],,
12,1,5,[2925],[],Find the Devourer in its Mud Burrow and slay it so that the Ezomytes can safely leave the walls of Clearfell once more.,Trouvez le Dévoreur dans sa Tanière boueuse et tuez-le pour que les Ézomytes puissent à nouveau quitter les murs de la Clairière en toute sécurité.,"Spürt den Verschlinger in seiner Schlammgrube auf und tötet ihn, damit die Ezomyten die Mauern von Lichtfall endlich wieder sicher verlassen können.",エゾマイト人が再び安全にクリアフェルの壁から離れることができるように、泥の巣穴でデヴァウラーを見つけて倒せ,에조미어인들이 다시 안전하게 클리어펠 밖으로 떠날 수 있도록 진흙 토굴에 있는 포식자를 찾아 처치하십시오.,Encontre o Devorador em sua Toca Lamacenta e mate-o para que os Ezomitas possam sair de Clearfell em segurança.,"Найдите Пожирателя в его Грязевой норе и убейте его, чтобы эзомиты могли вновь без опаски выходить за стены Клирфелла.",Encuentra al Devorador en el Lodazal y mátalo para que los ezomitas puedan salir con seguridad de los muros de Sierraclara.,ตามหาและสังหารตัวสวาปามภายในโพรงโคลน เพื่อให้เหล่าเอโซไมต์ได้ออกจากเคลียร์เฟลอย่างปลอดภัยอีกครั้ง,在泥沼陋居中找到吞噬獸並加以擊殺,讓艾茲麥人能再次安全地踏出皆伐城牆的保護範圍。,,Slay the Devourer,Tuez le Dévoreur,Tötet den Verschlinger,デヴァウラーを倒せ,포식자 처치하기,Mate o Devorador,Убейте Пожирателя,Derrota al Devorador,สังหารตัวสวาปาม,擊殺吞噬獸,[9],0,Search Clearfell for the entrance to the Mud Burrow\nSlay the Devourer in its lair,Fouillez la Clairière à la recherche de l'entrée de la Tanière boueuse\nTuez le Dévoreur dans son antre,Sucht in Lichtfall nach dem Eingang zur Schlammgrube\nTötet den Verschlinger in seinem Versteck.,クリアフェルを探索し泥の巣穴の入口を見つけろ\nデヴァウラーをその巣で倒せ,클리어펠을 수색해서 진흙 토굴 입구 찾기\n소굴에 있는 포식자 처치하기,Procure em Clearfell para encontrar a entrada da Toca Lamacenta\nMate o Devorador em seu covil,Найдите на Вырубке вход в Грязевую нору\nУбейте Пожирателя в его логове,Registra Sierraclara para encontrar la entrada al Lodazal\nDerrota al Devorador en su guarida,ค้นหาทางเข้าโพรงโคลนภายในเคลียร์เฟล\nสังหารตัวสวาปามในรังของมัน,在伐木場尋找泥沼陋居的入口\n在吞噬獸的巢穴擊殺吞噬獸,,[],,[],[],10,,,[],,
13,2,0,[4701],[],Quest Complete - You have released a dark entity called the Hooded One from the Tree of Souls.,Quête terminée — Vous avez libéré de l'Arbre des âmes une entité sombre appelée l'Encapuchonné.,Quest abgeschlossen: Ihr habt ein dunkles Wesen namens der Verhüllte aus dem Baum der Seelen befreit.,クエスト完了 - フードをかぶった者と呼ばれる闇の存在を魂の木から解放した,퀘스트 완료 - 영혼의 나무에서 두건 쓴 자라는 어둠의 존재를 풀어줬습니다.,"Missão Concluída - Você libertou uma entidade sombria da Árvore das Almas chamada O Encapuzado ",Задание выполнено - Вы освободили тёмную сущность по имений Скрытный от Дерева Душ.,Misión completa - Has liberado a una entidad oscura llamada el Encapuchado del Árbol de las almas.,เควสต์เสร็จสิ้น - คุณได้ปลดปล่อยบุคคลมืดมนที่มีชื่อว่าผู้คลุมกายออกมาจากต้นตรึงวิญญาณแล้ว,任務完成——你從攝魂之樹釋放出一個名為黑衣幽魂的黑暗生物。,1,Quest Complete,Quête terminée,Quest abgeschlossen,クエスト完了,퀘스트 완료,Missão Concluída,Задание выполнено,Misión completa,เควสต์เสร็จสิ้น,任務完成,[],0,,,,,,,,,,,,[],,[],[],10,,,[],,
14,2,1,[4696],[],You have released a dark entity called the Hooded One from the Tree of Souls. Return to Clearfell Encampment and speak to Una.,Vous avez libéré de l'Arbre des âmes une entité sombre appelée l'Encapuchonné. Retournez au Campement de la Clairière et parlez à Una.,Ihr habt ein dunkles Wesen namens der Verhüllte aus dem Baum der Seelen befreit. Kehrt zum Lichtfall-Lager zurück und sprecht mit Una.,フードをかぶった者と呼ばれる闇の存在を魂の木から解放した。クリアフェルの野営地に戻りウーナと話せ,영혼의 나무에서 두건 쓴 자라는 어둠의 존재를 풀어줬습니다. 클리어펠 야영지로 돌아가서 우나와 대화하십시오.,Você libertou uma entidade sombria da Árvore das Almas chamada O Encapuzado. Volte ao Acampamento Clearfell e fale com a Una.,Вы освободили тёмную сущность по имений Скрытный от Дерева Душ. Вернитесь в Лагерь Клирфелл и поговорите с Уной.,Has liberado a una entidad oscura llamada el Encapuchado en el Árbol de las almas. Regresa al Campamento de Sierraclara y habla con Una.,คุณได้ปลดปล่อยบุคคลมืดมนที่มีชื่อว่าผู้คลุมกายออกมาจากต้นตรึงวิญญาณแล้ว กลับไปพูดคุยกับอูน่าที่ค่ายเคลียร์เฟล,你從攝魂之樹釋放出一個名為黑衣幽魂的黑暗生物。返回皆伐營地與烏娜交談。,,Meet Una in Clearfell,Retrouvez Una à la Clairière,Trefft Una in Lichtfall,クリアフェルでウーナに会え,클리어펠에 있는 우나 만나기,Encontre Una em Clearfell,Встретьтесь с Уной в Клирфелле,Reúnete con Una en Sierraclara,ไปพบกับอูน่าในเคลียร์เฟล,在皆伐與烏娜碰面,[8],0,Return to Clearfell Encampment and speak to Una,Retournez au Campement de la Clairière et parlez à Una,Kehrt zum Lichtfall-Lager zurück und sprecht mit Una.,クリアフェルの野営地に戻りウーナと話せ,클리어펠 야영지로 돌아가서 우나와 대화하기,Volte ao Acampamento Clearfell e fale com Una,Вернитесь в Лагерь Клирфелл и поговорите с Уной,Regresa al Campamento de Sierraclara y habla con Una,กลับไปยังค่ายเคลียร์เฟลแล้วพูดคุยกับอูน่า,返回皆伐營地與烏娜交談,,[],,[],[],10,,,[],,
38,2,25,[2894],[2943],This vale is littered with the debris of countless battles. Search it for anything that may still be useful.,Cette vallée est jonchée des débris d'innombrables batailles. Cherchez-y tout ce qui pourrait encore être utile.,"\r\nDieses Tal ist mit den Trümmern unzähliger Schlachten übersät. Durchsucht es nach allem, was noch nützlich sein könnte.",この谷には数え切れない戦いの残骸が散らばっている。まだ役に立ちそうなものを探せ,이 계곡에는 헤아릴 수 없이 많은 전투의 흔적이 흩어져 있습니다. 잔해를 수색해서 아직 쓸 수 있는 걸 뭐든 찾아내십시오.,Este vale é coberto por detritos de inúmeras batalhas. Procure coisas que possam ser úteis,"Эта долина щетинится останками бесчисленных битв. Обыщите её на предмет того, что ещё может быть полезно.",El valle está lleno de deshechos de innumerables batallas. Regístralo en busca de objetos que puedan resultar útiles.,ห้วยนี้เกลื่อนกลาดไปด้วยซากของศึกเหนือคณานับ ค้นหาสิ่งที่ยังพอมีประโยชน์ภายในนี้,殘餘的魔法能量還在赤谷中縈繞。調查鐵鏽方尖碑,尋找有關力量魔符的情報。,,Search the Red Vale,Inspectez la Vallée rouge,Durchsucht das Rote Tal,赤き谷を探索しろ,붉은 계곡 수색하기,Procure no Vale Vermelho,Обыщите Красную Долину,Registra el Valle rojo,ค้นหาภายในห้วยสีชาด,調查鐵鏽方尖碑,[27],0,Search the Red Vale for Obelisks of Rust containing Runes of Power,Cherchez dans la Vallée rouge les Obélisques de Rouille qui contiennent les Runes de pouvoir,Durchsucht das Rote Tal nach Obelisken aus Rostdie Runen der Macht enthalten.,赤き谷で力のルーンが含まれる錆びたオベリスクを探せ,붉은 계곡을 수색해서 힘의 룬을 지닌 녹의 오벨리스크 찾기,Procure no Vale Vermelho por Obeliscos da Ferrugem que contenham Runas de Poder,Отыщите в Красной Долине ржавые обелиски с Рунами силы,Registra el Valle rojo para encontrar los obeliscos oxidados que contienen las runas de poder,ค้นหาเสาหินสนิมที่มีอักขระแห่งพลังภายในห้วยสีชาด,調查鐵鏽方尖碑\n收集力量魔符,27,[],,[],[],10,,,"[2974,2975,2976]"
Can't render this file because it has a wrong number of fields in line 17.

View file

@ -28,13 +28,16 @@ public sealed class NavigationController
// Explored grid — tracks which terrain cells the player has visited
private bool[]? _exploredGrid;
private int _exploredWidth, _exploredHeight;
private const int ExploreMarkRadius = 92; // grid cells (~1000 world units)
private const int ExploreMarkRadius = 150; // grid cells (~1630 world units)
// Stuck detection: rolling window of recent positions
private readonly Queue<Vector2> _positionHistory = new();
private const int StuckWindowSize = 10;
private const float StuckThreshold = 5f;
// Path failure cooldown — don't retry immediately when pathfinding fails
private long _pathFailCooldownMs;
public NavMode Mode { get; private set; } = NavMode.Idle;
public Vector2? DesiredDirection { get; private set; }
public IReadOnlyList<Vector2>? CurrentPath => _path;
@ -60,6 +63,7 @@ public sealed class NavigationController
_targetEntityId = 0;
_path = null;
_waypointIndex = 0;
_pathFailCooldownMs = 0;
Mode = NavMode.NavigatingToPosition;
Status = $"Nav to ({position.X:F0}, {position.Y:F0})";
Log.Debug("NavigationController: navigating to {Position}", position);
@ -71,6 +75,7 @@ public sealed class NavigationController
_goalPosition = null;
_path = null;
_waypointIndex = 0;
_pathFailCooldownMs = 0;
Mode = NavMode.NavigatingToEntity;
Status = $"Nav to entity {entityId}";
Log.Debug("NavigationController: navigating to entity {EntityId}", entityId);
@ -127,6 +132,7 @@ public sealed class NavigationController
_positionHistory.Clear();
_exploreBiasPoint = null;
_exploredGrid = null;
_pathFailCooldownMs = 0;
IsExplorationComplete = false;
}
@ -169,12 +175,16 @@ public sealed class NavigationController
if (goal is null)
{
if (Mode == NavMode.Exploring)
{
if (IsExplorationComplete)
return; // Already fully explored, don't re-BFS every tick
goal = PickExploreTarget(state);
}
if (goal is null)
{
if (Mode == NavMode.Exploring)
return; // Try again next tick
return;
Stop();
return;
}
@ -224,6 +234,10 @@ public sealed class NavigationController
// Repath conditions: no path, stuck, stale (>5s)
var needsRepath = _path is null || isStuck || (now - _pathTimestampMs > 5000);
// Don't retry pathfinding during failure cooldown
if (needsRepath && _path is null && now < _pathFailCooldownMs)
return;
// Entity moved significantly → repath
if (Mode == NavMode.NavigatingToEntity && _path is not null && _goalPosition.HasValue)
{
@ -249,14 +263,25 @@ public sealed class NavigationController
}
_path = Mode == NavMode.Exploring
? PathFinder.FindPath(state.Terrain, playerPos, goal.Value, _config.WorldToGrid,
? PathFinder.FindPath(terrain, playerPos, goal.Value, _config.WorldToGrid,
_exploredGrid, _exploredWidth, _exploredHeight)
: PathFinder.FindPath(state.Terrain, playerPos, goal.Value, _config.WorldToGrid);
: PathFinder.FindPath(terrain, playerPos, goal.Value, _config.WorldToGrid);
_waypointIndex = 0;
_pathTimestampMs = now;
if (_path is null && Mode == NavMode.Exploring)
{
// Retry without explored bias — the bias can make distant targets unreachable
_path = PathFinder.FindPath(terrain, playerPos, goal.Value, _config.WorldToGrid);
if (_path is not null)
Log.Debug("PATH OK (no-bias fallback): {Count} waypoints", _path.Count);
}
if (_path is null)
{
// Cooldown: wait 3s before retrying to avoid burning CPU on impossible paths
_pathFailCooldownMs = now + 3000;
if (Mode == NavMode.Exploring)
{
Log.Debug("PATH FAIL: unreachable explore target, picking new");

View file

@ -1,5 +1,6 @@
using System.Numerics;
using Roboto.Core;
using Serilog;
namespace Roboto.Navigation;
@ -39,12 +40,17 @@ public static class PathFinder
var openSet = new PriorityQueue<(int x, int y), float>();
var cameFrom = new Dictionary<(int, int), (int, int)>();
var gScore = new Dictionary<(int, int), float>();
var closedSet = new HashSet<(int, int)>();
// Track closest-to-goal node for fallback when goal is unreachable
(int x, int y) bestNode = startNode;
var bestDist = Heuristic(startNode, goalNode);
gScore[startNode] = 0;
openSet.Enqueue(startNode, Heuristic(startNode, goalNode));
var iterations = 0;
const int maxIterations = 50_000;
const int maxIterations = 200_000;
while (openSet.Count > 0 && iterations++ < maxIterations)
{
@ -53,6 +59,18 @@ public static class PathFinder
if (current == goalNode)
return ReconstructAndSimplify(cameFrom, current, gridToWorld);
// Skip already-expanded nodes (duplicate PQ entries)
if (!closedSet.Add(current))
continue;
// Track nearest reachable cell to goal
var distToGoal = Heuristic(current, goalNode);
if (distToGoal < bestDist)
{
bestDist = distToGoal;
bestNode = current;
}
var currentG = gScore.GetValueOrDefault(current, float.MaxValue);
for (var i = 0; i < 8; i++)
@ -63,6 +81,9 @@ public static class PathFinder
if (nx < 0 || nx >= w || ny < 0 || ny >= h) continue;
if (!terrain.IsWalkable(nx, ny)) continue;
var neighbor = (nx, ny);
if (closedSet.Contains(neighbor)) continue;
// Diagonal corner-cut check
if (i >= 4)
{
@ -71,10 +92,9 @@ public static class PathFinder
continue;
}
var neighbor = (nx, ny);
var stepCost = Cost[i];
if (exploredGrid is not null && nx < exploredWidth && ny < exploredHeight && exploredGrid[ny * exploredWidth + nx])
stepCost *= 3f;
stepCost *= 1.5f;
var tentativeG = currentG + stepCost;
if (tentativeG < gScore.GetValueOrDefault(neighbor, float.MaxValue))
@ -86,6 +106,19 @@ public static class PathFinder
}
}
// No exact path — check if we exhausted the connected region or hit iteration limit
var exhausted = openSet.Count == 0;
Log.Warning("PATH FAIL detail: expanded={Expanded}, {Reason}, bestDist={Best:F0} from goal",
closedSet.Count, exhausted ? "disconnected regions" : "iteration limit", bestDist);
// Fallback: path to closest reachable cell (only if meaningfully closer than start)
if (bestNode != startNode && bestDist < Heuristic(startNode, goalNode) * 0.8f)
{
Log.Information("PATH FALLBACK: pathing to nearest reachable cell ({X},{Y}), dist={D:F0} from goal",
bestNode.x, bestNode.y, bestDist);
return ReconstructAndSimplify(cameFrom, bestNode, gridToWorld);
}
return null;
}

View file

@ -12,6 +12,7 @@ public class CombatSystem : ISystem
private List<SkillProfile> _skills;
private int _globalCooldownMs;
private readonly float _worldToGrid;
private readonly Dictionary<int, long> _cooldowns = new();
private long _lastCastGlobal;
@ -22,14 +23,24 @@ public class CombatSystem : ISystem
// MaintainPressed tracking — which slots are currently held down
private readonly HashSet<int> _heldSlots = new();
// Kiting / orbit-herding
private bool _kiteEnabled;
private float _kiteRange = 300f;
private int _kiteDelayMs = 200;
private int _orbitSign = 1; // +1 = CCW, -1 = CW — persists for smooth orbiting
public CombatSystem(BotConfig config)
{
_worldToGrid = config.WorldToGrid;
var defaultProfile = new CharacterProfile();
_skills = defaultProfile.Skills
.Where(s => s.IsEnabled)
.OrderBy(s => s.Priority)
.ToList();
_globalCooldownMs = defaultProfile.Combat.GlobalCooldownMs;
_kiteEnabled = defaultProfile.Combat.KiteEnabled;
_kiteRange = defaultProfile.Combat.KiteRange;
_kiteDelayMs = defaultProfile.Combat.KiteDelayMs;
}
/// <summary>
@ -42,6 +53,9 @@ public class CombatSystem : ISystem
.OrderBy(s => s.Priority)
.ToList();
_globalCooldownMs = profile.Combat.GlobalCooldownMs;
_kiteEnabled = profile.Combat.KiteEnabled;
_kiteRange = profile.Combat.KiteRange;
_kiteDelayMs = profile.Combat.KiteDelayMs;
_cooldowns.Clear();
_aurasCast.Clear();
_heldSlots.Clear();
@ -74,12 +88,20 @@ public class CombatSystem : ISystem
if (state.AreaHash != _lastAreaHash)
{
_aurasCast.Clear();
_orbitSign = 1;
_lastAreaHash = state.AreaHash;
}
// Global cooldown: don't cast if we recently cast any skill
if (now - _lastCastGlobal < _globalCooldownMs)
{
// Orbit-herd during cooldown window (after cast animation delay)
if (_kiteEnabled && now - _lastCastGlobal >= _kiteDelayMs
&& state.NearestEnemies.Count > 0)
{
TryHerd(state, actions);
}
// Still need to handle MaintainPressed releases
UpdateHeldKeys(state, camera, playerZ, actions);
return;
@ -88,18 +110,21 @@ public class CombatSystem : ISystem
// Per-skill targeting: iterate skills in priority order, find best target for each
foreach (var skill in _skills)
{
// Per-slot cooldown check
if (_cooldowns.TryGetValue(skill.SlotIndex, out var lastCast) && now - lastCast < skill.CooldownMs)
continue;
// Check memory skill data if available (match by name, not slot index)
if (skill.SkillName is { Length: > 0 } && state.Player.Skills.Count > 0)
// Per-slot cooldown — rotation: ensure min delay = globalCd + 50 so other skills get a turn
if (_cooldowns.TryGetValue(skill.SlotIndex, out var lastCast))
{
var memSkill = FindMemorySkill(state.Player.Skills, skill.SkillName);
if (memSkill is not null && !memSkill.CanUse)
var effectiveCd = skill.MaintainPressed && _heldSlots.Contains(skill.SlotIndex)
? skill.CooldownMs
: Math.Max(skill.CooldownMs, _globalCooldownMs + 50);
if (now - lastCast < effectiveCd)
continue;
}
// Check memory skill data — match by slot index first, then by name
var memSkill = FindMemorySkill(state.Player.Skills, skill);
if (memSkill is not null && !memSkill.CanUse)
continue;
// Aura: self-cast once per zone
if (skill.IsAura)
{
@ -127,6 +152,12 @@ public class CombatSystem : ISystem
.Where(e => e.DistanceToPlayer >= skill.RangeMin && e.DistanceToPlayer <= skill.RangeMax)
.ToList();
// LOS filter — skip enemies behind walls
if (state.Terrain is { } terrain)
candidates = candidates
.Where(e => TerrainQuery.HasLineOfSight(terrain, state.Player.Position, e.Position, _worldToGrid))
.ToList();
// MinMonstersInRange check
if (candidates.Count < skill.MinMonstersInRange)
continue;
@ -157,7 +188,7 @@ public class CombatSystem : ISystem
}
// Normal cast
SubmitSkillAction(skill, screen.Value, actions);
SubmitSkillAction(skill, screen.Value, actions, target.Id);
_cooldowns[skill.SlotIndex] = now;
_lastCastGlobal = now;
@ -170,6 +201,68 @@ public class CombatSystem : ISystem
UpdateHeldKeys(state, camera, playerZ, actions);
}
/// <summary>
/// Orbit-herding: move perpendicular to enemy centroid so scattered mobs converge
/// into a tight cluster for AOE. Maintains ideal distance via radial bias.
/// </summary>
private void TryHerd(GameState state, ActionQueue actions)
{
var playerPos = state.Player.Position;
// Compute centroid of nearby hostiles
var centroid = Vector2.Zero;
var count = 0;
foreach (var e in state.NearestEnemies)
{
if (!e.IsAlive || e.DistanceToPlayer > _kiteRange * 2.5f) continue;
centroid += e.Position;
count++;
}
if (count == 0) return;
centroid /= count;
var toCentroid = centroid - playerPos;
var dist = toCentroid.Length();
if (dist < 1f) return;
var centroidDir = toCentroid / dist;
// Perpendicular = orbit direction (tangent to circle around centroid)
var perp = new Vector2(-centroidDir.Y, centroidDir.X) * _orbitSign;
// Radial bias: maintain ideal distance (_kiteRange) from centroid
float radialBias;
if (dist < _kiteRange * 0.6f)
radialBias = -0.6f; // too close — drift outward
else if (dist > _kiteRange * 1.4f)
radialBias = 0.5f; // too far — drift inward
else
radialBias = 0f; // sweet spot — pure orbit
var dir = Vector2.Normalize(perp + centroidDir * radialBias);
// Validate against terrain — flip orbit direction on wall hit
if (state.Terrain is { } terrain)
{
var validated = TerrainQuery.FindWalkableDirection(terrain, playerPos, dir, _worldToGrid);
// If terrain forced a significantly different direction, flip orbit
if (Vector2.Dot(validated, dir) < 0.5f)
{
_orbitSign *= -1;
perp = new Vector2(-centroidDir.Y, centroidDir.X) * _orbitSign;
dir = Vector2.Normalize(perp + centroidDir * radialBias);
dir = TerrainQuery.FindWalkableDirection(terrain, playerPos, dir, _worldToGrid);
}
else
{
dir = validated;
}
}
actions.Submit(new MoveAction(SystemPriority.Combat, dir));
}
private void UpdateHeldKeys(GameState state, Matrix4x4 camera, float playerZ, ActionQueue actions)
{
if (_heldSlots.Count == 0) return;
@ -188,6 +281,12 @@ public class CombatSystem : ISystem
.Where(e => e.DistanceToPlayer >= skill.RangeMin && e.DistanceToPlayer <= skill.RangeMax)
.ToList();
// LOS filter — release held key if target went behind wall
if (state.Terrain is { } terrain)
candidates = candidates
.Where(e => TerrainQuery.HasLineOfSight(terrain, state.Player.Position, e.Position, _worldToGrid))
.ToList();
if (candidates.Count >= skill.MinMonstersInRange)
{
var target = PickBestTarget(candidates, skill.TargetSelection);
@ -213,17 +312,30 @@ public class CombatSystem : ISystem
}
/// <summary>
/// Matches a profile skill name (e.g. "SpearThrow") to a memory skill (e.g. "SpearThrowPlayer").
/// Matches a profile skill to a memory skill. Tries slot index first, then name.
/// </summary>
private static SkillState? FindMemorySkill(IReadOnlyList<SkillState> memorySkills, string profileSkillName)
private static SkillState? FindMemorySkill(IReadOnlyList<SkillState> memorySkills, SkillProfile profile)
{
if (memorySkills.Count == 0) return null;
// Match by skill bar slot index (most reliable, doesn't require SkillName config)
foreach (var ms in memorySkills)
{
if (ms.Name is null) continue;
// Name is already stripped of "Player" suffix by MemoryPoller
if (string.Equals(ms.Name, profileSkillName, StringComparison.OrdinalIgnoreCase))
if (ms.SkillBarSlot == profile.SlotIndex)
return ms;
}
// Fallback: match by name if configured
if (profile.SkillName is { Length: > 0 })
{
foreach (var ms in memorySkills)
{
if (ms.Name is null) continue;
if (string.Equals(ms.Name, profile.SkillName, StringComparison.OrdinalIgnoreCase))
return ms;
}
}
return null;
}
@ -286,7 +398,7 @@ public class CombatSystem : ISystem
return nearest;
}
private static void SubmitSkillAction(SkillProfile skill, Vector2 screenPos, ActionQueue actions)
private static void SubmitSkillAction(SkillProfile skill, Vector2 screenPos, ActionQueue actions, uint? entityId = null)
{
switch (skill.InputType)
{
@ -303,7 +415,7 @@ public class CombatSystem : ISystem
break;
case SkillInputType.KeyPress:
actions.Submit(new CastAction(SystemPriority.Combat, skill.ScanCode, screenPos));
actions.Submit(new CastAction(SystemPriority.Combat, skill.ScanCode, screenPos, entityId));
break;
}
}

View file

@ -16,6 +16,9 @@ public class MovementSystem : ISystem
public float SafeDistance { get; set; } = 400f;
public float RepulsionWeight { get; set; } = 1.5f;
/// <summary>World-to-grid conversion factor for terrain queries.</summary>
public float WorldToGrid { get; set; } = 23f / 250f;
public void Update(GameState state, ActionQueue actions)
{
if (!state.Player.HasPosition) return;
@ -41,6 +44,11 @@ public class MovementSystem : ISystem
if (repulsion.LengthSquared() < 0.0001f) return;
var direction = Vector2.Normalize(repulsion);
// Validate repulsion direction against terrain — avoid walking into walls
if (state.Terrain is { } terrain)
direction = TerrainQuery.FindWalkableDirection(terrain, state.Player.Position, direction, WorldToGrid);
actions.Enqueue(new MoveAction(Priority, direction));
}
}

View file

@ -22,6 +22,9 @@ public class ThreatSystem : ISystem
/// <summary>If closest enemy is within this range, escalate to urgent flee.</summary>
public float PointBlankRange { get; set; } = 150f;
/// <summary>World-to-grid conversion factor for terrain queries.</summary>
public float WorldToGrid { get; set; } = 23f / 250f;
private DangerLevel _prevDanger = DangerLevel.Safe;
public void Update(GameState state, ActionQueue actions)
@ -52,6 +55,10 @@ public class ThreatSystem : ISystem
fleeDir = Vector2.Normalize(fleeDir);
// Validate flee direction against terrain — avoid walking into walls
if (state.Terrain is { } terrain)
fleeDir = TerrainQuery.FindWalkableDirection(terrain, state.Player.Position, fleeDir, WorldToGrid);
// Point-blank override: if closest enemy is very close, escalate to urgent
var isPointBlank = threats.ClosestDistance < PointBlankRange;