feat: adapt Exile-ui for linux

This commit is contained in:
2026-06-01 08:22:29 +02:00
parent f7bc80c86b
commit 166ce7816d
426 changed files with 13082 additions and 8 deletions

7
src-tauri/.gitignore vendored Normal file
View File

@ -0,0 +1,7 @@
# Generated by Cargo
# will have compiled files and executables
/target/
# Generated by Tauri
# will have schema files for capabilities auto-completion
/gen/schemas

5150
src-tauri/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

27
src-tauri/Cargo.toml Normal file
View File

@ -0,0 +1,27 @@
[package]
name = "exile-ui"
version = "0.1.0"
description = "A Tauri App"
authors = ["you"]
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[lib]
# The `_lib` suffix may seem redundant but it is necessary
# to make the lib name unique and wouldn't conflict with the bin name.
# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519
name = "exile_ui_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = [] }
tauri-plugin-opener = "2"
tauri-plugin-global-shortcut = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
dirs = "6"

3
src-tauri/build.rs Normal file
View File

@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}

View File

@ -0,0 +1,15 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Capability for the main and overlay windows",
"windows": ["main", "overlay"],
"permissions": [
"core:default",
"core:window:allow-start-dragging",
"core:window:allow-set-position",
"core:window:allow-set-size",
"core:window:allow-show",
"core:window:allow-hide",
"opener:default"
]
}

466
src-tauri/data/areas2.json Normal file
View File

@ -0,0 +1,466 @@
[
[
{
"id": "g1_1",
"name": "the riverbank"
},
{
"id": "g1_town",
"name": "clearfell encampment"
},
{
"id": "g1_2",
"name": "clearfell",
"recommendation": "2 | 2"
},
{
"id": "g1_3",
"name": "mud burrow"
},
{
"id": "g1_4",
"name": "the grelwood",
"recommendation": "2 | 2"
},
{
"id": "g1_5",
"name": "red vale",
"recommendation": "2 | 4"
},
{
"id": "g1_6",
"name": "grim tangle",
"recommendation": "4 | 5"
},
{
"id": "g1_7",
"name": "cemetery of eternals",
"recommendation": "5 | 6"
},
{
"id": "g1_8",
"name": "mausoleum of praetor",
"recommendation": "7 | 7"
},
{
"id": "g1_9",
"name": "tomb of consort",
"recommendation": "6 | 7"
},
{
"id": "g1_10",
"name": "root hollow"
},
{
"id": "g1_11",
"name": "hunting grounds",
"recommendation": "7 | 8"
},
{
"id": "g1_12",
"name": "freythorn",
"recommendation": "9 | 10"
},
{
"id": "g1_13_1",
"name": "ogham farmlands",
"recommendation": "8 | 9"
},
{
"id": "g1_13_2",
"name": "ogham village",
"recommendation": "10 | 11"
},
{
"id": "g1_14",
"name": "manor ramparts",
"recommendation": "11 | 12"
},
{
"id": "g1_15",
"name": "ogham manor",
"recommendation": "12 | 13+"
}
],
[
{
"id": "g2_1",
"name": "vastiri outskirts",
"recommendation": "13+ | 14"
},
{
"id": "g2_town",
"name": "ardura caravan"
},
{
"id": "g2_2",
"name": "traitor's passage",
"recommendation": "16.5 | 17.5"
},
{
"id": "g2_3a",
"name": "halani gates (blocked)"
},
{
"id": "g2_3",
"name": "halani gates",
"recommendation": "17.5 | 18.5"
},
{
"id": "g2_4_1",
"name": "keth",
"recommendation": "18.5 | 19"
},
{
"id": "g2_4_2",
"name": "lost city",
"recommendation": "19 | 20"
},
{
"id": "g2_4_3",
"name": "buried shrines",
"recommendation": "20 | 21"
},
{
"id": "g2_5_1",
"name": "mastodon badlands",
"recommendation": "21 | 21.5"
},
{
"id": "g2_5_2",
"name": "bone pits",
"recommendation": "21.5 | 22.5"
},
{
"id": "g2_6",
"name": "valley of the titans",
"recommendation": "22.5 | 23.5"
},
{
"id": "g2_7",
"name": "titan grotto",
"recommendation": "23.5 | 24"
},
{
"id": "g2_8",
"name": "deshar",
"recommendation": "24 | 25"
},
{
"id": "g2_9_1",
"name": "path of mourning",
"recommendation": "25 | 26"
},
{
"id": "g2_9_2",
"name": "spires of deshar",
"recommendation": "26 | 27"
},
{
"id": "g2_10_1",
"name": "mawdun quarry",
"recommendation": "14 | 15.5"
},
{
"id": "g2_10_2",
"name": "mawdun mine",
"recommendation": "15.5 | 16.5"
},
{
"id": "g2_12_1",
"name": "the dreadnought",
"recommendation": "27 | 28.5"
},
{
"id": "g2_13",
"name": "trial of sekhemas"
}
],
[
{
"id": "g3_1",
"name": "sandswept marsh",
"recommendation": "28.5 | 29"
},
{
"id": "g3_town",
"name": "ziggurat encampment"
},
{
"id": "g3_2_1",
"name": "infested barrens",
"recommendation": "31 | 32"
},
{
"id": "g3_2_2",
"name": "matlan waterways",
"recommendation": "34 | 35"
},
{
"id": "g3_4",
"name": "venom crypts",
"recommendation": "30 | 31"
},
{
"id": "g3_3",
"name": "jungle ruins",
"recommendation": "29 | 30"
},
{
"id": "g3_5",
"name": "chimeral wetlands",
"recommendation": "32 | 33"
},
{
"id": "g3_6_1",
"name": "jiquani's machinarium",
"recommendation": "33 | 33"
},
{
"id": "g3_6_2",
"name": "jiquani's sanctum",
"recommendation": "33 | 34"
},
{
"id": "g3_7",
"name": "azak bog",
"recommendation": "35 | 36"
},
{
"id": "g3_8",
"name": "drowned city",
"recommendation": "36 | 36"
},
{
"id": "g3_9",
"name": "molten vault",
"recommendation": "37 | 37"
},
{
"id": "g3_10_airlock",
"name": "temple of chaos"
},
{
"id": "g3_11",
"name": "apex of filth",
"recommendation": "36 | 37"
},
{
"id": "g3_12",
"name": "temple of kopec",
"recommendation": "37 | 38"
},
{
"id": "g3_14",
"name": "utzaal",
"recommendation": "38 | 39"
},
{
"id": "g3_16",
"name": "aggorat",
"recommendation": "39 | 40"
},
{
"id": "g3_17",
"name": "black chambers",
"recommendation": "40 | 41"
}
],
[
{
"id": "g4_town",
"name": "kingsmarch"
},
{
"id": "g4_1_1",
"name": "isle of kin",
"recommendation": "41 | 41.5"
},
{
"id": "g4_1_2",
"name": "volcanic warrens",
"recommendation": "41.5 | 42"
},
{
"id": "g4_2_1",
"name": "kedge bay",
"recommendation": "42 | 42.5"
},
{
"id": "g4_2_2",
"name": "journey's end",
"recommendation": "42.5 | 43"
},
{
"id": "g4_3_1",
"name": "whakapanu island",
"recommendation": "43 | 43"
},
{
"id": "g4_3_2",
"name": "singing caverns",
"recommendation": "43 | 43"
},
{
"id": "g4_4_1",
"name": "eye of hinekora",
"recommendation": "45 | 46"
},
{
"id": "g4_4_2",
"name": "halls of the dead",
"recommendation": "46 | 47"
},
{
"id": "g4_4_3",
"name": "trial of the ancestors"
},
{
"id": "g4_5_1",
"name": "abandoned prison",
"recommendation": "43 | 44"
},
{
"id": "g4_5_2",
"name": "solitary confinement",
"recommendation": "44 | 44"
},
{
"id": "g4_7",
"name": "shrike island",
"recommendation": "44 | 45"
},
{
"id": "g4_8a",
"name": "arastas"
},
{
"id": "g4_8b",
"name": "arastas (hostile)",
"recommendation": "47 | 47"
},
{
"id": "g4_10",
"name": "the excavation",
"recommendation": "47 | 48"
},
{
"id": "g4_11_1a",
"name": "ngakanu"
},
{
"id": "g4_11_1b",
"name": "ngakanu (hostile)",
"recommendation": "48 | 48"
},
{
"id": "g4_11_2",
"name": "heart of the tribe",
"recommendation": "49 | 49"
},
{
"id": "g4_13",
"name": "plunder's point"
}
],
[
{
"id": "p1_town",
"name": "the refuge"
},
{
"id": "p1_1",
"name": "scorched farmlands"
},
{
"id": "p1_2",
"name": "stones of serle"
},
{
"id": "p1_3",
"name": "the blackwood"
},
{
"id": "p1_4",
"name": "holten"
},
{
"id": "p1_5",
"name": "wolvenhold"
},
{
"id": "p1_6",
"name": "holten estate"
}
],
[
{
"id": "p2_town",
"name": "khari bazaar"
},
{
"id": "p2_1",
"name": "khari crossing"
},
{
"id": "p2_2",
"name": "pools of khatal"
},
{
"id": "p2_3",
"name": "sel khari sanctuary"
},
{
"id": "p2_5",
"name": "galai gates"
},
{
"id": "p2_6",
"name": "qimah"
},
{
"id": "p2_7",
"name": "qimah reservoir"
}
],
[
{
"id": "p3_town",
"name": "the glade"
},
{
"id": "p3_1",
"name": "ashen forest"
},
{
"id": "p3_2",
"name": "kriar village"
},
{
"id": "p3_3",
"name": "glacial tarn"
},
{
"id": "p3_4",
"name": "howling caves"
},
{
"id": "p3_5",
"name": "kriar peaks"
},
{
"id": "p3_6",
"name": "etched ravine"
},
{
"id": "p3_7",
"name": "cuachic vault"
}
],
[
{
"id": "g_endgame_town",
"name": "ziggurat refuge"
}
]
]

906
src-tauri/data/gems2.json Normal file
View File

@ -0,0 +1,906 @@
{
"skill": {
"acidic concoction": [0,4],
"ancestral cry": [13,1],
"ancestral spirits": [0,4],
"ancestral warrior totem": [13,1],
"apocalypse": [0,4],
"arc": [5,3],
"arctic howl": [5,1],
"armour breaker": [3,1],
"armour piercing rounds": [1,1],
"artillery ballista": [7,1],
"ball lightning": [11,3],
"barrage": [5,2],
"bleeding concoction": [0,4],
"blood hunt": [7,2],
"bloodhound's mark": [9,2],
"bone blast": [0,3],
"bone cage": [3,3],
"bone offering": [11,3],
"boneshatter": [1,1],
"bonestorm": [5,3],
"bow shot": [0,4],
"bursting fen toad": [0,4],
"chaos bolt": [0,3],
"charged staff": [9,3],
"cluster grenade": [13,1],
"comet": [11,3],
"compose requiem": [0,1],
"conductivity": [0,3],
"consecrate": [0,1],
"contagion": [1,3],
"cross slash": [7,1],
"crossbow shot": [0,4],
"cull the weak": [3,2],
"dark effigy": [9,3],
"decompose": [0,3],
"demon form": [0,4],
"despair": [9,3],
"detonate dead": [7,3],
"detonate minion": [0,2],
"detonating arrow": [9,2],
"devour": [5,1],
"disengage": [1,2],
"earthquake": [1,1],
"earthshatter": [7,1],
"electrocuting arrow": [5,2],
"elemental expression": [0,4],
"elemental storm": [0,4],
"elemental sundering": [11,2],
"elemental surge": [0,4],
"elemental weakness": [7,3],
"ember fusillade": [5,3],
"emergency reload": [11,1],
"encase in jade": [0,4],
"enfeeble": [3,3],
"entangle": [1,1],
"escape shot": [1,2],
"essence drain": [3,3],
"explosive concoction": [0,4],
"explosive grenade": [1,1],
"explosive shot": [7,1],
"explosive spear": [1,2],
"exsanguinate": [0,3],
"eye of winter": [13,3],
"falling thunder": [1,3],
"fangs of frost": [3,2],
"feast of flesh": [0,3],
"ferocious roar": [7,1],
"fireball": [3,3],
"firebolt": [0,3],
"firestorm": [11,3],
"flame breath": [13,1],
"flame wall": [1,3],
"flameblast": [13,3],
"flammability": [0,3],
"flash grenade": [3,1],
"flicker strike": [13,3],
"forge hammer": [9,1],
"fortifying cry": [9,1],
"fragmentation rounds": [1,1],
"freezing mark": [7,3],
"freezing salvo": [3,2],
"freezing shards": [0,3],
"frost bomb": [1,3],
"frost darts": [3,3],
"frost wall": [9,3],
"frostbolt": [5,3],
"frozen locus": [1,3],
"fulminating concoction": [0,4],
"furious slam": [1,1],
"fury of the mountain": [5,1],
"galvanic field": [0,3],
"galvanic shards": [5,1],
"gas arrow": [7,2],
"gas grenade": [5,1],
"gathering storm": [13,3],
"gemini surge": [0,1],
"glacial bolt": [7,1],
"glacial cascade": [1,3],
"glacial lance": [7,2],
"hailstorm rounds": [11,1],
"hammer of the gods": [13,1],
"hand of chayula": [9,3],
"hexblast": [11,3],
"high velocity rounds": [3,1],
"his foul emergence": [0,3],
"his scattering calamity": [0,3],
"his vile intrusion": [0,3],
"his winnowing flame": [0,3],
"hypothermia": [0,3],
"ice fragments": [0,4],
"ice nova": [1,3],
"ice shards": [5,1],
"ice shot": [9,2],
"ice strike": [5,3],
"ice-tipped arrows": [5,2],
"icestorm": [0,3],
"incendiary shot": [3,1],
"incinerate": [9,3],
"inevitable agony": [0,4],
"infernal cry": [3,1],
"killing palm": [1,3],
"leap slam": [7,1],
"lightning arrow": [1,2],
"lightning bolt": [0,3],
"lightning conduit": [13,3],
"lightning rod": [1,2],
"lightning spear": [3,2],
"lightning warp": [9,3],
"living bomb": [3,3],
"lunar assault": [1,1],
"lunar blessing": [13,1],
"mace strike": [0,4],
"magnetic salvo": [13,2],
"mana drain": [0,3],
"mana tempest": [7,3],
"mantra of destruction": [9,3],
"maul": [0,4],
"meditate": [0,4],
"molten blast": [5,1],
"molten crash": [0,1],
"moment of vulnerability": [0,4],
"mortar cannon": [11,1],
"oil barrage": [9,1],
"oil grenade": [9,1],
"orb of storms": [3,3],
"pain offering": [5,3],
"parry": [0,4],
"perfect strike": [5,1],
"permafrost bolts": [1,1],
"phantasmal arrow": [0,2],
"pinnacle of power": [0,4],
"plasma blast": [13,1],
"poisonburst arrow": [1,2],
"pounce": [3,1],
"power siphon": [0,3],
"primal strikes": [7,2],
"profane ritual": [7,3],
"quarterstaff strike": [0,4],
"rain of arrows": [9,2],
"raise shield": [0,4],
"raise zombie": [5,3],
"rake": [3,2],
"rampage": [11,1],
"rapid assault": [5,2],
"rapid shot": [5,1],
"reap": [0,3],
"rend": [0,4],
"resonating shield": [5,1],
"ritual sacrifice": [0,4],
"rolling magma": [3,1],
"rolling slam": [1,1],
"seismic cry": [11,1],
"shattering concoction": [0,4],
"shattering palm": [11,3],
"shattering spite": [0,2],
"shield charge": [3,1],
"shield wall": [7,1],
"shockburst rounds": [11,1],
"shockchain arrow": [11,2],
"shockwave totem": [3,1],
"shred": [0,4],
"siege ballista": [9,1],
"siege cascade": [13,1],
"sigil of power": [0,3],
"siphoning strike": [7,3],
"snap": [5,3],
"snipe": [3,2],
"sniper's mark": [7,2],
"solar orb": [0,3],
"soul offering": [13,3],
"soulrend": [0,3],
"spark": [1,3],
"spear of solaris": [13,2],
"spear stab": [0,4],
"spear throw": [0,4],
"spearfield": [5,2],
"spell totem": [7,1],
"spiral volley": [13,2],
"staggering palm": [3,3],
"stampede": [11,1],
"storm lance": [5,2],
"storm wave": [7,3],
"stormblast bolts": [9,1],
"stormcaller arrow": [3,2],
"sunder": [9,1],
"supercharged slam": [11,1],
"tame beast": [9,2],
"temper weapon": [0,4],
"tempest bell": [3,3],
"tempest flurry": [5,3],
"temporal chains": [7,3],
"thrashing vines": [9,1],
"thunderous leap": [9,2],
"thunderstorm": [5,1],
"time freeze": [0,4],
"time snap": [0,4],
"tornado": [11,1],
"tornado shot": [11,2],
"toxic domain": [13,2],
"toxic growth": [5,2],
"twister": [1,2],
"unbound avatar": [0,4],
"unearth": [1,3],
"valako's charge": [0,1],
"vaulting impact": [5,3],
"vine arrow": [3,2],
"volatile dead": [0,3],
"volcanic fissure": [7,1],
"volcano": [1,1],
"voltaic grenade": [7,1],
"voltaic mark": [7,2],
"vulnerability": [7,3],
"walking calamity": [13,1],
"wave of frost": [7,3],
"whirling assault": [11,3],
"whirling slash": [1,2],
"whirlwind lance": [11,2],
"wind blast": [3,3],
"wind serpent's fury": [13,2],
"wing blast": [3,1],
"wither": [0,3]
},
"spirit": {
"alchemist's boon": [14,2],
"align fate": [0,4],
"archmage": [14,3],
"arctic armour": [4,3],
"attrition": [4,1],
"barkskin": [8,1],
"barrier invocation": [8,3],
"berserk": [14,1],
"black powder blitz": [0,1],
"blasphemy": [8,3],
"blink": [8,3],
"blood boil": [0,4],
"briarpatch": [4,1],
"cackling companions": [0,1],
"called shots": [0,4],
"cast on block": [0,1],
"cast on charm use": [0,4],
"cast on critical": [14,3],
"cast on dodge": [14,3],
"cast on elemental ailment": [14,3],
"cast on melee kill": [0,1],
"cast on melee stun": [0,1],
"cast on minion death": [8,3],
"charge infusion": [14,3],
"combat frenzy": [8,2],
"companion": [0,2],
"convalescence": [4,3],
"crackling palm": [0,3],
"curse on block": [0,3],
"defiance banner": [8,1],
"discipline": [0,3],
"dread banner": [14,1],
"elemental conflux": [14,3],
"elemental invocation": [8,3],
"eternal rage": [14,1],
"feral invocation": [14,1],
"fire spell on hit": [0,4],
"fulmination": [0,3],
"future-past": [0,4],
"ghost dance": [4,3],
"grim feast": [4,3],
"heart of ice": [0,3],
"herald of ash": [4,1],
"herald of blood": [4,1],
"herald of ice": [4,3],
"herald of plague": [8,2],
"herald of thunder": [4,2],
"impurity": [0,3],
"into the breach": [0,4],
"iron ward": [4,1],
"kelari's malediction": [0,4],
"kelari, the tainted sands": [0,4],
"life remnants": [0,4],
"lingering illusion": [8,3],
"magma barrier": [4,1],
"malice": [0,3],
"mana remnants": [4,3],
"manifest weapon": [0,4],
"mirage archer": [8,2],
"mirror of refraction": [0,3],
"navira, the last mirage": [0,4],
"overwhelming presence": [8,1],
"plague bearer": [4,2],
"purity of fire": [0,3],
"purity of ice": [0,3],
"purity of lightning": [0,3],
"raging spirits": [4,3],
"ravenous swarm": [4,3],
"reaper's invocation": [14,3],
"rhoa mount": [14,2],
"ruzhan, the blazing sword": [0,4],
"sacrifice": [14,3],
"scavenged plating": [4,1],
"shard scavenger": [8,1],
"siphon elements": [8,3],
"skeletal arsonist": [3,3],
"skeletal brute": [13,3],
"skeletal cleric": [13,3],
"skeletal frost mage": [5,3],
"skeletal reaver": [9,3],
"skeletal sniper": [1,3],
"skeletal storm mage": [11,3],
"skeletal warrior": [0,3],
"sorcery ward": [0,4],
"spectre": [0,3],
"spellslinger": [0,3],
"summon infernal hound": [0,4],
"supporting fire": [0,4],
"temporal rift": [0,4],
"thundergod's wrath": [0,1],
"time of need": [8,1],
"trail of caltrops": [8,2],
"trinity": [14,3],
"void illusion": [0,4],
"war banner": [4,1],
"wind dancer": [4,2],
"withering presence": [4,3],
"wolf pack": [8,1]
},
"support": {
"abiding hex": [2,3],
"accelerated growth": [2,3],
"accelerated growth ii": [5,3],
"acrimony": [2,3],
"adhesive grenades i": [2,2],
"adhesive grenades ii": [3,2],
"adhesive grenades iii": [0,2],
"admixture": [2,2],
"advancing storm": [4,3],
"aftershock i": [2,1],
"aftershock ii": [4,1],
"aftershock iii": [0,1],
"ahn's citadel": [0,3],
"ailith's chimes": [0,2],
"alignment i": [3,2],
"alignment ii": [4,2],
"alignment iii": [5,2],
"amanamu's tithe": [0,1],
"ambrosia": [2,3],
"ambrosia ii": [3,3],
"ambush": [1,3],
"ammo conservation i": [2,2],
"ammo conservation ii": [4,2],
"ammo conservation iii": [5,2],
"ancestral aid": [0,1],
"ancestral call i": [4,1],
"ancestral call ii": [5,1],
"ancestral call iii": [0,1],
"arakaali's lust": [0,2],
"arbiter's ignition": [0,3],
"arcane surge": [1,3],
"arjun's medal": [0,2],
"armour break i": [2,1],
"armour break ii": [3,1],
"armour break iii": [4,1],
"armour demolisher i": [1,1],
"armour demolisher ii": [3,1],
"armour explosion": [1,1],
"arms length": [0,1],
"astral projection": [3,3],
"atalui's bloodletting": [0,1],
"atziri's allure": [0,3],
"atziri's impatience": [0,2],
"auto reload": [0,1],
"barbs i": [3,1],
"barbs ii": [5,1],
"barbs iii": [5,1],
"battershout": [1,1],
"behead i": [2,1],
"behead ii": [3,1],
"bhatair's vengeance": [0,3],
"bidding i": [2,3],
"bidding ii": [4,3],
"bidding iii": [5,3],
"biting frost i": [3,3],
"biting frost ii": [5,3],
"blazing critical": [4,3],
"bleed i": [1,1],
"bleed ii": [3,1],
"bleed iii": [5,1],
"bleed iv": [5,1],
"blind i": [2,2],
"blind ii": [5,2],
"blindside": [1,2],
"bloodlust": [0,1],
"bone shrapnel": [2,3],
"boundless energy i": [2,3],
"boundless energy ii": [4,3],
"bounty i": [2,2],
"bounty ii": [4,2],
"brambleslam": [5,1],
"branching fissures i": [3,1],
"branching fissures ii": [5,1],
"break endurance": [0,1],
"break posture": [0,2],
"brink i": [2,1],
"brink ii": [3,1],
"brittle armour": [3,3],
"brutality i": [1,1],
"brutality ii": [2,1],
"brutality iii": [5,1],
"brutus' brain": [0,1],
"burgeon i": [1,3],
"burgeon ii": [4,3],
"burning inscription": [4,3],
"bursting plague": [2,2],
"cadence": [2,2],
"caltrops": [3,2],
"cannibalism i": [3,1],
"cannibalism ii": [4,1],
"catalysing elements": [5,3],
"catharsis": [2,3],
"chain i": [1,2],
"chain ii": [2,2],
"chain iii": [5,2],
"chaos attunement": [3,3],
"chaos mastery": [5,3],
"chaotic freeze": [3,3],
"charge profusion i": [1,2],
"charge profusion ii": [5,2],
"charged mark": [5,2],
"charged shots i": [3,2],
"charged shots ii": [5,2],
"charm bounty": [0,2],
"cirel's cultivation": [0,1],
"clarity i": [1,3],
"clarity ii": [4,3],
"clash": [3,1],
"close combat i": [3,2],
"close combat ii": [5,2],
"cold attunement": [1,3],
"cold exposure": [4,3],
"cold mastery": [5,3],
"cold penetration": [2,3],
"combo finisher i": [1,2],
"combo finisher ii": [3,2],
"commandment": [0,3],
"commiserate": [2,2],
"compressed duration i": [1,1],
"compressed duration ii": [3,1],
"concentrated area": [1,3],
"concoct i": [2,1],
"concoct ii": [4,1],
"concussive spells": [4,3],
"considered casting": [2,3],
"controlled destruction": [1,3],
"controlled hazard": [4,2],
"cool headed": [3,1],
"cooldown recovery i": [3,2],
"cooldown recovery ii": [4,2],
"corpse conservation": [3,3],
"corrosion": [1,2],
"corrupting cry i": [3,1],
"corrupting cry ii": [5,1],
"coursing current": [1,3],
"crackling barrier": [2,3],
"crater": [2,1],
"crazed minions": [4,3],
"creeping chill": [1,3],
"crescendo i": [1,3],
"crescendo ii": [4,3],
"crescendo iii": [5,3],
"crystalline shards": [1,3],
"culling strike i": [4,2],
"culling strike ii": [5,2],
"culmination i": [3,2],
"culmination ii": [5,2],
"cursed ground": [3,3],
"danse macabre": [2,3],
"daresso's passion": [0,1],
"dauntless": [0,1],
"daze": [0,2],
"dazing cry": [0,1],
"dazzle": [0,2],
"deadly herald": [3,2],
"deadly poison i": [2,2],
"deadly poison ii": [4,2],
"deathmarch": [2,3],
"decaying hex": [3,3],
"deep cuts i": [3,1],
"deep cuts ii": [5,1],
"deep freeze": [1,3],
"defy i": [2,1],
"defy ii": [5,1],
"delayed gratification": [2,2],
"delayed reaction": [4,2],
"deliberation": [3,2],
"derange": [4,3],
"desperation": [0,1],
"devastate": [0,1],
"dialla's desire": [0,3],
"direstrike i": [2,1],
"direstrike ii": [4,1],
"doedre's undoing": [0,3],
"dominus' grasp": [0,2],
"double barrel i": [1,1],
"double barrel ii": [2,1],
"double barrel iii": [5,1],
"drain ailments": [3,3],
"durability": [2,2],
"echoing cry": [4,1],
"efficiency i": [1,1],
"efficiency ii": [4,1],
"einhar's beastrite": [0,1],
"electrocute": [2,2],
"electromagnetism": [3,3],
"elemental armament i": [1,1],
"elemental armament ii": [2,1],
"elemental armament iii": [5,1],
"elemental army": [1,3],
"elemental discharge": [3,3],
"elemental focus": [3,3],
"embitter": [2,3],
"encroaching ground": [2,3],
"enduring impact i": [1,1],
"enduring impact ii": [4,1],
"energy barrier": [0,3],
"energy capacitor": [3,3],
"energy retention": [3,3],
"enraged warcry i": [4,1],
"enraged warcry ii": [5,1],
"escalating poison": [1,2],
"esh's radiance": [0,3],
"essence harvest": [3,3],
"eternal flame i": [1,1],
"eternal flame ii": [3,1],
"eternal flame iii": [4,1],
"excise": [2,3],
"excoriate": [0,2],
"execrate": [3,3],
"execute i": [1,1],
"execute ii": [4,1],
"execute iii": [5,1],
"expanse": [1,3],
"exploit weakness": [2,1],
"exposing cry": [4,1],
"extraction": [3,3],
"fan the flames": [2,1],
"fan the flames ii": [4,1],
"feeding frenzy i": [3,3],
"feeding frenzy ii": [4,3],
"ferocity": [0,2],
"fiery death": [2,3],
"fire attunement": [1,1],
"fire exposure": [4,1],
"fire mastery": [5,3],
"fire penetration i": [2,1],
"fire penetration ii": [5,1],
"first blood": [0,1],
"fist of war i": [1,1],
"fist of war ii": [3,1],
"fist of war iii": [5,1],
"flame pillar": [0,1],
"flamepierce": [0,1],
"flow": [0,2],
"fluke": [3,3],
"focused curse": [2,3],
"font of blood": [2,1],
"font of mana": [2,3],
"fork": [3,2],
"fortress i": [1,3],
"fortress ii": [3,3],
"freeze": [2,3],
"freezefork": [0,3],
"frenzied riposte": [2,2],
"fresh clip i": [2,1],
"fresh clip ii": [4,1],
"frost nexus": [2,3],
"frostfire": [3,3],
"frozen spite": [2,2],
"gambleshot": [3,3],
"garukhan's resolve": [0,2],
"glacier": [2,3],
"haemocrystals": [3,1],
"hardy totems i": [2,1],
"hardy totems ii": [4,1],
"harmonic remnants i": [2,3],
"harmonic remnants ii": [3,3],
"hayoxi's fulmination": [0,3],
"heavy swing": [4,1],
"heft": [5,1],
"heightened accuracy i": [1,2],
"heightened accuracy ii": [3,2],
"heightened charges": [2,2],
"heightened curse": [1,3],
"herbalism i": [2,1],
"herbalism ii": [4,1],
"hex bloom": [3,3],
"hinder": [0,3],
"hit and run": [4,2],
"hobble": [0,2],
"holy descent": [5,1],
"hourglass": [1,3],
"hulking minions": [4,3],
"ice bite i": [1,3],
"ice bite ii": [3,3],
"icicle": [2,3],
"ignite i": [1,1],
"ignite ii": [3,1],
"ignite iii": [5,1],
"immolate": [3,1],
"impact shockwave": [1,1],
"impale": [4,2],
"impending doom": [1,3],
"incision": [4,1],
"inexorable critical i": [3,3],
"inexorable critical ii": [4,3],
"infernal legion i": [2,1],
"infernal legion ii": [4,1],
"infernal legion iii": [5,1],
"inhibitor": [2,2],
"innervate": [1,2],
"intense agony": [2,3],
"ixchel's torment": [0,3],
"jagged ground i": [2,1],
"jagged ground ii": [5,1],
"kalisa's crescendo": [0,3],
"kaom's madness": [0,1],
"khatal's rejuvenation": [0,3],
"knockback": [3,1],
"kulemak's dominion": [0,3],
"kurgal's leash": [0,3],
"last gasp": [1,3],
"lasting ground": [0,1],
"lasting shock": [2,2],
"leverage": [0,2],
"life bounty": [0,2],
"life drain": [1,2],
"life leech i": [2,1],
"life leech ii": [3,1],
"life leech iii": [5,1],
"lifetap": [2,1],
"lightning attunement": [1,2],
"lightning exposure": [4,2],
"lightning mastery": [5,3],
"lightning penetration": [2,2],
"living lightning": [2,3],
"living lightning ii": [4,3],
"lockdown": [0,2],
"long fuse i": [4,1],
"long fuse ii": [5,1],
"longshot i": [2,2],
"longshot ii": [3,2],
"loyalty": [2,3],
"magnetic remnants": [0,3],
"magnified area i": [1,3],
"magnified area ii": [3,3],
"maim": [2,2],
"malady": [0,2],
"mana bounty": [0,2],
"mana flare": [2,3],
"mana leech": [2,3],
"mark for death": [2,1],
"mark for death ii": [4,1],
"mark of siphoning": [1,3],
"mark of siphoning ii": [4,3],
"meat shield i": [1,1],
"meat shield ii": [3,1],
"minion instability": [2,3],
"minion mastery": [5,3],
"minion pact i": [1,3],
"minion pact ii": [4,3],
"mobility": [3,2],
"momentum": [2,2],
"morgana's tempest": [0,3],
"multishot i": [1,2],
"multishot ii": [2,2],
"murderous intent": [0,2],
"muster": [3,3],
"mysticism i": [2,3],
"mysticism ii": [4,3],
"nadir": [0,3],
"neural overload": [3,2],
"nimble reload": [4,2],
"nova projectiles i": [2,2],
"nova projectiles ii": [4,2],
"ois\u00edn's oath": [0,3],
"opening move": [1,1],
"outmaneuver": [0,2],
"overabundance i": [1,2],
"overabundance ii": [4,2],
"overabundance iii": [0,2],
"overcharge": [4,2],
"overextend": [2,2],
"overreach": [2,2],
"paquate's pact": [0,1],
"payload": [4,2],
"perfected endurance": [4,2],
"perfection": [4,2],
"perpetual charge": [1,3],
"persistent ground i": [2,1],
"persistent ground ii": [3,1],
"persistent ground iii": [5,1],
"physical mastery": [5,3],
"pierce i": [1,2],
"pierce ii": [3,2],
"pierce iii": [5,2],
"pin i": [1,2],
"pin ii": [2,2],
"pin iii": [5,2],
"pinpoint critical": [2,3],
"poison i": [1,2],
"poison ii": [3,2],
"poison iii": [5,2],
"poison spores": [3,2],
"potent exposure": [2,3],
"potential": [0,3],
"practical magic i": [3,3],
"practical magic ii": [5,3],
"practiced combo": [3,2],
"precision i": [1,2],
"precision ii": [4,2],
"premeditation": [0,1],
"profanity i": [1,3],
"profanity ii": [5,3],
"projectile acceleration i": [1,2],
"projectile acceleration ii": [4,2],
"projectile acceleration iii": [5,2],
"projectile deceleration i": [1,2],
"projectile deceleration ii": [3,2],
"prolonged duration i": [1,1],
"prolonged duration ii": [3,1],
"prolonged duration iii": [0,1],
"punch through": [3,2],
"pursuit i": [1,2],
"pursuit ii": [3,2],
"pursuit iii": [5,2],
"quill burst": [3,1],
"rage i": [1,1],
"rage ii": [4,1],
"rage iii": [5,1],
"rageforged i": [4,1],
"rageforged ii": [5,1],
"raging cry": [2,1],
"rakiata's flow": [0,2],
"rally": [0,1],
"rapid attacks i": [1,2],
"rapid attacks ii": [4,2],
"rapid attacks iii": [5,2],
"rapid casting i": [1,3],
"rapid casting ii": [4,3],
"rapid casting iii": [5,3],
"ratha's assault": [0,2],
"rearm i": [3,2],
"rearm ii": [5,2],
"refraction i": [2,1],
"refraction ii": [3,1],
"refraction iii": [5,1],
"reinforced totems i": [3,1],
"reinforced totems ii": [5,1],
"relentless rage": [0,1],
"remnant potency i": [2,1],
"remnant potency ii": [3,1],
"remnant potency iii": [4,1],
"rending apex": [3,1],
"retaliate i": [2,1],
"retaliate ii": [4,1],
"retreat i": [1,2],
"retreat ii": [3,2],
"retreat iii": [5,2],
"reverberate": [0,1],
"ricochet i": [2,2],
"ricochet ii": [4,2],
"rigwald's ferocity": [0,2],
"rime": [0,3],
"rip": [3,1],
"rising tempest": [2,3],
"ritualistic curse": [3,3],
"rupture": [3,1],
"rusted spikes": [0,1],
"ruthless": [0,1],
"sacrificial lamb i": [1,3],
"sacrificial lamb ii": [3,3],
"sacrificial offering": [3,3],
"salvo": [3,2],
"searing flame i": [3,1],
"searing flame ii": [4,1],
"second wind i": [2,2],
"second wind ii": [4,2],
"second wind iii": [5,2],
"see red": [0,1],
"selfless remnants": [2,1],
"shock": [1,2],
"shock conduction i": [2,3],
"shock conduction ii": [5,3],
"shock siphon": [0,3],
"shocking leap": [2,2],
"short fuse i": [2,1],
"short fuse ii": [4,1],
"sione's temper": [0,3],
"skittering stone i": [2,1],
"skittering stone ii": [4,1],
"slow potency": [1,2],
"soul drain": [3,2],
"spar": [0,1],
"spectral volley": [4,2],
"spell cascade": [1,3],
"spell echo": [2,3],
"splinter totem i": [3,1],
"splinter totem ii": [5,1],
"static shocks": [3,3],
"steadfast i": [1,1],
"steadfast ii": [3,1],
"stoicism i": [3,1],
"stoicism ii": [5,1],
"stomping ground": [1,1],
"stormchain": [0,2],
"stormfire": [3,3],
"streamlined rounds": [3,2],
"strong hearted": [3,3],
"stun i": [2,1],
"stun ii": [4,1],
"stun iii": [5,1],
"supercritical": [3,3],
"swift affliction i": [1,2],
"swift affliction ii": [3,2],
"swift affliction iii": [5,2],
"syzygy": [4,1],
"tacati's ire": [0,2],
"tasalio's rhythm": [0,2],
"tawhoa's tending": [0,1],
"tear": [3,1],
"tecrod's revenge": [0,3],
"tectonic slams": [2,1],
"thornskin i": [2,1],
"thornskin ii": [4,1],
"thrill of the kill": [1,3],
"thrill of the kill ii": [3,3],
"tireless": [1,1],
"tremors": [0,1],
"tul's stillness": [0,2],
"tumult": [0,2],
"uhtred's augury": [0,1],
"uhtred's exodus": [0,1],
"uhtred's omen": [0,1],
"unabating": [0,1],
"unbending": [0,3],
"unbreakable": [0,1],
"undermine": [0,1],
"unerring power": [0,2],
"unleash": [1,3],
"unsteady tempo": [0,1],
"untouchable": [0,2],
"unyielding": [0,1],
"upheaval i": [1,1],
"upheaval ii": [5,1],
"upwelling i": [2,3],
"upwelling ii": [4,3],
"urgent totems i": [2,1],
"urgent totems ii": [3,1],
"urgent totems iii": [4,1],
"uruk's smelting": [0,1],
"uul-netol's embrace": [0,1],
"vanguard i": [1,1],
"vanguard ii": [2,1],
"varashta's blessing": [0,3],
"verglas": [3,3],
"vilenta's propulsion": [0,3],
"vitality i": [1,1],
"vitality ii": [4,1],
"volatile power": [0,3],
"volatility": [3,3],
"volcanic eruption": [2,1],
"volt": [2,2],
"warm blooded": [3,2],
"wildfire": [2,3],
"wildshards i": [3,3],
"wildshards ii": [5,3],
"wind wave": [0,2],
"window of opportunity i": [1,2],
"window of opportunity ii": [5,2],
"withering touch": [1,3],
"xibaqua's rending": [0,3],
"xoph's pyre": [0,1],
"zarokh's refrain": [0,3],
"zarokh's revolt": [0,3],
"zenith i": [1,3],
"zenith ii": [3,3],
"zerphi's infamy": [0,1]
}
}

794
src-tauri/data/guide2.json Normal file
View File

@ -0,0 +1,794 @@
[
[
[
"kill the_bloated_miller",
"enter areaidg1_town ;; clearfell encampment"
],
[
"(color:red)info: optional rewards (img:exa) (img:skill) (img:rune) (img:jeweller)",
"(hint)__ to include them, enable (quest:optionals) toggle",
"(color:lime)ready_for_0.5:_includes_pre-release_info",
"(hint)__ (color:yellow)old/unconfirmed_clues_are_marked:_??",
"how to: use world-map for guidance",
"(img:quest_2) renly: (img:skill) || enter areaidg1_2 ;; clearfell"
],
[
"optional: (img:skill) in (img:checkpoint) (color:cc99ff)camp || transmute: (color:cc99ff)league",
"optional: (img:skill2) arena:boss in areaidg1_3, (img:support2) (img:quest_2) renly ;; mud burrow",
"kill (img:checkpoint) beira || enter (img:checkpoint) areaidg1_4 ;; the grelwood"
],
[
"optional: transmute: (color:cc99ff)league",
"optional: (img:flasks) + (img:support) <witch>: (img:checkpoint) (color:cc99ff)hut || (img:skill) arena:bramble",
"(color:ff00ff)follow_2: glow-roots to get (img:waypoint) ,",
"(hint)__ (color:aqua)mushrooms to (img:in-out2) areaidg1_6: (img:waypoint) ;; the grim tangle",
"follow river upstream: (img:checkpoint) areaidg1_5 ;; the red vale"
],
[
"optional: (img:skill) (color:cc99ff)league || atk weapons: (color:cc99ff)racks",
"clear (quest:3_obelisks) for (quest:3_runes)",
"(img:portal) to areaidg1_town ;; clearfell encampment"
],
[
"leaguestart: optional: check vendors",
"(img:quest_2) renly: (quest:runed_spikes)",
"(img:waypoint) to areaidg1_4 ;; the grelwood"
],
[
"break the (color:cc99ff)3_runic_seals",
"(img:portal) to areaidg1_town ;; clearfell encampment"
],
[
"(img:quest_2) una",
"(img:waypoint) to areaidg1_6 ;; the grim tangle"
],
[
"optional: (img:skill2) (color:cc99ff)league || (img:support) arena:rotten_druid",
"find ?aldur vault?",
"(hint)_ - (img:quest_2) (quest:league): unlocks (color:cc99ff)ward forging",
"(hint)_ - to be safe, check the quest-log",
"enter (img:checkpoint) areaidg1_7 ;; cemetery of the eternals"
],
[
"activate the (img:waypoint) and (img:checkpoint)",
"optional: (img:regal) (color:cc99ff)league || (img:ring) in (img:checkpoint) (color:cc99ff)ruin (edge?)",
"enter (img:checkpoint) areaidg1_9 ;; tomb of the consort"
],
[
"optional: amulet: (color:cc99ff)league",
"optional: (img:support) <knight> in (img:checkpoint) (color:cc99ff)treasure",
"kill (img:checkpoint) asinia for (quest:key_piece)",
"enter areaidg1_7 ;; cemetery of the eternals"
],
[
"optional: (img:regal) (color:cc99ff)league || (img:ring) in (img:checkpoint) (color:cc99ff)ruin (edge?)",
"enter (img:checkpoint) areaidg1_8 ;; mausoleum of the praetor"
],
[
"optional: random (img:rune): (color:cc99ff)league",
"kill (img:checkpoint) draven for (quest:key_piece)",
"enter areaidg1_7 ;; cemetery of the eternals"
],
{
"condition": [
"league-start",
"yes"
],
"lines": [
"(img:checkpoint) -travel to (img:waypoint), open (color:cc99ff)gate",
"(hint)__ when it opens, (color:cc99ff)esc:_respawn",
"kill lachlann for (quest:ring)",
"(img:portal) areaidg1_town ;; clearfell encampment"
]
},
{
"condition": [
"league-start",
"yes"
],
"lines": [
"(img:quest_2) una: (quest:hooded_one)",
"leaguestart: optional: check vendors",
"(img:waypoint) to areaidg1_11 ;; hunting grounds"
]
},
{
"condition": [
"league-start",
"no"
],
"lines": [
"(img:checkpoint) -travel to (img:waypoint), open (color:cc99ff)gate",
"(hint)__ when it opens, (color:cc99ff)esc:_respawn",
"kill lachlann || leave (quest:ring)",
"enter areaidg1_11 ;; hunting grounds"
]
},
[
"optional: (img:exa) (color:cc99ff)league || (img:support) <dryad> (edge?)",
"clear (img:checkpoint) (color:cc99ff)ritual (near center?)",
"(hint)__ on the way, look for (color:aqua)tracks or (color:cc99ff)road",
"follow cleared ritual: areaidg1_12 ;; freythorn",
"(hint)__ keep looking for (color:aqua)tracks or (color:cc99ff)road"
],
[
"(img:waypoint) to areaidg1_11 ;; hunting grounds"
],
[
"(img:checkpoint) to (color:cc99ff)ritual, (color:ff00ff)find/follow_2:",
"(hint)_ - (color:aqua)tracks: kill (img:checkpoint) crowbell (quest:(book))",
"(hint)_ - road+sign: get (img:checkpoint) at the end",
"enter (img:checkpoint) areaidg1_13_1 ;; ogham farmlands"
],
[
"optional: (img:skill2) (color:cc99ff)league || (img:skill2) in (img:checkpoint) (color:cc99ff)crop_circle",
"?road leads straight? to (img:checkpoint) (color:cc99ff)hut (quest:(lute))",
"find & enter (img:checkpoint) areaidg1_13_2 ;; ogham village",
"(hint)__ ?one of the roads after (color:cc99ff)hut leads there?"
],
[
"(img:waypoint) to areaidg1_town (img:town) ;; clearfell encampment",
"leaguestart: optional: check vendors",
"(img:quest_2) una: (quest:book) || (img:waypoint) to areaidg1_12 ;; freythorn"
],
[
"optional: (img:support) (color:cc99ff)league",
"find & clear (color:cc99ff)3 (img:checkpoint) (color:cc99ff)rituals",
"(hint)__ cleared rituals point to the next",
"clear (img:checkpoint) arena:boss_ritual (quest:(skull))",
"(img:portal) to areaidg1_town ;; clearfell encampment"
],
[
"leaguestart: (img:quest_2) finn: (color:cc99ff)ele-res_charm",
"(img:waypoint) to areaidg1_13_2 ;; ogham village"
],
[
"leaguestart: optional: (img:artificer) (color:cc99ff)league",
"leaguestart: follow road: (img:checkpoint) (color:cc99ff)workshop",
"(hint)__ get (quest:tools) + <chest>: (img:artificer) + (img:b-rune)",
"twinkrun: optional: (img:artificer) (color:cc99ff)league || (img:b-rune) + (img:artificer) in (img:checkpoint) (color:cc99ff)shop",
"(hint)__ follow road to (img:checkpoint) (color:cc99ff)workshop",
"?follow road?: (img:checkpoint) arena:executioner",
"leaguestart: (img:quest_2) leitis, (img:portal) areaidg1_town ;; clearfell encampment",
"twinkrun: (img:quest_2) leitis || enter areaidg1_14 ;; manor ramparts"
],
{
"condition": [
"league-start",
"yes"
],
"lines": [
"(img:quest_2) leitis: (img:skill) || (img:quest_2) renly: (img:rune) + (quest:bench)",
"optional: check vendors",
"(img:waypoint) to areaidg1_14 ;; manor ramparts"
]
},
[
"optional: (img:skill2) (color:cc99ff)league || (img:support) in (img:checkpoint) (color:cc99ff)gallows (edge?)",
"inner edge leads to areaidg1_15 ;; ogham manor"
],
[
"optional: alchemy orb: (color:cc99ff)league",
"floor 1: kill (img:checkpoint) candlemass",
"floor 3: kill (img:checkpoint) geonor",
"leaguestart: enter areaidg1_town ;; clearfell encampment",
"twinkrun: (img:portal) areaidg1_town ;; clearfell encampment"
],
[
"leaguestart: optional: check vendors",
"twinkrun: (img:quest_2) leitis: (img:skill)",
"twinkrun: optional: (img:quest_2) renly: (img:rune)",
"(img:quest_2) hooded one: areaidg2_1 ;; vastiri outskirts"
]
],
[
[
"optional: ? (img:exa) / (img:spirit2) ? (color:cc99ff)league || (img:support2) in (img:checkpoint) (color:cc99ff)raider_camp",
"find & kill (img:checkpoint) rathbreaker || (img:portal) out",
"(hint)__ ?search for long passage along edge?",
"(img:quest_2) zarka: (img:skill) || enter areaidg2_town ;; the ardura caravan"
],
[
"(img:quest_2) hooded one",
"leaguestart: optional: check vendors",
"(img:quest_2) asala",
"(color:cc99ff)desert_map: areaidg2_10_1 ;; mawdun quarry"
],
[
"optional: ? (img:spirit2) / (img:exa) ? (color:cc99ff)league || (img:artificer) in (img:checkpoint) (color:cc99ff)cache (edge?)",
"reach (img:checkpoint) areaidg2_10_2 ;; mawdun mine"
],
[
"optional: (img:support) (color:cc99ff)league",
"find & kill (img:checkpoint) rudja",
"(hint)__ ?usually (img:0) / (img:1) in the zone?",
"(img:quest_2) risu || (img:portal) areaidg2_town ;; the ardura caravan"
],
[
"leaguestart: optional: check vendors",
"(img:quest_2) risu || (img:quest_2) asala",
"(color:cc99ff)desert_map: areaidg2_2 ;; traitor's passage"
],
[
"optional: (img:artificer) (color:cc99ff)league || (img:skill2) in (img:checkpoint) (color:cc99ff)bell_chest",
"reach (img:checkpoint) intersection",
"(quest:ascend) early?: follow (color:aqua)parchment",
"(hint)__ kill (img:checkpoint) balbala for (quest:trial_key)",
"follow blank walls: areaidg2_3 ;; the halani gates"
],
[
"optional: (img:exa) (color:cc99ff)league || <atk_weapon> in (img:checkpoint) (color:cc99ff)tent",
"kill jamanra || use (img:arena) stairs",
"find (img:checkpoint) || (img:portal) areaidg2_town ;; the ardura caravan"
],
[
"leaguestart: optional: check vendors",
"(img:quest_2) zarka: (img:skill) || (img:quest_2) asala",
"if you want to (quest:ascend) early:",
"(hint)__ (color:cc99ff)desert_map: areaidg2_13 ;; trial of the sekhemas",
"(color:cc99ff)desert_map: areaidg2_4_1 ;; keth"
],
[
"optional: (img:gcp) (color:cc99ff)league || (color:66b2ff)amulet: ?check 2 (img:arena) stairs?",
"(color:ff00ff)kill_2: (color:cc99ff)snake_mobs (color:lime)(relic) || (img:checkpoint) arena:kabala",
"(hint)__ arena:kabala: near areas with more (color:cc99ff)snakes",
"find & enter (img:checkpoint) areaidg2_4_2 ;; the lost city"
],
[
"optional: alch orb: (color:cc99ff)league",
"optional: (img:spirit2) in (img:checkpoint) (color:cc99ff)tomb || (color:66b2ff)jewel from <scarab>",
"enter (img:checkpoint) areaidg2_4_3 ;; buried shrines",
"(hint)_ - ?1st corridor shows gen. direction?",
"(hint)_ - ?find (img:checkpoint) (color:aqua)v-shaped_bridge along edge?"
],
[
"optional: (img:jeweller) (color:cc99ff)league || (color:66b2ff)res (img:ring) + (img:rune) in (img:checkpoint) (color:cc99ff)offering",
"find (img:arena) heart_of_keth, kill azarian",
"(img:quest_2) halani: (quest:cinders) || (img:quest_2) halani: (quest:essence)",
"(img:portal) to areaidg2_town ;; the ardura caravan"
],
[
"(img:quest_2) zarka: (img:support)",
"leaguestart: optional: check vendors",
"(color:cc99ff)desert_map: areaidg2_5_1 ;; mastodon badlands"
],
[
"optional: (img:regal) (color:cc99ff)league || (img:support) in (img:checkpoint) (color:cc99ff)fossil || j-bone: (color:cc99ff)abyss",
"(img:in-out2) lightless_passage for (img:waypoint)",
"(hint)__ follow cracks in ground || clear this &",
"(hint)__ the follow-up if you want to (quest:abyss_craft)",
"enter (img:checkpoint) areaidg2_5_2 ;; the bone pits"
],
[
"optional: (img:exa) (color:cc99ff)league",
"(color:ff00ff)kill_2: (color:cc99ff)hyenas (color:lime)(relic) || arena:ekbab (color:lime)(tusks)",
"(hint)__ arena:ekbab: ?near areas with more (color:cc99ff)hyenas?",
"(img:portal) to areaidg2_town ;; the ardura caravan"
],
[
"(img:quest_2) zarka: (img:support)",
"leaguestart: optional: check vendors",
"(color:cc99ff)desert_map: areaidg2_6 ;; valley of the titans"
],
[
"optional: (color:ff8111)unique_item: (color:cc99ff)league || rib: (color:cc99ff)abyss",
"traverse (color:cc99ff)3_canyons for (quest:3) (img:checkpoint) (quest:seals)",
"?find (img:waypoint): insert (quest:relics) nearby?",
"enter areaidg2_7 ;; the titan grotto"
],
[
"optional: chance shard: (color:cc99ff)league",
"optional: random (img:rune) in (img:checkpoint) (color:cc99ff)titan_sword",
"kill zalmarath for (quest:ruby)",
"(img:portal) to areaidg2_town ;; the ardura caravan"
],
[
"(img:quest_2) zarka: (img:support) + (quest:horn) || (img:quest_2) asala",
"(color:cc99ff)desert_map: areaidg2_2 ;; traitor's passage",
"(img:quest_2) (color:cc99ff)sound_the_horn",
"(img:quest_2) asala || (color:cc99ff)desert_map: areaidg2_8 ;; deshar"
],
[
"optional: (img:artificer) ?in (img:checkpoint) (color:cc99ff)path nearby? || (img:rune) (color:cc99ff)league",
"late (quest:ascension?): find (img:checkpoint) (color:cc99ff)corpses",
"(hint)__ kill (color:yellow)2_vultures for (color:cc99ff)djinn_barya",
"find (quest:letter) on (img:quest_2) (quest:fallen_dekhara)",
"enter (img:checkpoint) areaidg2_9_1 ;; path of mourning"
],
[
"leaguestart: optional: dmg issues?: (img:waypoint) to town",
"(hint)__ give (quest:letter) to (color:cc99ff)shambrin: (quest:book)",
"optional: (img:support) + <items> in (img:checkpoint) (color:cc99ff)hushed_urn",
"reach (img:checkpoint) areaidg2_9_2 ;; the spires of deshar"
],
[
"optional: (img:gcp) (color:cc99ff)league",
"(color:ff00ff)2_tasks: (color:cc99ff)sisters_statue || arena:tor_gul",
"(hint)_ - (img:checkpoint) (color:cc99ff)statue: ?g-pattern on the map?",
"(hint)_ - (img:checkpoint) arena:tor_gul: ?far end of the zone?",
"(img:portal) to areaidg2_town ;; the ardura caravan"
],
[
"leaguestart: optional: check vendors",
"give (quest:letter) to (color:cc99ff)shambrin: (quest:book)",
"late (quest:ascension?): (img:quest_2) zarka || (img:quest_2) asala",
"(hint)__ (color:cc99ff)desert_map: areaidg2_13 ;; trial of the sekhemas",
"(color:cc99ff)desert_map: areaidg2_12_1 ;; the dreadnought"
],
[
"kill jamanra || (img:quest_2) asala",
"(img:portal) to areaidg2_town ;; the ardura caravan"
],
[
"go (img:7): (img:quest_2) hooded one",
"(hint)_ - wait for transformation",
"(hint)_ - (color:cc99ff)esc:_character_selection",
"leaguestart: optional: check vendors",
"(img:quest_2) asala: areaidg3_1 ;; sandswept marsh"
]
],
[
[
"optional: (img:skill2) arena:rootdredge || (img:ring) in (img:checkpoint) (color:cc99ff)hang._tree",
"optional: (img:support2) (color:cc99ff)league || (img:jeweller) in (img:checkpoint) (color:cc99ff)camp ?near exit?",
"reach (img:checkpoint) areaidg3_town ;; ziggurat encampment"
],
[
"leaguestart: (img:quest_2) oswald",
"leaguestart: optional: check vendors",
"enter areaidg3_3 ;; jungle ruins"
],
[
"optional: alchemy orb: (color:cc99ff)league",
"optional: <belt>: (img:checkpoint) (color:cc99ff)grave || <glove>: (img:checkpoint) (color:cc99ff)camp chest",
"(color:ff00ff)2_tasks: get (img:waypoint) || (img:checkpoint) arena:monkey (center)",
"(hint)__ on the way, look around for (color:cc99ff)snakes",
"follow (color:cc99ff)snakes to (img:checkpoint) areaidg3_4 ;; venom crypts"
],
[
"optional: (img:ring) (color:cc99ff)league || (img:support) in (img:checkpoint) (color:cc99ff)crypt",
"optional: collarbone: (color:cc99ff)abyss",
"find (img:checkpoint) (quest:corpse) for (quest:venom)",
"(hint)__ near areas with more (color:cc99ff)humans",
"use (img:arena) exit to areaidg3_3 ;; jungle ruins"
],
[
"optional: alchemy orb: (color:cc99ff)league",
"optional: <belt>: (img:checkpoint) (color:cc99ff)grave || <glove>: (img:checkpoint) (color:cc99ff)camp chest",
"enter (img:checkpoint) areaidg3_2_1 nearby ;; infested barrens"
],
[
"optional: (img:exa) (color:cc99ff)league || <boots>: (img:checkpoint) (color:cc99ff)camp chest",
"(hint)__ npc in (color:cc99ff)camp can reveal (img:arena) exit",
"find & enter (img:checkpoint) (color:fec076)mystic_refuge",
"(hint)__ (img:quest_2) (quest:league): unlocks (color:ff8111)unique forging",
"enter (img:checkpoint) areaidg3_5 ;; chimeral wetlands"
],
[
"(img:waypoint) to areaidg3_town (img:town) ;; ziggurat encampment"
],
[
"(img:quest_2) servi: (color:lime)perma_buff + (img:artificer)",
"(hint)__ (color:ff0000)cannot_be_changed_later",
"leaguestart: optional: check vendors",
"(img:waypoint) to areaidg3_5 ;; chimeral wetlands"
],
[
"optional: (img:skill) (color:cc99ff)league || <helm>: (img:checkpoint) (color:cc99ff)camp chest",
"optional: (color:66b2ff)amulet: (img:checkpoint) (color:cc99ff)toxic_bloom",
"(color:ff00ff)find_2: (img:checkpoint) arena:chimera near (img:arena) exit,",
"(hint)__ (img:in-out2) areaidg3_10_airlock for (img:waypoint) ;; temple of chaos",
"enter areaidg3_6_1 ;; jiquani's machinarium"
],
[
"optional: (img:artificer) (color:cc99ff)league",
"find (quest:core) for (color:cc99ff)altar || (quest:2_cores) for:",
"(hint)_ - (img:checkpoint) arena:blackjaw: ?room on side edge?",
"(hint)_ - (img:checkpoint) (color:cc99ff)locked_exit: follow red line",
"enter areaidg3_6_2 ;; jiquani's sanctum"
],
[
"optional: (img:exa) (color:cc99ff)league || (img:checkpoint) (color:cc99ff)corruption_altar",
"find (quest:2_cores), start (quest:2_gens.) (v-shape?)",
"(hint)__ then (color:cc99ff)esc:_respawn, (img:checkpoint) -travel to start",
"activate (quest:core) || kill zicoatl for (quest:core)",
"(img:waypoint) to areaidg3_3 ;; jungle ruins"
],
[
"activate (img:quest_2) (color:cc99ff)stone_altar",
"enter areaidg3_2_2 ;; the matlan waterways"
],
[
"optional: (img:spirit2) (color:cc99ff)league || <caster_weap>: (img:checkpoint) (color:cc99ff)hut",
"reach (color:cc99ff)reservoir_mechanism",
"enter (img:checkpoint) areaidg3_7 ;; azak bog"
],
[
"optional: (img:rune) (color:cc99ff)league || (img:quest_2) (color:cc99ff)flame_ritual",
"(hint)__ temp (color:red)25%_fire_res, ?near center?",
"kill (img:checkpoint) ignagduk for (quest:2_items)",
"(img:portal) to areaidg3_town ;; ziggurat encampment"
],
[
"(img:quest_2) servi: (color:66b2ff)ailment_charm",
"leaguestart: optional: check vendors",
"(quest:ascension): (img:waypoint) to areaidg3_10_airlock ;; temple of chaos",
"(hint)__ (color:cc99ff)build-dependent: can be done later",
"go (img:3): (img:quest_2) alva, enter areaidg3_8 ;; the drowned city"
],
[
"optional: (img:support) (color:cc99ff)league",
"leaguestart: (img:in-out2) areaidg3_9 for (img:waypoint) ;; the molten vault",
"reach (img:checkpoint) areaidg3_11 ;; apex of filth"
],
[
"optional: vaal orb: (color:cc99ff)league",
"optional: qual flasks in (img:checkpoint) (color:cc99ff)cauldron",
"(hint)__ find/loot (quest:3_mushrooms)",
"kill queen_of_filth for (quest:idol)",
"(img:portal) to areaidg3_town ;; ziggurat encampment"
],
[
"leaguestart: (img:waypoint) to areaidg3_9, kill mektul ;; the molten vault",
"(hint)_ - (color:ff8111)item: (color:cc99ff)league || (img:quest_2) oswald: (quest:reforge), (img:skill), (img:artificer)",
"(hint)_ - skippable for later if preferred",
"go (img:3), open (color:cc99ff)door: areaidg3_12 ;; temple of kopec"
],
[
"optional: (img:spirit2) (color:cc99ff)league",
"find corner (img:arena) stairs, kill ketzuli",
"(img:quest_2) alva: areaidg3_town ;; ziggurat encampment",
"(hint)__ opt. skip: (img:portal) when she says \"wait\""
],
[
"enter the (color:cc99ff)gateway",
"follow stairs to areaidg3_14 ;; utzaal"
],
[
"optional: (time-lost) jewel: (color:cc99ff)league",
"(color:ff00ff)kill_2: (color:cc99ff)goliaths (color:lime)(heart) || (img:checkpoint) arena:napuatzi",
"(hint)_ - (quest:heart) also drops next zone: 2nd half",
"(hint)_ - war-cry sound leads to arena:napuatzi",
"enter (img:checkpoint) areaidg3_16 ;; aggorat"
],
[
"optional: (img:skill2) (color:cc99ff)league",
"find plaza and (img:checkpoint) || farm (quest:heart)?",
"?go (img:0) ?, use (quest:heart) in (img:checkpoint) (color:cc99ff)sacrifice",
"find & enter (img:checkpoint) areaidg3_17 ;; the black chambers",
"(hint)__ ?straight (img:6) or (img:2) of (color:cc99ff)sacrifice?"
],
[
"optional: vaal orb: (color:cc99ff)league",
"find & kill (img:checkpoint) doryani",
"(hint)__ ?zone: mazes connected by bridges?",
"(img:portal) to areaidg3_town ;; ziggurat encampment"
],
[
"(img:quest_2) doryani: (quest:cut-scene)",
"(img:quest_2) doryani",
"(img:quest_2) alva: (color:cc99ff)travel_to areaidg4_town ;; kingsmarch"
]
],
[
[
"(color:red)info: seasonal (quest:quest) rotations",
"(hint)__ every arena:boss-fight has \"free (img:quest_2) matiki\"",
"(img:quest_2) doryani || (img:quest_2) alva: (quest:charter)",
"leaguestart: optional: check vendors || extra vendors in:",
"(hint)__ areaidg4_8a: <jewelry/caster>, areaidg4_11_1a: <atk> ;; arastas ;; ngakanu",
"(img:quest_2) makoru (ship): areaidg4_1_1 ;; isle of kin"
],
[
"optional: (img:gcp) (color:cc99ff)league || (img:skill) (img:support2) in (img:checkpoint) (color:cc99ff)beast_pen",
"optional: (img:jeweller) in (img:checkpoint) (color:cc99ff)delve || (img:quest_2) (quest:map): (img:checkpoint) (color:cc99ff)sailor",
"optional: greater (img:b-rune) from (img:checkpoint) arena:blind_beast",
"reach (img:checkpoint) areaidg4_1_2 ;; volcanic warrens"
],
[
"optional: (img:support) (color:cc99ff)league || (color:ff0000)fire / (color:ffff00)light (img:ring) in (img:checkpoint) (color:cc99ff)nest",
"(hint)_ - element = (color:cc99ff)golem_killed_last",
"(hint)_ - has qual, can roll multiple ele-mods",
"follow lava upstream to (img:checkpoint) arena:krutog",
"free (img:quest_2) matiki || makoru (ship): areaidg4_2_1 ;; kedge bay"
],
[
"optional: (img:exa) (color:cc99ff)league || (img:quest_2) (quest:map): (img:checkpoint) (color:cc99ff)stash",
"optional: alch orb: (img:checkpoint) (color:cc99ff)tidal_cay",
"reach areaidg4_2_2 ;; journey's end"
],
[
"optional: alch orb: (color:cc99ff)league",
"activate the (img:waypoint) _|| (img:quest_2) tujen",
"kill (img:checkpoint) hartlin || (img:portal) areaidg4_town ;; kingsmarch"
],
[
"(img:quest_2) dannig: (quest:verisium_spikes)",
"leaguestart: optional: check vendors",
"(img:waypoint) to areaidg4_2_2 ;; journey's end"
],
[
"(img:quest_2) freya || kill omniphobia",
"makoru (ship): areaidg4_3_1 ;; whakapanu island",
"(img:portal) to areaidg4_town ;; kingsmarch"
],
[
"(img:quest_2) tujen: (quest:book) + (img:skill)",
"enter (img:portal) areaidg4_3_1 ;; whakapanu island"
],
[
"optional: (img:artificer) (color:cc99ff)league || (img:support) in (img:checkpoint) (color:cc99ff)crab_cavern",
"optional: ?0.5?: (img:checkpoint) arena:shark || (img:quest_2) (quest:map): (img:checkpoint) (color:cc99ff)pirate",
"(hint)__ hand in (quest:shark_fin) in areaidg4_11_1a ;; ngakanu",
"reach (img:checkpoint) areaidg4_3_2 ;; singing caverns"
],
[
"optional: (color:66b2ff)charm: (color:cc99ff)league",
"optional: <all-res_amu>: get (quest:pearl) in (img:checkpoint) (color:cc99ff)clam",
"(hint)__ bring it to (img:quest_2) rog in areaidg4_town (img:town) ;; kingsmarch",
"kill (img:checkpoint) diamora || free (img:quest_2) matiki",
"makoru (ship): areaidg4_5_1 ;; abandoned prison"
],
[
"optional: (img:exa) (color:cc99ff)league",
"(quest:buff): 30% inc. flask recovery",
"(hint)_ - kill (color:cc99ff)necromancers for (quest:key)",
"(hint)_ - find (img:checkpoint) (color:cc99ff)chapel, activate (quest:statue)",
"enter (img:checkpoint) areaidg4_5_2 ;; solitary confinement"
],
[
"optional: random (img:rune): (color:cc99ff)league",
"kill (img:checkpoint) prisoner || free (img:quest_2) matiki",
"makoru (ship): areaidg4_7 ;; shrike island"
],
[
"optional: (img:support) (color:cc99ff)league || (img:quest_2) (quest:map): (img:checkpoint) (color:cc99ff)corpse_nest",
"kill (img:checkpoint) scourge_of_the_skies",
"free (img:quest_2) matiki || (img:portal) areaidg4_town ;; kingsmarch"
],
[
"(img:quest_2) hooded one",
"leaguestart: optional: check vendors",
"optional: makoru (ship): areaidg4_13 ;; plunder's point",
"(hint)__ requires 4 (quest:map_pieces)",
"makoru (ship): areaidg4_4_1 ;; eye of hinekora"
],
[
"optional: chaos orb: (color:cc99ff)league",
"(img:quest_2) matiki || (img:quest_2) (color:cc99ff)well || complete 3 tests",
"(hint)__ (img:spirit2): (color:cc99ff)waterfall between 2nd/3rd test",
"(img:quest_2) (color:lime)pay_respects in (img:checkpoint) (color:cc99ff)silent_hall",
"enter (img:checkpoint) areaidg4_4_2 ;; halls of the dead"
],
[
"optional: random (img:rune): (color:cc99ff)league",
"complete 3 (img:checkpoint) (color:cc99ff)tests for (quest:tattoos)",
"(hint)__ (img:quest_2) totems: turn in (quest:tattoos)",
"defeat (img:checkpoint) (color:ff8111)yama for (quest:silver_coin)",
"enter areaidg4_4_3 ;; trial of the ancestors"
],
[
"(img:quest_2) navali: (quest:tattoo_of_hinekora)",
"makoru (ship): areaidg4_8b ;; arastas (hostile)"
],
[
"follow (img:quest_2) lorandis || go outside",
"optional: (img:skill2) (color:cc99ff)league || 3 (img:exa) + 3 (img:regal) in (color:cc99ff)2 (img:checkpoint) (color:cc99ff)bells",
"kill torvian || enter areaidg4_10 ;; the excavation"
],
[
"optional: <amulet>: (color:cc99ff)league",
"kill (img:checkpoint) benedictus || enter site",
"(color:cc99ff)cut-scene || (img:portal) to areaidg4_town ;; kingsmarch",
"(hint)__ (color:cc99ff)skip: when quest says \"speak to",
"(hint)__ the hooded one,\" (color:cc99ff)esc:_respawn"
],
[
"leaguestart: optional: check vendors",
"(img:quest_2) rhodri (ship): areaidg4_11_1b ;; ngakanu"
],
[
"greater (img:jeweller): (color:cc99ff)league",
"reach (img:checkpoint) areaidg4_11_2 ;; heart of the tribe"
],
[
"optional: (img:spirit2) (color:cc99ff)league",
"find & defeat arena:tavakai",
"(img:quest_2) tavakai || (img:portal) areaidg4_town ;; kingsmarch"
],
[
"(color:yellow)updated:_non-linear_interludes",
"(hint)__ new order prioritizes (quest:points/buffs)",
"(color:red)if_you're_using_the_timer:",
"(hint)__ (img:quest_2) hooded one: (color:cc99ff)travel_to_ogham",
"(hint)__ (so the timer stays synced up)",
"(img:quest_2) hooded one: (color:cc99ff)travel_to_vastiri",
"enter areaidp2_town ;; the khari bazaar"
]
],
[
[
"leaguestart: optional: check vendors",
"go (img:1) to areaidp2_1 ;; the khari crossing"
],
[
"optional: (img:gcp) (color:cc99ff)league || <jewelry/caster> (color:cc99ff)vendor",
"(hint)__ (color:cc99ff)vendor: south between the next 2 (img:checkpoint)",
"go (img:0) + (img:7) to (img:checkpoint) (color:cc99ff)stairway: get (quest:gift)",
"(color:cc99ff)esc:_respawn",
"follow (img:6) edge to (img:checkpoint) areaidp2_5 ;; galai gates"
],
[
"activate (img:waypoint)",
"go back to areaidp2_1 ;; the khari crossing"
],
[
"optional: (img:gcp) (color:cc99ff)league",
"(img:checkpoint) -travel to (color:cc99ff)town_entrance",
"go (img:2), kill (img:checkpoint) anundr_&_akthi",
"(img:portal) to areaidp2_town ;; khari bazaar"
],
[
"(img:quest_2) risu: (quest:book)",
"go (img:5) to areaidp2_1 ;; the khari crossing"
],
[
"go (img:6) to (img:checkpoint) areaidp2_2 ;; pools of khatal"
],
[
"(img:waypoint) to areaidp2_town (img:town) ;; khari bazaar",
"(img:quest_2) hooded one: (color:cc99ff)travel_to_kriar",
"enter areaidp3_town ;; the glade"
],
[
"enter areaidp3_1 ;; ashen forest"
],
[
"optional: <belt>: (color:cc99ff)league || (img:skill) in (img:checkpoint) (color:cc99ff)monument",
"reach (img:checkpoint) areaidp3_2 ;; kriar village"
],
[
"optional: greater (img:rune): (color:cc99ff)league",
"kill (img:checkpoint) lythara for (quest:skull)",
"enter areaidp3_3 ;; glacial tarn"
],
[
"optional: greater augment: (color:cc99ff)league",
"follow (img:2) edge: (img:checkpoint) areaidp3_4 ;; howling caves"
],
[
"optional: chaos orb: (color:cc99ff)league",
"kill (img:checkpoint) yeti (color:lime)(tusks) || (img:portal) areaidp3_town ;; the glade"
],
[
"(img:quest_2) hilda: (quest:book)",
"leaguestart: optional: check vendors",
"(img:waypoint) to areaidp3_3 ;; glacial tarn"
],
[
"optional: greater augment: (color:cc99ff)league",
"follow (img:7) edge, kill (img:checkpoint) rakkar",
"enter areaidp3_5 ;; kriar peaks"
],
[
"optional: greater transmute: (color:cc99ff)league",
"optional: (color:ff8111)item from (img:quest_2) elder madox",
"(hint)__ passage marked by (color:cc99ff)owls (edge)",
"reach (img:checkpoint) areaidp3_6 ;; etched ravine"
],
[
"optional: (img:exa) (color:cc99ff)league",
"reach areaidp3_7 ;; the cuachic vault"
],
[
"optional: vaal: (color:cc99ff)league || locked vaults (quest:(cores))",
"kill (img:checkpoint) zolin_&_zelina",
"(img:quest_2) doryani || (img:portal) areaidp3_town ;; the glade"
],
[
"leaguestart: optional: check vendors",
"(img:waypoint) to areaidp2_2 ;; pools of khatal"
]
],
[
[
"optional: alch orb: (color:cc99ff)league",
"reach (img:checkpoint) areaidp2_3 ;; sel khari sanctuary"
],
[
"optional: chance orb: (color:cc99ff)league",
"optional: <ring/amu/jewel>: loot (quest:2_baryas)",
"(hint)__ put into (img:checkpoint) (color:cc99ff)pedestals (side edges)",
"find & kill (img:checkpoint) elzarah",
"(img:quest_2) asala || (img:portal) to areaidp2_town ;; the khari bazaar"
],
[
"(img:waypoint) to areaidp2_5 ;; the galai gates"
],
[
"optional: greater augment: (color:cc99ff)league",
"kill (img:checkpoint) vornas || enter areaidp2_6 ;; qimah"
],
[
"optional: (img:exa) (color:cc99ff)league || (img:checkpoint) strongboxes",
"(quest:buff): (img:checkpoint) (color:cc99ff)7_pillars (corners/edges)",
"(hint)_ - follow (img:0) edge to the (img:arena) (color:cc99ff)exit",
"(hint)_ - not there? it's (img:3) of (img:arena) exit_/_start",
"(img:quest_2) jado || enter (img:checkpoint) areaidp2_7 ;; qimah reservoir"
],
[
"optional: greater transmute: (color:cc99ff)league",
"optional: currency: find (quest:2_vials) + (color:cc99ff)2 (img:checkpoint) (color:cc99ff)wells",
"(hint)__ 1st drops, 2nd in (img:checkpoint) (color:cc99ff)side-room <chest>",
"kill (img:checkpoint) azmadi || (img:quest_2) grand barya",
"(img:quest_2) jado || (img:portal) to areaidp2_town ;; the khari bazaar"
],
[
"(img:quest_2) hooded one: (color:cc99ff)travel_to_ogham",
"(color:red)timer-users: (img:waypoint) to areaidp1_town ;; the refuge"
]
],
[
[
"leaguestart: optional: check vendors",
"enter (img:checkpoint) areaidp1_1 ;; scorched farmlands"
],
[
"optional: (img:skill) on (img:checkpoint) (color:cc99ff)corpse by road || (img:support) (color:cc99ff)league",
"get the (img:checkpoint) next to (color:cc99ff)wall_of_darkness",
"kill witches, enter (img:checkpoint) areaidp1_2 ;; stones of serle"
],
[
"optional: (img:exa) (color:cc99ff)league",
"find 6 (img:checkpoint) (color:cc99ff)megaliths || kill siora",
"(img:quest_2) una",
"go back to areaidp1_1 ;; scorched farmlands",
"(hint)__ (color:red)wait_for_quest-state: \"return to\""
],
[
"(img:checkpoint) -travel to (color:cc99ff)wall_of_darkness",
"enter (img:checkpoint) areaidp1_3 ;; the blackwood"
],
[
"optional: omens in (color:cc99ff)omen_altars",
"optional: greater transmute: (color:cc99ff)league",
"reach (img:checkpoint) areaidp1_4 ;; holten"
],
[
"optional: greater (img:rune): (color:cc99ff)league",
"optional: (img:checkpoint) (color:cc99ff)rune_vendor: (img:6) of (color:cc99ff)bridge",
"(hint)__ (color:cc99ff)bridge is on way to areaidp1_5 ;; wolvenhold",
"follow (img:1) edge: (img:checkpoint) areaidp1_5 ;; wolvenhold"
],
[
"optional: greater augment: (color:cc99ff)league",
"kill (img:checkpoint) oswin for (quest:book)",
"(hint)__ usually (img:1) or (img:7)",
"go back to (img:checkpoint) areaidp1_4 ;; holten"
],
[
"optional: greater (img:rune): (color:cc99ff)league",
"(img:0) edge leads to (img:checkpoint) areaidp1_6 ;; holten estate"
],
[
"optional: (img:artificer) (color:cc99ff)league",
"kill (img:checkpoint) elswyth_&_wulfric",
"(img:portal) to areaidp1_town ;; the refuge"
],
[
"(img:quest_2) renly",
"leaguestart: optional: check vendors",
"(img:waypoint) to areaidg4_town ;; kingsmarch"
],
[
"(img:quest_2) hooded one: (quest:book)",
"(img:quest_2) hooded one: (color:cc99ff)travel_to_oriath",
"enter areaidg_endgame_town ;; the ziggurat refuge"
],
[
"(img:quest_2) alva || (img:quest_2) doryani",
"leaguestart: use the (color:cc99ff)map_device",
"<the_act-tracker_ends_here>"
]
]
]

BIN
src-tauri/icons/128x128.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

BIN
src-tauri/icons/32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 974 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 903 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

BIN
src-tauri/icons/icon.icns Normal file

Binary file not shown.

BIN
src-tauri/icons/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

BIN
src-tauri/icons/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

212
src-tauri/src/config.rs Normal file
View File

@ -0,0 +1,212 @@
use crate::timer::RunTimer;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
/// A manually-created profile bound to one in-game character (matched by name
/// against the log). Each profile keeps its own guide progress, branch and timer.
#[derive(Serialize, Deserialize, Clone, Debug)]
#[serde(default)]
pub struct Profile {
pub name: String,
pub current_step: usize,
pub league_start: bool,
/// Show optional loot/quests/encounters (lines prefixed "optional:").
pub show_optionals: bool,
/// Per-profile campaign timer (with its own per-act splits).
pub timer: RunTimer,
}
impl Default for Profile {
fn default() -> Self {
Profile {
name: String::new(),
current_step: 0,
league_start: true,
show_optionals: true,
timer: RunTimer::default(),
}
}
}
/// Persistent user configuration. Stored as JSON in the platform config dir.
#[derive(Serialize, Deserialize, Clone, Debug)]
#[serde(default)]
pub struct Config {
/// Absolute path to the game's Client.txt log file.
pub log_path: Option<String>,
/// Substring used to detect whether the game window is focused (matched against the active window title).
pub poe_window_match: String,
/// Only show overlays while the game window is focused.
pub overlay_only_when_focused: bool,
// --- leveling tracker overlay geometry (logical pixels) ---
pub overlay_x: i32,
pub overlay_y: i32,
pub overlay_width: u32,
pub overlay_font_size: u32,
// --- global hotkeys (accelerator strings, e.g. "Alt+X") ---
pub hotkey_next: String,
pub hotkey_prev: String,
pub hotkey_toggle: String,
pub hotkey_timer_pause: String,
pub hotkey_layout: String,
// --- zone layout viewer (interactive overlay, ported from the AHK act-decoder) ---
/// Enable the zone-layout viewer window + its hotkey.
pub feature_layouts: bool,
/// Saved position of the layout window (logical pixels).
pub layout_x: i32,
pub layout_y: i32,
/// Size (px) of the main layout image rendered in the viewer.
pub layout_size: u32,
// --- campaign timer ---
pub timer_enabled: bool,
pub timer_pause_in_town: bool,
/// Auto-pause the timer after no mouse movement for `timer_afk_seconds`.
pub timer_afk_enabled: bool,
pub timer_afk_seconds: u32,
/// Auto-pause the timer while the game window isn't focused.
pub timer_pause_unfocused: bool,
// --- leveling tracker state ---
/// Live step for the active character (mirrors `progress[active_character]`).
pub current_step: usize,
/// Show optional loot/quests (mirrors the active profile's `show_optionals`).
pub show_optionals: bool,
/// Follow the league-start branch of the guide (vs. the non-league-start branch).
pub league_start: bool,
/// How many upcoming steps to display below the current one.
pub lookahead: usize,
/// Show the per-area level recommendations in the overlay.
pub show_recommendation: bool,
// --- character profiles & per-profile progress ---
/// Name of the character whose profile is currently active.
pub active_character: Option<String>,
/// Manually-created character profiles.
pub profiles: Vec<Profile>,
}
impl Default for Config {
fn default() -> Self {
Config {
log_path: None,
poe_window_match: "Path of Exile".to_string(),
overlay_only_when_focused: true,
overlay_x: 40,
overlay_y: 120,
overlay_width: 460,
overlay_font_size: 15,
hotkey_next: "Alt+X".to_string(),
hotkey_prev: "Alt+Z".to_string(),
hotkey_toggle: "Alt+Shift+X".to_string(),
hotkey_timer_pause: "Alt+P".to_string(),
hotkey_layout: "Alt+C".to_string(),
feature_layouts: true,
layout_x: 600,
layout_y: 160,
layout_size: 360,
timer_enabled: true,
timer_pause_in_town: true,
timer_afk_enabled: true,
timer_afk_seconds: 60,
timer_pause_unfocused: true,
current_step: 0,
show_optionals: true,
league_start: true,
lookahead: 2,
show_recommendation: true,
active_character: None,
profiles: Vec::new(),
}
}
}
impl Config {
/// Create a profile for `name` if none exists. Returns true if it was added.
pub fn create_profile(&mut self, name: &str, league_start: bool) -> bool {
let name = name.trim();
if name.is_empty() || self.profiles.iter().any(|p| p.name == name) {
return false;
}
self.profiles.push(Profile {
name: name.to_string(),
current_step: 0,
league_start,
show_optionals: true,
timer: RunTimer::default(),
});
true
}
/// Remove a profile by name. If it was active, clears the active character.
pub fn delete_profile(&mut self, name: &str) {
self.profiles.retain(|p| p.name != name);
if self.active_character.as_deref() == Some(name) {
self.active_character = None;
}
}
/// Make `name` the active profile, loading its step and branch into the live
/// fields. Returns true if the active profile changed (caller may need to
/// rebuild the guide if the league-start branch differs).
pub fn select_profile(&mut self, name: &str) -> bool {
if self.active_character.as_deref() == Some(name) {
return false;
}
// Persist the outgoing profile's live state first.
self.sync_progress();
let Some(p) = self.profiles.iter().find(|p| p.name == name) else {
return false;
};
self.current_step = p.current_step;
self.league_start = p.league_start;
self.show_optionals = p.show_optionals;
self.active_character = Some(name.to_string());
true
}
/// Mirror the live step/branch/optionals back into the active profile so they persist.
pub fn sync_progress(&mut self) {
let (step, league, optionals) = (self.current_step, self.league_start, self.show_optionals);
if let Some(a) = self.active_character.clone() {
if let Some(p) = self.profiles.iter_mut().find(|p| p.name == a) {
p.current_step = step;
p.league_start = league;
p.show_optionals = optionals;
}
}
}
}
fn config_dir() -> PathBuf {
let mut dir = dirs::config_dir().unwrap_or_else(|| PathBuf::from("."));
dir.push("exile-ui");
dir
}
pub fn config_path() -> PathBuf {
let mut p = config_dir();
p.push("config.json");
p
}
impl Config {
pub fn load() -> Config {
let path = config_path();
match std::fs::read_to_string(&path) {
Ok(text) => serde_json::from_str(&text).unwrap_or_default(),
Err(_) => Config::default(),
}
}
pub fn save(&self) {
let dir = config_dir();
let _ = std::fs::create_dir_all(&dir);
if let Ok(text) = serde_json::to_string_pretty(self) {
let _ = std::fs::write(config_path(), text);
}
}
}

View File

@ -0,0 +1,224 @@
use serde::Serialize;
use std::collections::HashMap;
// Game data is embedded at compile time. These files come from the original
// Lailloken/Exile-UI data set (PoE2 variants).
const AREAS_JSON: &str = include_str!("../data/areas2.json");
const GUIDE_JSON: &str = include_str!("../data/guide2.json");
const GEMS_JSON: &str = include_str!("../data/gems2.json");
#[derive(Serialize, Clone, Debug)]
pub struct Area {
pub id: String,
pub name: String,
/// Recommended character level when entering this area (raw "min | max" string).
pub recommendation: Option<String>,
/// Index of the area group this belongs to (acts: 0..n).
pub group: usize,
}
#[derive(Serialize, Clone, Debug)]
pub struct Step {
/// Lines of the step, in the original markup language.
pub lines: Vec<String>,
/// Guide section index (roughly corresponds to an act).
pub section: usize,
/// Area the player should travel to during this step, if detectable.
pub target_area: Option<String>,
}
#[derive(Serialize, Clone)]
pub struct GuideData {
pub steps: Vec<Step>,
/// areaID -> Area metadata.
pub areas: HashMap<String, Area>,
/// Ordered list of area-group "names" (first travelable area id of each group) for labels.
pub section_count: usize,
}
/// Resolve the area an `areaid…` token points at within a step.
fn extract_target_area(lines: &[String]) -> Option<String> {
let mut last: Option<String> = None;
let mut preferred: Option<String> = None;
for line in lines {
let lower = line.to_lowercase();
let is_travel = lower.contains("enter ")
|| lower.contains("waypoint")
|| lower.contains("portal")
|| lower.contains("to areaid");
let mut search = line.as_str();
while let Some(pos) = search.find("areaid") {
let rest = &search[pos + "areaid".len()..];
let id: String = rest
.chars()
.take_while(|c| c.is_ascii_alphanumeric() || *c == '_')
.collect();
if !id.is_empty() {
last = Some(id.clone());
if is_travel {
preferred = Some(id);
}
}
// advance past this token
let consumed = pos + "areaid".len() + rest.chars().take_while(|c| c.is_ascii_alphanumeric() || *c == '_').count();
if consumed >= search.len() {
break;
}
search = &search[consumed..];
}
}
preferred.or(last)
}
/// Normalize a guide entry into (lines, optional condition (key, value)).
fn parse_entry(entry: &serde_json::Value) -> (Vec<String>, Option<(String, String)>) {
if let Some(arr) = entry.as_array() {
let lines = arr
.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect();
(lines, None)
} else if let Some(obj) = entry.as_object() {
let lines = obj
.get("lines")
.and_then(|v| v.as_array())
.map(|a| {
a.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect()
})
.unwrap_or_default();
let condition = obj.get("condition").and_then(|v| v.as_array()).and_then(|a| {
match (a.first().and_then(|x| x.as_str()), a.get(1).and_then(|x| x.as_str())) {
(Some(k), Some(v)) => Some((k.to_string(), v.to_string())),
_ => None,
}
});
(lines, condition)
} else {
(Vec::new(), None)
}
}
pub fn load(league_start: bool) -> GuideData {
// --- areas ---
let mut areas: HashMap<String, Area> = HashMap::new();
if let Ok(groups) = serde_json::from_str::<Vec<Vec<serde_json::Value>>>(AREAS_JSON) {
for (gi, group) in groups.iter().enumerate() {
for a in group {
let id = a.get("id").and_then(|v| v.as_str()).unwrap_or("").to_string();
if id.is_empty() {
continue;
}
let name = a.get("name").and_then(|v| v.as_str()).unwrap_or("").to_string();
let recommendation = a
.get("recommendation")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
areas.insert(
id.clone(),
Area {
id,
name,
recommendation,
group: gi,
},
);
}
}
}
// --- guide ---
// Structure: [ section[ entry, ... ], ... ] where each entry is either an
// array of line strings, or an object { "condition": [key, value], "lines": [...] }.
// Conditional entries currently only encode "league-start" branches.
let mut steps: Vec<Step> = Vec::new();
let mut section_count = 0;
if let Ok(sections) = serde_json::from_str::<Vec<Vec<serde_json::Value>>>(GUIDE_JSON) {
section_count = sections.len();
for (si, section) in sections.into_iter().enumerate() {
for entry in section {
let (lines, condition) = parse_entry(&entry);
// Skip the branch that doesn't apply to the chosen play mode.
if let Some((key, val)) = &condition {
if key == "league-start" {
let wants_yes = val == "yes";
if wants_yes != league_start {
continue;
}
}
}
if lines.is_empty() {
continue;
}
let target_area = extract_target_area(&lines);
steps.push(Step {
lines,
section: si,
target_area,
});
}
}
}
GuideData {
steps,
areas,
section_count,
}
}
/// Raw gems data (skill/spirit/support -> name -> [level, quality-ish]) passed to the UI as-is.
pub fn gems_raw() -> serde_json::Value {
serde_json::from_str(GEMS_JSON).unwrap_or(serde_json::Value::Null)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn extracts_travel_target() {
let lines = vec![
"kill some_boss".to_string(),
"(img:portal) to areaidg1_town ;; clearfell encampment".to_string(),
];
assert_eq!(extract_target_area(&lines).as_deref(), Some("g1_town"));
}
#[test]
fn prefers_travel_line_over_mention() {
let lines = vec![
"optional: boss in areaidg1_3".to_string(),
"enter areaidg1_4 ;; the grelwood".to_string(),
];
assert_eq!(extract_target_area(&lines).as_deref(), Some("g1_4"));
}
#[test]
fn guide_and_areas_load() {
let g = load(true);
assert!(!g.steps.is_empty(), "guide steps should load");
assert!(g.areas.contains_key("g1_1"), "areas should contain g1_1");
assert!(g.section_count >= 6);
}
}
/// Find the step index whose target area equals `area_id`, preferring the match
/// closest to `current`. Returns None if no step targets that area.
pub fn step_for_area(steps: &[Step], area_id: &str, current: usize) -> Option<usize> {
let mut best: Option<usize> = None;
let mut best_dist = usize::MAX;
for (i, step) in steps.iter().enumerate() {
if let Some(t) = &step.target_area {
if t == area_id {
let dist = if i >= current { i - current } else { current - i };
if dist < best_dist {
best_dist = dist;
best = Some(i);
}
}
}
}
best
}

766
src-tauri/src/lib.rs Normal file
View File

@ -0,0 +1,766 @@
mod config;
mod leveltracker;
mod logwatch;
mod poe;
mod timer;
use config::Config;
use leveltracker::GuideData;
use timer::RunTimer;
use serde::Serialize;
use std::str::FromStr;
use std::sync::Mutex;
use tauri::{
AppHandle, Emitter, LogicalPosition, LogicalSize, Manager, PhysicalSize, WebviewUrl,
WebviewWindowBuilder,
};
use tauri_plugin_global_shortcut::{GlobalShortcutExt, Shortcut, ShortcutState};
/// Live game state derived from the client log.
#[derive(Serialize, Clone, Default)]
pub struct Status {
pub character: String,
pub class: String,
pub level: u32,
pub area_id: String,
pub area_name: String,
pub area_level: u32,
pub area_seed: String,
pub act: usize,
pub game_focused: bool,
}
pub struct AppState {
pub config: Mutex<Config>,
pub guide: Mutex<GuideData>,
pub status: Mutex<Status>,
pub timer: Mutex<RunTimer>,
// While the user drags the layout window by its title bar: Some((dx, dy)) is
// the offset from the window's top-left to the cursor; a background thread
// follows the mouse via xdotool (Tauri/KWin won't reposition this window).
pub layout_drag: Mutex<Option<(i32, i32)>>,
}
// ---------------------------------------------------------------------------
// Commands
// ---------------------------------------------------------------------------
#[tauri::command]
fn get_config(state: tauri::State<AppState>) -> Config {
state.config.lock().unwrap().clone()
}
#[tauri::command]
fn save_config(app: AppHandle, state: tauri::State<AppState>, new: Config) {
let league_changed = {
let mut cfg = state.config.lock().unwrap();
let changed = cfg.league_start != new.league_start;
*cfg = new;
cfg.sync_progress(); // keep the active profile in sync with live fields
cfg.save();
changed
};
if league_changed {
rebuild_guide(&app);
}
register_shortcuts(&app);
apply_overlay_geometry(&app);
let cfg = state.config.lock().unwrap().clone();
let _ = app.emit("config://update", cfg);
}
#[tauri::command]
fn get_status(state: tauri::State<AppState>) -> Status {
state.status.lock().unwrap().clone()
}
#[tauri::command]
fn get_guide(state: tauri::State<AppState>) -> GuideData {
state.guide.lock().unwrap().clone()
}
#[tauri::command]
fn get_gems() -> serde_json::Value {
leveltracker::gems_raw()
}
#[tauri::command]
fn detect_logs() -> Vec<String> {
poe::detect_log_paths()
}
#[tauri::command]
fn get_timer(state: tauri::State<AppState>) -> RunTimer {
state.timer.lock().unwrap().clone()
}
#[tauri::command]
fn timer_start(app: AppHandle, state: tauri::State<AppState>) {
let act = state.status.lock().unwrap().act.max(1);
let name = now_string();
{
let mut t = state.timer.lock().unwrap();
t.start(act, name);
}
store_live_timer(&state);
emit_timer(&app);
}
#[tauri::command]
fn timer_pause(app: AppHandle, state: tauri::State<AppState>) {
{
let mut t = state.timer.lock().unwrap();
t.toggle_pause();
}
store_live_timer(&state);
emit_timer(&app);
}
#[tauri::command]
fn timer_reset(app: AppHandle, state: tauri::State<AppState>) {
state.timer.lock().unwrap().reset();
store_live_timer(&state);
emit_timer(&app);
}
#[tauri::command]
fn step_delta(app: AppHandle, state: tauri::State<AppState>, delta: i64) {
let max = state.guide.lock().unwrap().steps.len().saturating_sub(1);
let new = {
let cur = state.config.lock().unwrap().current_step as i64;
(cur + delta).clamp(0, max as i64) as usize
};
set_step(&app, new);
}
#[tauri::command]
fn step_goto(app: AppHandle, new: usize) {
set_step(&app, new);
}
// --- character profiles ---
#[tauri::command]
fn create_profile(app: AppHandle, state: tauri::State<AppState>, name: String, league_start: bool) {
let added = {
let mut cfg = state.config.lock().unwrap();
let added = cfg.create_profile(&name, league_start);
if added {
cfg.save();
}
added
};
if added {
// Newly created profiles become the active one.
select_profile(app, state, name);
}
}
#[tauri::command]
fn delete_profile(app: AppHandle, state: tauri::State<AppState>, name: String) {
{
let mut cfg = state.config.lock().unwrap();
cfg.delete_profile(&name);
cfg.save();
}
let cfg = state.config.lock().unwrap().clone();
let _ = app.emit("config://update", cfg);
}
#[tauri::command]
fn select_profile(app: AppHandle, state: tauri::State<AppState>, name: String) {
// Bank the outgoing profile's live timer before switching.
store_live_timer(&state);
let league_changed = {
let mut cfg = state.config.lock().unwrap();
let prev_league = cfg.league_start;
let switched = cfg.select_profile(&name);
if switched {
cfg.save();
}
switched && cfg.league_start != prev_league
};
// Load the incoming profile's timer into the live state.
{
let new_timer = {
let cfg = state.config.lock().unwrap();
cfg.active_character
.as_ref()
.and_then(|a| cfg.profiles.iter().find(|p| &p.name == a))
.map(|p| p.timer.clone())
.unwrap_or_default()
};
*state.timer.lock().unwrap() = new_timer;
}
if league_changed {
rebuild_guide(&app);
}
let (step, cfg) = {
let cfg = state.config.lock().unwrap();
(cfg.current_step, cfg.clone())
};
let _ = app.emit("tracker://step", step);
let _ = app.emit("config://update", cfg);
emit_timer(&app);
}
#[tauri::command]
fn set_overlay_locked(app: AppHandle, locked: bool) {
if let Some(w) = app.get_webview_window("overlay") {
let _ = w.set_ignore_cursor_events(locked);
if locked {
// Removing the drag bar on re-lock shifts the content up, and the
// transparent WebKitGTK surface doesn't clear its previous frame,
// leaving a ghost copy of the old layout (hide/show doesn't fix it —
// WebKit keeps its backing buffer across map cycles). Forcing a real
// resize reallocates and clears that surface. Two synchronous
// set_size calls would be coalesced by GTK (the window never truly
// changes size), so grow by 1px now and shrink back after a GTK loop
// cycle. A resize keeps the top-left corner, so the dragged position
// is preserved (unlike hide/show, which lets the WM re-place it).
if let Ok(sz) = w.inner_size() {
let _ = w.set_size(PhysicalSize::new(sz.width, sz.height + 1));
let app2 = app.clone();
std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_millis(50));
if let Some(w2) = app2.get_webview_window("overlay") {
let _ = w2.set_size(sz);
}
});
}
}
}
let _ = app.emit("overlay://locked", locked);
}
#[tauri::command]
fn toggle_overlay(app: AppHandle) {
// Visibility is a frontend concern; just notify the overlay view.
let _ = app.emit("overlay://toggle", ());
}
/// Show/hide the interactive zone-layout viewer window. It is a real (focusable,
/// non-click-through) window, so visibility is the OS window's own show/hide. The
/// view itself polls the current area (its `listen` subscription doesn't fire
/// while the window is created hidden), so showing it is enough to see the zone.
#[tauri::command]
fn toggle_layout(app: AppHandle, state: tauri::State<AppState>) {
if !state.config.lock().unwrap().feature_layouts {
return;
}
if let Some(w) = app.get_webview_window("layout") {
if w.is_visible().unwrap_or(false) {
persist_layout_position(&app, &state);
let _ = w.hide();
} else {
let (x, y) = {
let cfg = state.config.lock().unwrap();
(cfg.layout_x, cfg.layout_y)
};
let _ = w.set_position(LogicalPosition::new(x, y));
let _ = w.show();
let _ = w.set_focus();
// KWin tends to clamp/centre a freshly shown decorationless window;
// force the saved position via xdotool, same as the overlay.
std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_millis(60));
let _ = std::process::Command::new("xdotool")
.args([
"search",
"--name",
"Exile UI Layouts",
"windowmove",
&x.to_string(),
&y.to_string(),
])
.status();
});
}
}
}
/// Resize the layout window to fit its rendered content (called by the frontend
/// whenever the viewer's content size changes).
#[tauri::command]
fn set_layout_size(app: AppHandle, width: u32, height: u32) {
if let Some(w) = app.get_webview_window("layout") {
let _ = w.set_size(LogicalSize::new(width.max(80), height.max(80)));
}
}
/// Persist the layout window's current position into config (called by the
/// frontend after the user drags it).
#[tauri::command]
fn save_layout_geometry(app: AppHandle, state: tauri::State<AppState>) {
persist_layout_position(&app, &state);
}
/// Begin dragging the layout window by its title bar. The frontend's
/// `data-tauri-drag-region` / `setPosition` don't move this window under KWin, so
/// a background thread follows the mouse with xdotool until `end_layout_drag`.
#[tauri::command]
fn start_layout_drag(app: AppHandle, state: tauri::State<AppState>) {
let Some(w) = app.get_webview_window("layout") else {
return;
};
let Some((mx, my)) = crate::poe::mouse_position() else {
return;
};
let Ok(pos) = w.outer_position() else {
return;
};
// Cursor offset within the window (physical px == screen px at scale 1).
*state.layout_drag.lock().unwrap() = Some((mx - pos.x, my - pos.y));
// Resolve the X window id once so the loop only spawns one xdotool per tick.
let id = std::process::Command::new("xdotool")
.args(["search", "--name", "Exile UI Layouts"])
.output()
.ok()
.and_then(|o| {
String::from_utf8_lossy(&o.stdout)
.lines()
.last()
.map(|s| s.trim().to_string())
});
let Some(id) = id.filter(|s| !s.is_empty()) else {
return;
};
let app = app.clone();
std::thread::spawn(move || {
let start = std::time::Instant::now();
loop {
let off = match *app.state::<AppState>().layout_drag.lock().unwrap() {
Some(o) => o,
None => break,
};
// Safety net so a missed pointerup can't pin the window to the cursor.
if start.elapsed().as_secs() > 30 {
*app.state::<AppState>().layout_drag.lock().unwrap() = None;
break;
}
if let Some((mx, my)) = crate::poe::mouse_position() {
let _ = std::process::Command::new("xdotool")
.args([
"windowmove",
&id,
&(mx - off.0).to_string(),
&(my - off.1).to_string(),
])
.status();
}
std::thread::sleep(std::time::Duration::from_millis(16));
}
});
}
/// Stop dragging the layout window and persist its final position.
#[tauri::command]
fn end_layout_drag(app: AppHandle, state: tauri::State<AppState>) {
*state.layout_drag.lock().unwrap() = None;
std::thread::sleep(std::time::Duration::from_millis(30));
persist_layout_position(&app, &state);
}
fn persist_layout_position(app: &AppHandle, state: &AppState) {
if let Some(w) = app.get_webview_window("layout") {
let scale = w.scale_factor().unwrap_or(1.0);
if let Ok(pos) = w.outer_position() {
let lp = pos.to_logical::<i32>(scale);
let mut cfg = state.config.lock().unwrap();
cfg.layout_x = lp.x;
cfg.layout_y = lp.y;
cfg.save();
}
}
}
/// Resize the overlay window's height to fit its content (called by the frontend
/// whenever the rendered overlay's height changes). Keeps the window only as tall
/// as needed so it can be placed anywhere without the WM clamping it on-screen.
#[tauri::command]
fn set_overlay_height(app: AppHandle, state: tauri::State<AppState>, height: u32) {
if let Some(w) = app.get_webview_window("overlay") {
let width = state.config.lock().unwrap().overlay_width;
let _ = w.set_size(LogicalSize::new(width, height.max(1)));
}
}
/// Move the overlay to its saved position via xdotool. Tauri/tao's set_position
/// is clamped on-screen by KWin (and unreliable at startup), so we drive the X11
/// window move directly — it honors off-edge positions for the short window.
#[tauri::command]
fn restore_overlay_position(state: tauri::State<AppState>) {
let (x, y) = {
let cfg = state.config.lock().unwrap();
(cfg.overlay_x, cfg.overlay_y)
};
let _ = std::process::Command::new("xdotool")
.args([
"search",
"--name",
"Exile UI Overlay",
"windowmove",
&x.to_string(),
&y.to_string(),
])
.status();
}
#[tauri::command]
fn save_overlay_geometry(app: AppHandle, state: tauri::State<AppState>) {
// Persist the overlay window's current position/size back into config.
if let Some(w) = app.get_webview_window("overlay") {
let scale = w.scale_factor().unwrap_or(1.0);
if let (Ok(pos), Ok(size)) = (w.outer_position(), w.inner_size()) {
let lp = pos.to_logical::<i32>(scale);
let ls = size.to_logical::<u32>(scale);
let mut cfg = state.config.lock().unwrap();
cfg.overlay_x = lp.x;
cfg.overlay_y = lp.y;
cfg.overlay_width = ls.width;
cfg.save();
}
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
fn emit_timer(app: &AppHandle) {
let t = app.state::<AppState>().timer.lock().unwrap().clone();
let _ = app.emit("timer://update", t);
}
/// Persist the live timer into the active profile and save the config. Replaces
/// the old standalone timer.json so each profile keeps its own timer + splits.
fn store_live_timer(state: &AppState) {
let t = { state.timer.lock().unwrap().clone() };
let mut cfg = state.config.lock().unwrap();
if let Some(a) = cfg.active_character.clone() {
if let Some(p) = cfg.profiles.iter_mut().find(|p| p.name == a) {
p.timer = t;
}
}
cfg.save();
}
pub(crate) fn persist_timer(app: &AppHandle) {
store_live_timer(&app.state::<AppState>());
}
/// A friendly local timestamp used to label a run.
pub(crate) fn now_string() -> String {
std::process::Command::new("date")
.arg("+%Y/%m/%d %H:%M")
.output()
.ok()
.and_then(|o| String::from_utf8(o.stdout).ok())
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.unwrap_or_else(|| {
let secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
format!("run {}", secs)
})
}
fn set_step(app: &AppHandle, new: usize) {
let state = app.state::<AppState>();
{
let mut cfg = state.config.lock().unwrap();
cfg.current_step = new;
cfg.sync_progress();
cfg.save();
}
let _ = app.emit("tracker://step", new);
}
/// Rebuild the guide for the active profile's league-start branch and clamp the
/// cursor; emits a reload so both windows refresh.
fn rebuild_guide(app: &AppHandle) {
let state = app.state::<AppState>();
let league = state.config.lock().unwrap().league_start;
let rebuilt = leveltracker::load(league);
let max = rebuilt.steps.len().saturating_sub(1);
*state.guide.lock().unwrap() = rebuilt;
let step = {
let mut cfg = state.config.lock().unwrap();
if cfg.current_step > max {
cfg.current_step = max;
cfg.sync_progress();
cfg.save();
}
cfg.current_step
};
let _ = app.emit("guide://reload", ());
let _ = app.emit("tracker://step", step);
}
/// Auto-switch to the profile matching the character read from the log, if one
/// exists. Manually-created profiles only — an unknown character is ignored.
pub(crate) fn activate_profile_if_exists(app: &AppHandle, name: &str) {
let state = app.state::<AppState>();
let exists = {
let cfg = state.config.lock().unwrap();
cfg.active_character.as_deref() != Some(name)
&& cfg.profiles.iter().any(|p| p.name == name)
};
if exists {
select_profile(app.clone(), state, name.to_string());
}
}
fn register_shortcuts(app: &AppHandle) {
let gs = app.global_shortcut();
let _ = gs.unregister_all();
let cfg = app.state::<AppState>().config.lock().unwrap().clone();
for accel in [
&cfg.hotkey_next,
&cfg.hotkey_prev,
&cfg.hotkey_toggle,
&cfg.hotkey_timer_pause,
&cfg.hotkey_layout,
] {
if let Ok(sc) = Shortcut::from_str(accel) {
let _ = gs.register(sc);
}
}
}
fn apply_overlay_geometry(app: &AppHandle) {
if let Some(w) = app.get_webview_window("overlay") {
let cfg = app.state::<AppState>().config.lock().unwrap().clone();
let _ = w.set_position(LogicalPosition::new(cfg.overlay_x, cfg.overlay_y));
// Width is user-controlled; height tracks the content (set_overlay_height)
// so a tall window isn't clamped on-screen when placed near a screen edge.
let scale = w.scale_factor().unwrap_or(1.0);
let h = w
.inner_size()
.map(|s| s.to_logical::<u32>(scale).height)
.unwrap_or(200);
let _ = w.set_size(LogicalSize::new(cfg.overlay_width, h));
}
}
fn handle_shortcut(app: &AppHandle, shortcut: &Shortcut) {
let cfg = app.state::<AppState>().config.lock().unwrap().clone();
if Shortcut::from_str(&cfg.hotkey_next).ok().as_ref() == Some(shortcut) {
step_delta_internal(app, 1);
} else if Shortcut::from_str(&cfg.hotkey_prev).ok().as_ref() == Some(shortcut) {
step_delta_internal(app, -1);
} else if Shortcut::from_str(&cfg.hotkey_toggle).ok().as_ref() == Some(shortcut) {
toggle_overlay(app.clone());
} else if Shortcut::from_str(&cfg.hotkey_timer_pause).ok().as_ref() == Some(shortcut) {
let state = app.state::<AppState>();
{
let mut t = state.timer.lock().unwrap();
t.toggle_pause();
}
store_live_timer(&state);
emit_timer(app);
} else if Shortcut::from_str(&cfg.hotkey_layout).ok().as_ref() == Some(shortcut) {
toggle_layout(app.clone(), app.state::<AppState>());
}
}
fn step_delta_internal(app: &AppHandle, delta: i64) {
let state = app.state::<AppState>();
let max = state.guide.lock().unwrap().steps.len().saturating_sub(1);
let new = {
let cur = state.config.lock().unwrap().current_step as i64;
(cur + delta).clamp(0, max as i64) as usize
};
set_step(app, new);
}
// ---------------------------------------------------------------------------
// Entry point
// ---------------------------------------------------------------------------
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
// WebKitGTK's DMABUF/GBM renderer fails on a number of Linux GPU/driver
// setups, leaving the webview blank ("Failed to create GBM buffer").
// Disabling it forces a software/GL path that renders reliably. Users can
// still override by exporting the variable themselves.
#[cfg(target_os = "linux")]
{
if std::env::var_os("WEBKIT_DISABLE_DMABUF_RENDERER").is_none() {
std::env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1");
}
// Game overlays need self-positioning, always-on-top, global hotkeys
// and active-window detection — all of which Wayland forbids for
// clients. Running under XWayland restores them (and PoE2 itself runs
// under XWayland via Proton, so both share one X11 space). Force the
// X11 GDK backend whenever an X server (real or XWayland) is reachable,
// unless the user explicitly chose a backend.
if std::env::var_os("GDK_BACKEND").is_none() && std::env::var_os("DISPLAY").is_some() {
std::env::set_var("GDK_BACKEND", "x11");
}
}
let config = Config::load();
let guide = leveltracker::load(config.league_start);
// The live timer mirrors the active profile's timer.
let live_timer = config
.active_character
.as_ref()
.and_then(|a| config.profiles.iter().find(|p| &p.name == a))
.map(|p| p.timer.clone())
.unwrap_or_default();
let state = AppState {
config: Mutex::new(config),
guide: Mutex::new(guide),
status: Mutex::new(Status::default()),
timer: Mutex::new(live_timer),
layout_drag: Mutex::new(None),
};
tauri::Builder::default()
.on_window_event(|window, event| {
// The overlay window lives for the whole session (hidden/click-through),
// so Tauri never auto-exits when the user closes the main window.
// Closing "main" should tear the whole app down, overlay included.
if let tauri::WindowEvent::CloseRequested { .. } = event {
if window.label() == "main" {
window.app_handle().exit(0);
}
}
})
.plugin(tauri_plugin_opener::init())
.plugin(
tauri_plugin_global_shortcut::Builder::new()
.with_handler(|app, shortcut, event| {
if event.state() == ShortcutState::Pressed {
handle_shortcut(app, shortcut);
}
})
.build(),
)
.manage(state)
.invoke_handler(tauri::generate_handler![
get_config,
save_config,
get_status,
get_guide,
get_gems,
detect_logs,
get_timer,
timer_start,
timer_pause,
timer_reset,
step_delta,
step_goto,
create_profile,
delete_profile,
select_profile,
set_overlay_locked,
toggle_overlay,
save_overlay_geometry,
set_overlay_height,
restore_overlay_position,
toggle_layout,
set_layout_size,
save_layout_geometry,
start_layout_drag,
end_layout_drag,
])
.setup(|app| {
let handle = app.handle().clone();
// Auto-detect a log path on first run if none configured.
{
let state = handle.state::<AppState>();
let mut cfg = state.config.lock().unwrap();
if cfg.log_path.is_none() {
if let Some(p) = poe::detect_log_paths().into_iter().next() {
cfg.log_path = Some(p);
cfg.save();
}
}
}
// Create the transparent, click-through overlay window.
let (ox, oy, ow) = {
let state = handle.state::<AppState>();
let cfg = state.config.lock().unwrap();
(cfg.overlay_x, cfg.overlay_y, cfg.overlay_width)
};
let overlay = WebviewWindowBuilder::new(
&handle,
"overlay",
WebviewUrl::App("index.html".into()),
)
.title("Exile UI Overlay")
// Small initial height; the frontend resizes it to fit the rendered
// content (set_overlay_height). A short window won't be clamped
// on-screen by the WM when restored near a screen edge.
.inner_size(ow as f64, 200.0)
.position(ox as f64, oy as f64)
.decorations(false)
.transparent(true)
.always_on_top(true)
.skip_taskbar(true)
.resizable(true)
.shadow(false)
// Must be created visible: the GTK/Gdk window only exists once
// realized, and set_ignore_cursor_events() needs it to apply the
// X11 input-shape (otherwise tao panics on an unrealized window).
.visible(true)
.build();
// The OS window stays visible and click-through for its whole
// lifetime; actual show/hide is handled in the frontend (a
// transparent, empty overlay is invisible anyway). This avoids
// GTK window-realization races around the input shape.
if let Ok(w) = overlay {
let _ = w.set_ignore_cursor_events(true);
// Position is restored by the frontend (restore_overlay_position)
// once it has shrunk the window to its content height — doing it
// earlier lets the WM clamp the still-tall window on-screen.
}
// Create the interactive zone-layout viewer window, hidden until the
// user toggles it with its hotkey. It is focusable and not
// click-through (you click to pick/refine the matching layout).
// Unlike the overlay it is NOT transparent: it's an opaque panel, and
// transparent WebKitGTK windows don't reliably repaint here (the view
// stays blank/ghosted), whereas an opaque window composites fine.
let (lx, ly, lsz) = {
let state = handle.state::<AppState>();
let cfg = state.config.lock().unwrap();
(cfg.layout_x, cfg.layout_y, cfg.layout_size)
};
let _ = WebviewWindowBuilder::new(
&handle,
"layout",
WebviewUrl::App("index.html".into()),
)
.title("Exile UI Layouts")
.inner_size(lsz as f64 + 24.0, lsz as f64 + 110.0)
.position(lx as f64, ly as f64)
.decorations(false)
.always_on_top(true)
.skip_taskbar(true)
.resizable(true)
.shadow(false)
.visible(false)
.build();
register_shortcuts(&handle);
logwatch::spawn(handle.clone());
Ok(())
})
.run(tauri::generate_context!())
.expect("error while running tauri application");
}

408
src-tauri/src/logwatch.rs Normal file
View File

@ -0,0 +1,408 @@
use crate::{leveltracker, AppState};
use std::fs::File;
use std::io::{Read, Seek, SeekFrom};
use std::time::Duration;
use tauri::{AppHandle, Emitter, Manager};
/// Parsed character info from an "is now level" line.
struct CharInfo {
character: String,
class: String,
level: u32,
}
fn parse_level_line(line: &str) -> Option<CharInfo> {
if !line.contains("is now level") {
return None;
}
// Take everything after the last ':' (the " : <msg>" separator; timestamps
// contain ':' too, but the message itself has none after it).
let after = line.rsplit_once(':').map(|(_, b)| b).unwrap_or(line).trim();
// after looks like: "CharName (Class) is now level 12"
let level: u32 = after
.chars()
.rev()
.take_while(|c| c.is_ascii_digit() || *c == ' ')
.collect::<String>()
.chars()
.rev()
.collect::<String>()
.trim()
.parse()
.ok()?;
let open = after.find('(');
let close = after.find(')');
let (character, class) = match (open, close) {
(Some(o), Some(c)) if c > o => (
after[..o].trim().to_string(),
after[o + 1..c].trim().to_string(),
),
_ => (after.split_whitespace().next().unwrap_or("").to_string(), String::new()),
};
Some(CharInfo {
character,
class,
level,
})
}
/// Parsed area info from a "Generating level" line.
struct AreaInfo {
id: String,
level: u32,
seed: String,
}
fn parse_area_line(line: &str) -> Option<AreaInfo> {
let gen = line.find("Generating level ")?;
let rest = &line[gen + "Generating level ".len()..];
// rest: "12 area \"g1_5\" with seed 1234567"
let level: u32 = rest
.chars()
.take_while(|c| c.is_ascii_digit())
.collect::<String>()
.parse()
.ok()?;
let area_kw = rest.find("area \"")?;
let after = &rest[area_kw + "area \"".len()..];
let id_end = after.find('"')?;
// The game capitalizes the area-id prefix (e.g. "G1_6") while the bundled
// guide/area data uses lowercase ids, so normalize for matching.
let mut id = after[..id_end].to_lowercase();
// known bugged PoE2 area ids carry a trailing underscore
if id == "c_g2_9_2_" || id == "c_g3_16_" {
id.pop();
}
let seed = rest
.find("with seed ")
.map(|p| rest[p + "with seed ".len()..].trim().to_string())
.unwrap_or_default();
Some(AreaInfo {
id,
level,
seed,
})
}
fn handle_line(app: &AppHandle, line: &str) {
let state = app.state::<AppState>();
if let Some(ci) = parse_level_line(line) {
{
let mut s = state.status.lock().unwrap();
s.character = ci.character.clone();
s.class = ci.class.clone();
s.level = ci.level;
}
emit_status(app);
// If a profile exists for the character we're playing, make it active so
// its saved progress is tracked.
if !ci.character.is_empty() {
crate::activate_profile_if_exists(app, &ci.character);
}
return;
}
if let Some(ai) = parse_area_line(line) {
let (area_name, act) = {
let guide = state.guide.lock().unwrap();
match guide.areas.get(&ai.id) {
Some(a) => (a.name.clone(), a.group + 1),
None => (String::new(), 0),
}
};
{
let mut s = state.status.lock().unwrap();
s.area_id = ai.id.clone();
s.area_name = area_name;
s.area_level = ai.level;
s.area_seed = ai.seed.clone();
s.act = act;
}
emit_status(app);
// Campaign timer: auto-start / advance acts / finish on area change.
let timer_enabled = state.config.lock().unwrap().timer_enabled;
let timer_changed = {
let mut t = state.timer.lock().unwrap();
t.on_area(&ai.id, act, timer_enabled, crate::now_string)
};
if timer_changed {
crate::persist_timer(app);
let t = state.timer.lock().unwrap().clone();
let _ = app.emit("timer://update", t);
}
// Auto-advance the leveling guide when entering a step's target area.
let new_step = {
let guide = state.guide.lock().unwrap();
let cur = state.config.lock().unwrap().current_step;
leveltracker::step_for_area(&guide.steps, &ai.id, cur)
.map(|i| (i + 1).min(guide.steps.len().saturating_sub(1)))
};
if let Some(ns) = new_step {
let changed = {
let mut cfg = state.config.lock().unwrap();
if cfg.current_step != ns {
cfg.current_step = ns;
cfg.save();
true
} else {
false
}
};
if changed {
let _ = app.emit("tracker://step", ns);
}
}
}
}
fn emit_status(app: &AppHandle) {
let state = app.state::<AppState>();
let status = state.status.lock().unwrap().clone();
let _ = app.emit("status://update", status);
}
/// Read the tail of the file once to establish the current character/area.
fn initial_scan(app: &AppHandle, file: &mut File) {
let len = file.metadata().map(|m| m.len()).unwrap_or(0);
let start = len.saturating_sub(512 * 1024);
if file.seek(SeekFrom::Start(start)).is_err() {
return;
}
let mut buf = String::new();
if file.read_to_string(&mut buf).is_err() {
// fall through; non-UTF8 bytes shouldn't happen for this log
}
let mut last_level: Option<String> = None;
let mut last_area: Option<String> = None;
for line in buf.lines() {
if line.contains("is now level") {
last_level = Some(line.to_string());
}
if line.contains("Generating level ") {
last_area = Some(line.to_string());
}
}
if let Some(l) = last_level {
handle_line(app, &l);
}
if let Some(a) = last_area {
handle_line(app, &a);
}
let _ = file.seek(SeekFrom::End(0));
}
/// Spawn the background thread that tails the configured log file and the
/// active-window poller that controls overlay visibility.
pub fn spawn(app: AppHandle) {
std::thread::spawn(move || {
let mut file: Option<File> = None;
let mut pos: u64 = 0;
let mut current_path: Option<String> = None;
let mut leftover = String::new();
let mut last_tick = std::time::Instant::now();
let mut last_save = std::time::Instant::now();
let mut reassert_ticks: u8 = 0;
// AFK detection: poll the mouse position; if it hasn't moved for the
// configured delay, the timer auto-pauses.
let mut last_mouse: Option<(i32, i32)> = None;
let mut last_move = std::time::Instant::now();
let mut mouse_ticks: u8 = 0;
loop {
// (Re)open the file if the configured path changed or it appeared.
let want_path = {
let state = app.state::<AppState>();
let cfg = state.config.lock().unwrap();
cfg.log_path.clone()
};
if want_path != current_path {
current_path = want_path.clone();
file = None;
leftover.clear();
if let Some(p) = &want_path {
if let Ok(mut f) = File::open(p) {
initial_scan(&app, &mut f);
pos = f.metadata().map(|m| m.len()).unwrap_or(0);
file = Some(f);
}
}
}
// Read any newly appended bytes.
if let Some(f) = file.as_mut() {
if let Ok(meta) = f.metadata() {
let len = meta.len();
if len < pos {
// file was truncated/rotated
pos = 0;
let _ = f.seek(SeekFrom::Start(0));
leftover.clear();
}
if len > pos {
if f.seek(SeekFrom::Start(pos)).is_ok() {
let mut buf = Vec::new();
if f.read_to_end(&mut buf).is_ok() {
pos = len;
leftover.push_str(&String::from_utf8_lossy(&buf));
// process complete lines
while let Some(nl) = leftover.find('\n') {
let line: String =
leftover.drain(..=nl).collect::<String>();
let line = line.trim_end_matches(['\n', '\r']);
if !line.is_empty() {
handle_line(&app, line);
}
}
}
}
}
}
}
update_focus(&app);
// Keep the overlay above a self-raising game. Clicking inside PoE2
// (especially fullscreen / borderless under Wayland+XWayland) raises
// the game window in the stack and buries the overlay; the WM only
// honors our always-on-top at the moment it's set. Re-assert it
// every ~600ms while the game is focused so the overlay pops back.
reassert_ticks = reassert_ticks.wrapping_add(1);
if reassert_ticks >= 2 {
reassert_ticks = 0;
if app.state::<AppState>().status.lock().unwrap().game_focused {
if let Some(w) = app.get_webview_window("overlay") {
// Toggling forces the WM to restack ABOVE without
// stealing focus from the game.
let _ = w.set_always_on_top(false);
let _ = w.set_always_on_top(true);
}
}
}
// Poll the mouse roughly once a second to track activity.
mouse_ticks = mouse_ticks.wrapping_add(1);
if mouse_ticks >= 3 {
mouse_ticks = 0;
if let Some(p) = crate::poe::mouse_position() {
if Some(p) != last_mouse {
last_mouse = Some(p);
last_move = std::time::Instant::now();
}
}
}
let afk = {
let state = app.state::<AppState>();
let focused = state.status.lock().unwrap().game_focused;
let cfg = state.config.lock().unwrap();
let idle = cfg.timer_afk_enabled
&& last_move.elapsed().as_secs() >= cfg.timer_afk_seconds as u64;
let unfocused = cfg.timer_pause_unfocused && !focused;
idle || unfocused
};
tick_timer(&app, &mut last_tick, &mut last_save, afk);
std::thread::sleep(Duration::from_millis(300));
}
});
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_area_generation() {
// real PoE2 0.5 logs capitalize the prefix ("G1_5"); we normalize it.
let line = "2026/05/29 08:26:16 789879 2caa22d2 [DEBUG Client 312] Generating level 12 area \"G1_5\" with seed 1839472";
let a = parse_area_line(line).expect("should parse");
assert_eq!(a.id, "g1_5");
assert_eq!(a.level, 12);
assert_eq!(a.seed, "1839472");
}
#[test]
fn fixes_bugged_area_id() {
let line = "Generating level 30 area \"c_g3_16_\" with seed 42";
let a = parse_area_line(line).expect("should parse");
assert_eq!(a.id, "c_g3_16");
}
#[test]
fn parses_level_up() {
let line = "2024/12/06 21:05:00 123 abc [INFO Client 19340] : MyChar (Sorceress) is now level 2";
let c = parse_level_line(line).expect("should parse");
assert_eq!(c.character, "MyChar");
assert_eq!(c.class, "Sorceress");
assert_eq!(c.level, 2);
}
#[test]
fn ignores_unrelated_lines() {
assert!(parse_area_line("just some text").is_none());
assert!(parse_level_line("connected to instance").is_none());
}
}
/// Accumulate elapsed time into the campaign timer and emit updates.
fn tick_timer(
app: &AppHandle,
last_tick: &mut std::time::Instant,
last_save: &mut std::time::Instant,
afk: bool,
) {
let now = std::time::Instant::now();
let delta = now.duration_since(*last_tick).as_secs_f64();
*last_tick = now;
let state = app.state::<AppState>();
let (enabled, pause_in_town) = {
let cfg = state.config.lock().unwrap();
(cfg.timer_enabled, cfg.timer_pause_in_town)
};
if !enabled {
return;
}
let area_id = state.status.lock().unwrap().area_id.clone();
let changed = {
let mut t = state.timer.lock().unwrap();
t.tick(delta, &area_id, pause_in_town, afk)
};
if changed {
let t = state.timer.lock().unwrap().clone();
let _ = app.emit("timer://update", t);
// Persist roughly every 15s as a crash backup.
if now.duration_since(*last_save).as_secs() >= 15 {
*last_save = now;
crate::persist_timer(app);
}
}
}
/// Detect game focus and show/hide the overlay accordingly.
fn update_focus(app: &AppHandle) {
let (match_str, only_when_focused) = {
let state = app.state::<AppState>();
let cfg = state.config.lock().unwrap();
(cfg.poe_window_match.clone(), cfg.overlay_only_when_focused)
};
let title = crate::poe::active_window_title().unwrap_or_default();
let focused = title.contains(&match_str);
let changed = {
let state = app.state::<AppState>();
let mut s = state.status.lock().unwrap();
let c = s.game_focused != focused;
s.game_focused = focused;
c
};
if changed {
let _ = app.emit("focus://update", focused);
}
let _ = only_when_focused; // visibility is decided in the overlay frontend
}

6
src-tauri/src/main.rs Normal file
View File

@ -0,0 +1,6 @@
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
exile_ui_lib::run()
}

123
src-tauri/src/poe.rs Normal file
View File

@ -0,0 +1,123 @@
use std::path::PathBuf;
use std::process::Command;
/// Candidate relative paths to the client log under a game install directory.
const LOG_RELATIVE: &[&str] = &["logs/Client.txt", "logs/client.txt"];
/// Common install roots (relative to $HOME) where Path of Exile 2 may live.
const GAME_ROOTS: &[&str] = &[
".local/share/Steam/steamapps/common/Path of Exile 2",
".steam/steam/steamapps/common/Path of Exile 2",
".steam/root/steamapps/common/Path of Exile 2",
".var/app/com.valvesoftware.Steam/.local/share/Steam/steamapps/common/Path of Exile 2",
"Games/Path of Exile 2",
// PoE1 fallbacks
".local/share/Steam/steamapps/common/Path of Exile",
".steam/steam/steamapps/common/Path of Exile",
];
/// Try to locate existing Client.txt files in well-known locations.
pub fn detect_log_paths() -> Vec<String> {
let mut found = Vec::new();
let home = match dirs::home_dir() {
Some(h) => h,
None => return found,
};
// 1) Hard-coded common roots.
for root in GAME_ROOTS {
for rel in LOG_RELATIVE {
let mut p = home.join(root);
p.push(rel);
if p.is_file() {
found.push(p.to_string_lossy().to_string());
}
}
}
// 2) Parse Steam's libraryfolders.vdf for additional library locations.
for vdf in steam_library_paths(&home) {
for sub in ["Path of Exile 2", "Path of Exile"] {
for rel in LOG_RELATIVE {
let mut p = vdf.join("steamapps/common").join(sub);
p.push(rel);
if p.is_file() {
let s = p.to_string_lossy().to_string();
if !found.contains(&s) {
found.push(s);
}
}
}
}
}
found
}
fn steam_library_paths(home: &PathBuf) -> Vec<PathBuf> {
let mut libs = Vec::new();
let candidates = [
".local/share/Steam/steamapps/libraryfolders.vdf",
".steam/steam/steamapps/libraryfolders.vdf",
];
for c in candidates {
let p = home.join(c);
if let Ok(text) = std::fs::read_to_string(&p) {
// crude VDF scan: capture every "path" "…" value
for line in text.lines() {
let line = line.trim();
if line.starts_with("\"path\"") {
if let Some(start) = line[6..].find('"') {
let rest = &line[6 + start + 1..];
if let Some(end) = rest.find('"') {
libs.push(PathBuf::from(&rest[..end]));
}
}
}
}
}
}
libs
}
/// Current global mouse pointer position (X11/XWayland), used for AFK detection.
/// XScreenSaver idle isn't available under XWayland, so we poll the pointer.
pub fn mouse_position() -> Option<(i32, i32)> {
let out = Command::new("xdotool")
.arg("getmouselocation")
.output()
.ok()?;
if !out.status.success() {
return None;
}
let s = String::from_utf8_lossy(&out.stdout);
// format: "x:3814 y:8 screen:0 window:..."
let mut x = None;
let mut y = None;
for tok in s.split_whitespace() {
if let Some(v) = tok.strip_prefix("x:") {
x = v.parse().ok();
} else if let Some(v) = tok.strip_prefix("y:") {
y = v.parse().ok();
}
}
Some((x?, y?))
}
/// Returns the title of the currently focused X11 window, if obtainable.
pub fn active_window_title() -> Option<String> {
let out = Command::new("xdotool")
.args(["getactivewindow", "getwindowname"])
.output()
.ok()?;
if out.status.success() {
let s = String::from_utf8_lossy(&out.stdout).trim().to_string();
if s.is_empty() {
None
} else {
Some(s)
}
} else {
None
}
}

232
src-tauri/src/timer.rs Normal file
View File

@ -0,0 +1,232 @@
use serde::{Deserialize, Serialize};
/// Campaign speedrun timer: tracks total time and per-act splits, mirroring the
/// behavior of the original tool's leveling-tracker timer.
#[derive(Serialize, Deserialize, Clone, Debug)]
#[serde(default)]
pub struct RunTimer {
/// A run is in progress (started, not reset).
pub active: bool,
/// The run reached the endgame and stopped accumulating.
pub finished: bool,
/// User-requested pause (distinct from automatic town/hideout pause).
pub manual_pause: bool,
/// Whether the timer is currently auto-paused (town/hideout) — derived, but
/// stored so the UI can show why it's paused.
pub auto_paused: bool,
/// Whether the timer is currently auto-paused due to inactivity (AFK).
pub afk_paused: bool,
/// Seconds accumulated in completed acts.
pub total_seconds: f64,
/// Seconds accumulated in the current act.
pub act_seconds: f64,
/// 1-based current act.
pub current_act: usize,
/// Per-act splits, indexed by act number (0 unused).
pub splits: Vec<f64>,
/// Human label for the run (start date/time).
pub run_name: Option<String>,
}
impl Default for RunTimer {
fn default() -> Self {
RunTimer {
active: false,
finished: false,
manual_pause: false,
auto_paused: false,
afk_paused: false,
total_seconds: 0.0,
act_seconds: 0.0,
current_act: 1,
splits: vec![0.0; 16],
run_name: None,
}
}
}
/// An area id that should auto-pause the timer (towns, hideouts, login).
pub fn is_safe_zone(area_id: &str) -> bool {
let a = area_id.to_lowercase();
a.is_empty() || a == "login" || a.contains("town") || a.contains("hideout")
}
/// Detect the campaign starting zone (to auto-start a run).
fn is_campaign_start(area_id: &str) -> bool {
let a = area_id.to_lowercase();
a == "g1_1" || a == "1_1_1"
}
fn is_endgame(area_id: &str) -> bool {
area_id.to_lowercase().contains("endgame")
}
impl RunTimer {
fn ensure_act(&mut self, act: usize) {
if self.splits.len() <= act {
self.splits.resize(act + 1, 0.0);
}
}
/// Begin a fresh run.
pub fn start(&mut self, act: usize, name: String) {
*self = RunTimer::default();
self.active = true;
self.current_act = act.max(1);
self.run_name = Some(name);
}
pub fn reset(&mut self) {
*self = RunTimer::default();
}
pub fn toggle_pause(&mut self) {
if self.active && !self.finished {
self.manual_pause = !self.manual_pause;
}
}
/// Advance the elapsed time by `delta` seconds, honoring pause rules.
/// `afk` is true when no input was detected for the configured delay.
/// Returns true if the UI should be refreshed (time advanced, or a
/// pause state just changed).
pub fn tick(&mut self, delta: f64, area_id: &str, pause_in_town: bool, afk: bool) -> bool {
if !self.active || self.finished || self.manual_pause {
let changed = self.auto_paused || self.afk_paused;
self.auto_paused = false;
self.afk_paused = false;
return changed;
}
// AFK takes precedence over town pause for the displayed reason.
if afk {
let was = self.afk_paused;
self.afk_paused = true;
self.auto_paused = false;
return !was;
}
let was_afk = self.afk_paused;
self.afk_paused = false;
let safe = pause_in_town && is_safe_zone(area_id);
let was = self.auto_paused;
self.auto_paused = safe;
if safe {
// Emit once on entering the paused state, then stay quiet.
return !was || was_afk;
}
self.act_seconds += delta;
true
}
/// React to an area change. May auto-start a run, advance acts, or finish.
/// `enabled` is the timer feature toggle. Returns true if state changed.
pub fn on_area(&mut self, area_id: &str, act: usize, enabled: bool, start_name: impl Fn() -> String) -> bool {
if !enabled {
return false;
}
let mut changed = false;
// Auto-start when entering the campaign start with no active run.
if !self.active && is_campaign_start(area_id) {
self.start(act.max(1), start_name());
return true;
}
if !self.active || self.finished {
return changed;
}
// Endgame reached: bank the final act and stop.
if is_endgame(area_id) {
self.ensure_act(self.current_act);
self.splits[self.current_act] = self.act_seconds;
self.total_seconds += self.act_seconds;
self.act_seconds = 0.0;
self.finished = true;
return true;
}
// Advanced to a later act: bank the current act's split.
if act > self.current_act {
self.ensure_act(self.current_act);
self.splits[self.current_act] = self.act_seconds;
self.total_seconds += self.act_seconds;
self.act_seconds = 0.0;
self.current_act = act;
changed = true;
}
changed
}
}
#[cfg(test)]
mod tests {
use super::*;
fn name() -> String {
"run".to_string()
}
#[test]
fn auto_starts_at_campaign_start() {
let mut t = RunTimer::default();
assert!(t.on_area("g1_1", 1, true, name));
assert!(t.active);
assert_eq!(t.current_act, 1);
}
#[test]
fn does_not_start_when_disabled() {
let mut t = RunTimer::default();
assert!(!t.on_area("g1_1", 1, false, name));
assert!(!t.active);
}
#[test]
fn ticks_and_pauses_in_town() {
let mut t = RunTimer::default();
t.start(1, name());
t.tick(5.0, "g1_5", true, false);
assert!((t.act_seconds - 5.0).abs() < 1e-6);
// entering a town pauses accumulation
t.tick(5.0, "g1_town", true, false);
assert!((t.act_seconds - 5.0).abs() < 1e-6);
assert!(t.auto_paused);
}
#[test]
fn afk_pauses_accumulation() {
let mut t = RunTimer::default();
t.start(1, name());
t.tick(5.0, "g1_5", true, false);
assert!((t.act_seconds - 5.0).abs() < 1e-6);
// afk: time should not accumulate
t.tick(5.0, "g1_5", true, true);
assert!((t.act_seconds - 5.0).abs() < 1e-6);
assert!(t.afk_paused);
// resume on activity
t.tick(5.0, "g1_5", true, false);
assert!((t.act_seconds - 10.0).abs() < 1e-6);
assert!(!t.afk_paused);
}
#[test]
fn banks_split_on_act_change() {
let mut t = RunTimer::default();
t.start(1, name());
t.tick(10.0, "g1_5", true, false);
t.on_area("g2_1", 2, true, name);
assert_eq!(t.current_act, 2);
assert!((t.splits[1] - 10.0).abs() < 1e-6);
assert!((t.total_seconds - 10.0).abs() < 1e-6);
assert_eq!(t.act_seconds, 0.0);
}
#[test]
fn finishes_at_endgame() {
let mut t = RunTimer::default();
t.start(3, name());
t.tick(7.0, "g3_1", true, false);
t.on_area("g_endgame_town", 8, true, name);
assert!(t.finished);
assert!(!t.tick(5.0, "g_endgame_town", true, false)); // no accumulation after finish
}
}

36
src-tauri/tauri.conf.json Normal file
View File

@ -0,0 +1,36 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Exile UI",
"version": "0.1.0",
"identifier": "com.exileui.app",
"build": {
"beforeDevCommand": "npm run dev",
"devUrl": "http://localhost:1420",
"beforeBuildCommand": "npm run build",
"frontendDist": "../build"
},
"app": {
"windows": [
{
"label": "main",
"title": "Exile UI",
"width": 820,
"height": 740
}
],
"security": {
"csp": null
}
},
"bundle": {
"active": true,
"targets": ["appimage", "deb"],
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
]
}
}