optimizations
This commit is contained in:
parent
419e2eb4a4
commit
d124f2c288
44 changed files with 1663 additions and 639 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
{
|
||||
"GooGoGaaGa": "GooGoGaaGa_Default_Copy",
|
||||
"terdsare": "terdsare_Default",
|
||||
"dudemoko": "dudemoko_Default"
|
||||
}
|
||||
88
profiles/_config.json
Normal file
88
profiles/_config.json
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,7 +431,9 @@ 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
|
||||
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();
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>();
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
// Try new config file first
|
||||
if (File.Exists(_configFile))
|
||||
{
|
||||
if (!File.Exists(_assignmentsFile)) return;
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(_assignmentsFile);
|
||||
_assignments = JsonSerializer.Deserialize<Dictionary<string, string>>(json, JsonOpts) ?? new();
|
||||
var json = File.ReadAllText(_configFile);
|
||||
_config = JsonSerializer.Deserialize<ProfileConfig>(json, JsonOpts) ?? new();
|
||||
return;
|
||||
}
|
||||
catch
|
||||
{
|
||||
_assignments = new();
|
||||
_config = new();
|
||||
}
|
||||
}
|
||||
|
||||
private void SaveAssignments()
|
||||
// Migrate from legacy _assignments.json
|
||||
if (File.Exists(_legacyAssignmentsFile))
|
||||
{
|
||||
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 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()
|
||||
|
|
|
|||
22
src/Roboto.Core/QuestInfo.cs
Normal file
22
src/Roboto.Core/QuestInfo.cs
Normal 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; }
|
||||
}
|
||||
|
|
@ -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; }
|
||||
|
|
|
|||
88
src/Roboto.Core/TerrainQuery.cs
Normal file
88
src/Roboto.Core/TerrainQuery.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.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.LatestState = state;
|
||||
}
|
||||
_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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// Diagnostic state slots — expensive, only needed for MemoryDiagnostics UI
|
||||
MemoryProfiler.BeginSection("StateReader");
|
||||
if (readSlowData)
|
||||
_stateReader!.ReadStateSlots(snap);
|
||||
_stateReader.ReadIsLoading(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);
|
||||
|
|
|
|||
65
src/Roboto.Memory/Infrastructure/MemoryProfiler.cs
Normal file
65
src/Roboto.Memory/Infrastructure/MemoryProfiler.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
// 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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
// Build or refresh the tree traversal cache
|
||||
MemoryProfiler.BeginSection("E.Tree");
|
||||
if (_cachedTreeOrder is null || _cachedTreeEntityCount != ExpectedCount)
|
||||
{
|
||||
// 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 entityPtr = treeNode.Data.EntityPtr;
|
||||
if (entityPtr == 0) return;
|
||||
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))
|
||||
// Skip expensive reads for low-priority entities (effects, terrain, critters)
|
||||
var lowPriority = ShouldSkipComponents(entity.Type);
|
||||
|
||||
if (!lowPriority)
|
||||
{
|
||||
// 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 && !IsLowPriorityPath(entity.Type))
|
||||
if (hasComponentLookup)
|
||||
{
|
||||
var lookup = _components.ReadComponentLookup(entityPtr);
|
||||
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);
|
||||
compPtrs = null!;
|
||||
if (compCount > 0 && compCount < 200)
|
||||
{
|
||||
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 (compPtrs is not null)
|
||||
{
|
||||
// Stable component data — read once, cache
|
||||
if (!_stableCompsCache.TryGetValue(entityPtr, out var stable))
|
||||
{
|
||||
stable = new CachedEntityComponents { Rarity = -1 };
|
||||
|
||||
if (lookup.TryGetValue("Targetable", out var targetIdx) && targetIdx >= 0 && targetIdx < compPtrs.Length)
|
||||
{
|
||||
var targetComp = compPtrs[targetIdx];
|
||||
if (targetComp != 0)
|
||||
{
|
||||
var targetable = mem.Read<Targetable>(targetComp);
|
||||
entity.IsTargetable = targetable.IsTargetable != 0;
|
||||
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 (lookup.TryGetValue("Life", out var lifeIdx) && lifeIdx >= 0 && lifeIdx < compCount)
|
||||
if (!_loggedMonsterComponents)
|
||||
{
|
||||
var lifeComp = mem.ReadPointer(compFirst + lifeIdx * 8);
|
||||
_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)
|
||||
{
|
||||
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)
|
||||
// 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 = life.Health.Current;
|
||||
entity.LifeTotal = life.Health.Total;
|
||||
entity.LifeCurrent = hpCurrent;
|
||||
entity.LifeTotal = hpTotal;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (lookup.TryGetValue("Actor", out var actorIdx) && actorIdx >= 0 && actorIdx < compCount)
|
||||
if (lookup.TryGetValue("Actor", out var actorIdx) && actorIdx >= 0 && actorIdx < compPtrs.Length)
|
||||
{
|
||||
var actorComp = mem.ReadPointer(compFirst + actorIdx * 8);
|
||||
var actorComp = compPtrs[actorIdx];
|
||||
if (actorComp != 0)
|
||||
{
|
||||
var animId = mem.Read<int>(actorComp + ActorOffsets.AnimationId);
|
||||
entity.ActionId = (short)(animId & 0xFFFF);
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
lookup.TryGetValue("Transitionable", out var trIdx) && trIdx >= 0 && trIdx < compPtrs.Length)
|
||||
{
|
||||
var trComp = mem.ReadPointer(compFirst + trIdx * 8);
|
||||
var trComp = compPtrs[trIdx];
|
||||
if (trComp != 0)
|
||||
{
|
||||
var tr = mem.Read<Transitionable>(trComp);
|
||||
entity.TransitionState = tr.CurrentStateEnum;
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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,8 +414,11 @@ 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)
|
||||
|
|
|
|||
|
|
@ -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,9 +111,12 @@ public sealed class GameStates
|
|||
igsPtr = _slotPointers[offsets.InGameStateIndex];
|
||||
}
|
||||
|
||||
// Dump controller pre-slots region for diagnostics
|
||||
// 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;
|
||||
AreaLoading.Update(areaLoadingPtr);
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -40,6 +46,14 @@ public sealed class Terrain : RemoteObject
|
|||
var offsets = Ctx.Offsets;
|
||||
|
||||
if (!offsets.TerrainInline)
|
||||
{
|
||||
// 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;
|
||||
|
|
@ -47,9 +61,13 @@ public sealed class Terrain : RemoteObject
|
|||
var terrainPtr = mem.ReadPointer(terrainListPtr);
|
||||
if (terrainPtr == 0) return true;
|
||||
|
||||
var dimsPtr = mem.ReadPointer(terrainPtr + offsets.TerrainDimensionsOffset);
|
||||
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);
|
||||
if (TerrainCols > 0 && TerrainCols < 1000 &&
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,19 +354,16 @@ 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);
|
||||
var trackedHead = mem.ReadPointer(_cachedTrackedPanelAddr + offsets.TrackedQuestLinkedListOffset);
|
||||
if (trackedHead != 0)
|
||||
TraverseTrackedQuests(trackedHead, trackedMap);
|
||||
}
|
||||
}
|
||||
|
||||
// ── All quests: GameUi+0x358 ──
|
||||
var allHead = mem.ReadPointer(GameUiPtr + offsets.QuestLinkedListOffset);
|
||||
|
|
@ -563,5 +596,8 @@ public sealed class UIElements : RemoteObject
|
|||
{
|
||||
UiRootPtr = 0;
|
||||
GameUiPtr = 0;
|
||||
_cachedForGameUi = 0;
|
||||
_cachedTrackedPanelAddr = 0;
|
||||
_cachedQuestParentAddr = 0;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 Rost,die 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.
|
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.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;
|
||||
// Name is already stripped of "Player" suffix by MemoryPoller
|
||||
if (string.Equals(ms.Name, profileSkillName, StringComparison.OrdinalIgnoreCase))
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue