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

10
.gitignore vendored Normal file
View File

@ -0,0 +1,10 @@
.DS_Store
node_modules
/build
/.svelte-kit
/package
.env
.env.*
!.env.example
vite.config.js.timestamp-*
vite.config.ts.timestamp-*

7
.vscode/extensions.json vendored Normal file
View File

@ -0,0 +1,7 @@
{
"recommendations": [
"svelte.svelte-vscode",
"tauri-apps.tauri-vscode",
"rust-lang.rust-analyzer"
]
}

3
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,3 @@
{
"svelte.enable-ts-plugin": true
}

View File

@ -20,9 +20,12 @@ configurer. Pour les sessions X11 c'est sans effet.
> position/taille forcées, *ignorer la barre des tâches*. Jouer en **plein écran
> fenêtré** (borderless).
## Périmètre actuel (v1)
## Fonctionnalités
Socle + **Act-Tracker / leveling** :
Socle + **Act-Tracker / leveling**, **timer de run**, **profils de personnage**
et **zone-layout viewer**.
### Act-Tracker / leveling
- Lecture en continu du `Client.txt` du jeu (détection de zone et de niveau via
les lignes `Generating level … area "…"` et `… is now level N`).
@ -36,7 +39,31 @@ Socle + **Act-Tracker / leveling** :
- **Hotkeys globaux** configurables (suivant / précédent / afficher-masquer).
- Branche **league-start** vs non-league-start du guide.
- L'overlay n'apparaît que lorsque le jeu est au premier plan (optionnel).
- Réglages persistés dans `~/.config/exile-ui/config.json`.
### Timer de run
- Chronomètre de speedrun campagne : temps total + **splits par acte**, calqué
sur le timer de l'outil d'origine.
- **Pause automatique** en ville/hideout et sur **inactivité (AFK)** — la
détection AFK se fait par polling de la souris via `xdotool` (les API d'idle
sont absentes sous Wayland/XWayland).
- Pause manuelle distincte de la pause automatique.
### Profils de personnage
- Profils créés manuellement, **liés à un personnage in-game par son nom**
(reconnu dans le log) — pas d'API GGG (OAuth réservé).
- Chaque profil garde sa **propre progression**, sa branche de guide et son
**timer** (avec splits par acte).
### Zone-layout viewer
- Sélecteur de layouts de zone interactif, **porté de l'`act-decoder`** AHK :
arbre de décision sur les images groupées par `areaID`, qu'on affine
manuellement selon ce qu'on voit à l'écran (aucune capture d'écran — ToS).
- Images de layouts PoE2 embarquées dans `static/layouts/`.
Réglages et profils persistés dans `~/.config/exile-ui/config.json`.
## Développement
@ -59,19 +86,24 @@ npm run tauri build # produit l'AppImage + .deb dans src-tauri/target/release
src/ frontend (SvelteKit, SPA)
lib/api.ts pont commandes/events Tauri
lib/markup.ts parseur du langage de balisage du guide → HTML
lib/StepView.svelte rendu d'une étape du guide (lignes + optionnels)
lib/Overlay.svelte fenêtre overlay (label "overlay")
lib/Settings.svelte fenêtre principale / réglages (label "main")
lib/Settings.svelte fenêtre principale / réglages / profils (label "main")
lib/Layouts.svelte fenêtre du zone-layout viewer (label "layout")
lib/layouts.ts arbre de décision des layouts (manifest areaID → chemins)
src-tauri/src/
lib.rs état partagé, commandes, fenêtres, hotkeys
logwatch.rs tail du Client.txt → parsing → events + progression
leveltracker.rs chargement du guide/zones, logique de progression
timer.rs timer de run (total + splits par acte, pauses auto/AFK)
poe.rs détection du log + fenêtre active (X11/xdotool)
config.rs persistance de la configuration
src-tauri/data/ données PoE2 embarquées (guide, zones, gemmes)
config.rs persistance config + profils de personnage
src-tauri/data/ données PoE2 embarquées (guide2, areas2, gems2)
static/layouts/ images de layouts de zone (zone-layout viewer)
```
Les deux fenêtres chargent le même bundle SPA ; la vue est choisie selon le
label de la fenêtre (`overlay` vs `main`).
Les fenêtres chargent le même bundle SPA ; la vue est choisie selon le label de
la fenêtre (`overlay`, `main`, `layout`).
## Crédits

1938
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

29
package.json Normal file
View File

@ -0,0 +1,29 @@
{
"name": "exile-ui",
"version": "0.1.0",
"description": "",
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"tauri": "tauri"
},
"license": "MIT",
"dependencies": {
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-opener": "^2"
},
"devDependencies": {
"@sveltejs/adapter-static": "^3.0.6",
"@sveltejs/kit": "^2.9.0",
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"typescript": "~5.6.2",
"vite": "^6.0.3",
"@tauri-apps/cli": "^2"
}
}

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"
]
}
}

24
src/app.css Normal file
View File

@ -0,0 +1,24 @@
:root {
color-scheme: dark;
--bg: #14110d;
--panel: #1d1812;
--border: #3a2f22;
--text: #d8cdbb;
--muted: #8c8170;
--accent: #c8a24a;
--accent2: #66b2ff;
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
padding: 0;
background: transparent;
color: var(--text);
font-family: "Fontin SmallCaps", "Segoe UI", system-ui, sans-serif;
-webkit-font-smoothing: antialiased;
}

13
src/app.html Normal file
View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Exile UI</title>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

374
src/lib/Layouts.svelte Normal file
View File

@ -0,0 +1,374 @@
<script lang="ts">
import { onMount } from "svelte";
import {
getStatus,
getConfig,
toggleLayout,
setLayoutSize,
startLayoutDrag,
endLayoutDrag,
type Status,
type Config,
} from "$lib/api";
import {
loadManifest,
childrenOf,
imageUrl,
isDeadEnd,
rotationBlocked,
type LayoutManifest,
} from "$lib/layouts";
let manifest = $state<LayoutManifest>({});
let status = $state<Status | null>(null);
let config = $state<Config | null>(null);
// Decision-tree cursor + per-zone view state (reset whenever the zone changes).
let path = $state("");
let excluded = $state<string[]>([]);
let rot = $state(0); // degrees, multiples of 90
let flipH = $state(false);
let flipV = $state(false);
let lastArea = "";
function syncArea(s: Status | null) {
const id = s?.area_id ?? "";
if (id !== lastArea) {
lastArea = id;
path = "";
excluded = [];
rot = 0;
flipH = false;
flipV = false;
}
}
const areaId = $derived(status?.area_id ?? "");
const areaName = $derived(status?.area_name || areaId || "—");
const paths = $derived(manifest[areaId] ?? []);
const hasZone = $derived(paths.length > 0);
const candidates = $derived(
childrenOf(paths, path).filter((p) => !excluded.includes(p)),
);
const resolved = $derived(path !== "" && childrenOf(paths, path).length === 0);
const blocked = $derived(rotationBlocked(areaId));
const transform = $derived(
`rotate(${rot}deg) scaleX(${flipH ? -1 : 1}) scaleY(${flipV ? -1 : 1})`,
);
const crumbs = $derived(path === "" ? [] : path.split("_"));
function pick(p: string) {
path = p;
}
function back() {
const seg = path.split("_");
seg.pop();
path = seg.join("_");
}
function exclude(p: string) {
if (!excluded.includes(p)) excluded = [...excluded, p];
}
function rotate(dir: number) {
rot = (((rot + dir * 90) % 360) + 360) % 360;
}
function resetOrientation() {
rot = 0;
flipH = false;
flipV = false;
}
// Keep the OS window sized to the rendered content.
let boxW = $state(0);
let boxH = $state(0);
$effect(() => {
if (boxW > 0 && boxH > 0) setLayoutSize(Math.ceil(boxW), Math.ceil(boxH));
});
onMount(() => {
let alive = true;
// The layout window is created hidden, and its `listen` event subscription
// never resolves in that state (unlike the always-visible overlay), so live
// status events don't arrive here. The `get_status` command does work, so we
// poll it and only react when the area actually changes (cheap; no flicker).
(async () => {
manifest = await loadManifest();
config = await getConfig();
while (alive) {
const s = await getStatus();
if (!status || s.area_id !== status.area_id) {
status = s;
syncArea(s);
}
await new Promise((r) => setTimeout(r, 500));
}
})();
return () => {
alive = false;
};
});
const imgSize = $derived(config?.layout_size ?? 360);
// Drag the window by its title bar. WebKitGTK's `data-tauri-drag-region` and
// Tauri's `setPosition` don't move this window under KWin, so the Rust side
// follows the mouse with xdotool between start/end (driven by pointerdown/up,
// which fire reliably — only pointermove is flaky here).
function startDrag(e: PointerEvent) {
if (e.button !== 0 || (e.target as HTMLElement).closest("button")) return;
e.preventDefault();
startLayoutDrag();
window.addEventListener("pointerup", endDrag, { once: true });
window.addEventListener("blur", endDrag, { once: true });
}
function endDrag() {
window.removeEventListener("pointerup", endDrag);
window.removeEventListener("blur", endDrag);
endLayoutDrag();
}
</script>
<div class="viewer" bind:clientWidth={boxW} bind:clientHeight={boxH}>
<div class="bar" role="toolbar" tabindex="-1" onpointerdown={startDrag}>
<span class="title">🗺 {areaName}</span>
<button class="close" title="Fermer (hotkey)" onclick={() => toggleLayout()}>✕</button>
</div>
{#if !hasZone}
<div class="empty">Pas de layout répertorié pour cette zone.</div>
{:else}
<!-- Main image: the deepest committed pick, with the zone's orientation. -->
<div class="stage" style="width:{imgSize}px;height:{imgSize}px;">
{#if path !== ""}
<img src={imageUrl(areaId, path)} alt={path} style="transform:{transform};" />
{:else}
<div class="hint">Clique le layout qui correspond à ce que tu vois en jeu.</div>
{/if}
</div>
<!-- Orientation controls (the whole zone is placed at a random rotation). -->
{#if !blocked}
<div class="ctrls">
<button title="Pivoter à gauche" onclick={() => rotate(-1)}>⟲</button>
<button title="Pivoter à droite" onclick={() => rotate(1)}>⟳</button>
<button class:on={flipH} title="Miroir horizontal" onclick={() => (flipH = !flipH)}>⇋</button>
<button class:on={flipV} title="Miroir vertical" onclick={() => (flipV = !flipV)}>⇅</button>
<button title="Réinitialiser l'orientation" onclick={resetOrientation}>↺</button>
<span class="deg">{rot}°</span>
</div>
{/if}
<!-- Breadcrumb / back navigation. -->
{#if path !== ""}
<div class="crumbs">
<button class="link" onclick={() => (path = "")}>début</button>
{#each crumbs as seg, i}
<span class="sep"></span>
<button
class="link"
onclick={() => (path = crumbs.slice(0, i + 1).join("_"))}
>{seg === "x" ? "?" : seg}</button>
{/each}
<button class="back" onclick={back}> retour</button>
</div>
{/if}
<!-- Candidate thumbnails at the current branch. -->
{#if candidates.length}
<div class="label">{resolved ? "" : path === "" ? "Layouts possibles :" : "Affine :"}</div>
<div class="thumbs">
{#each candidates as c}
<button
class="thumb"
class:dead={isDeadEnd(c)}
title={isDeadEnd(c) ? "Aucun de ceux-ci / non répertorié" : c}
onclick={() => pick(c)}
oncontextmenu={(e) => {
e.preventDefault();
exclude(c);
}}
>
<img src={imageUrl(areaId, c)} alt={c} style="transform:{transform};" />
<span class="cap">{isDeadEnd(c) ? "?" : c.split("_").slice(-1)[0]}</span>
</button>
{/each}
</div>
<div class="tip">clic = choisir · clic droit = exclure</div>
{:else if resolved}
<div class="label done">Layout identifié.</div>
{/if}
{/if}
</div>
<style>
.viewer {
display: inline-flex;
flex-direction: column;
background: #0f0c08;
border: 1px solid var(--border);
overflow: hidden;
width: max-content;
}
.bar {
display: flex;
align-items: center;
gap: 8px;
padding: 3px 6px 3px 10px;
background: var(--accent);
color: #14110d;
cursor: move;
user-select: none;
}
.bar .title {
font-weight: 700;
font-size: 0.9em;
text-transform: capitalize;
flex: 1;
}
.bar .close {
background: transparent;
border: none;
color: #14110d;
cursor: pointer;
font-weight: 700;
padding: 0 4px;
}
.empty {
padding: 18px;
color: var(--muted);
font-size: 0.9em;
text-align: center;
}
.stage {
display: flex;
align-items: center;
justify-content: center;
margin: 8px auto 4px;
background: #000;
border: 1px solid var(--border);
overflow: hidden;
}
.stage img {
max-width: 100%;
max-height: 100%;
transition: transform 0.12s ease;
}
.stage .hint {
color: var(--muted);
font-size: 0.85em;
text-align: center;
padding: 12px;
}
.ctrls {
display: flex;
gap: 4px;
align-items: center;
justify-content: center;
padding: 2px 6px;
}
.ctrls button {
background: #2a2218;
color: var(--text);
border: 1px solid var(--border);
border-radius: 4px;
padding: 2px 8px;
cursor: pointer;
font-size: 1em;
line-height: 1.2;
}
.ctrls button:hover {
border-color: var(--accent);
}
.ctrls button.on {
border-color: var(--accent);
background: rgba(200, 162, 74, 0.18);
}
.ctrls .deg {
color: var(--muted);
font-size: 0.8em;
margin-left: 4px;
}
.crumbs {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 4px;
padding: 2px 8px;
font-size: 0.8em;
}
.crumbs .sep {
color: var(--muted);
}
.crumbs .link {
background: transparent;
border: none;
color: var(--accent2);
cursor: pointer;
padding: 0 2px;
}
.crumbs .back {
margin-left: auto;
background: #2a2218;
color: var(--text);
border: 1px solid var(--border);
border-radius: 4px;
padding: 1px 8px;
cursor: pointer;
}
.label {
padding: 2px 10px 0;
font-size: 0.82em;
color: var(--muted);
}
.label.done {
color: #7cfc00;
padding-bottom: 8px;
}
.thumbs {
display: flex;
flex-wrap: wrap;
gap: 6px;
padding: 4px 8px;
justify-content: center;
max-width: 460px;
}
.thumb {
position: relative;
width: 92px;
height: 92px;
padding: 0;
background: #000;
border: 1px solid var(--border);
border-radius: 4px;
cursor: pointer;
overflow: hidden;
}
.thumb:hover {
border-color: var(--accent);
}
.thumb.dead {
opacity: 0.6;
border-style: dashed;
}
.thumb img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.12s ease;
}
.thumb .cap {
position: absolute;
bottom: 0;
right: 0;
background: rgba(0, 0, 0, 0.7);
color: var(--accent);
font-size: 0.75em;
padding: 0 4px;
border-top-left-radius: 4px;
}
.tip {
padding: 0 8px 8px;
font-size: 0.72em;
color: var(--muted);
text-align: center;
}
</style>

239
src/lib/Overlay.svelte Normal file
View File

@ -0,0 +1,239 @@
<script lang="ts">
import { onMount } from "svelte";
import {
getGuide,
getConfig,
getStatus,
onStep,
onStatus,
onConfig,
onFocus,
onOverlayToggle,
onOverlayLocked,
onGuideReload,
getTimer,
onTimer,
fmtTime,
setOverlayHeight,
restoreOverlayPosition,
type GuideData,
type Config,
type Status,
type RunTimer,
} from "$lib/api";
import StepView from "$lib/StepView.svelte";
let guide = $state<GuideData | null>(null);
let config = $state<Config | null>(null);
let status = $state<Status | null>(null);
let timer = $state<RunTimer | null>(null);
let focused = $state(false);
let userHidden = $state(false);
let locked = $state(true);
// Measured height of the rendered overlay box; drives the OS window height so
// the window is only as tall as its content (else the WM clamps it on-screen
// and the overlay jumps to the middle when placed/restored near a screen edge).
let boxHeight = $state(0);
// Whether the overlay content should be shown. When unlocked (editing the
// position) it is always shown; otherwise it follows the focus/toggle rules.
const visible = $derived.by(() => {
if (!config) return false;
if (!locked) return true;
if (userHidden) return false;
return config.overlay_only_when_focused ? focused : true;
});
const current = $derived(config?.current_step ?? 0);
const currentStep = $derived(guide?.steps[current] ?? null);
const lookahead = $derived.by(() => {
if (!guide || !config) return [];
const n = config.lookahead;
return guide.steps.slice(current + 1, current + 1 + n);
});
// Recommended level for the area this step heads to.
const recommendation = $derived.by(() => {
if (!guide || !currentStep?.target_area) return null;
return guide.areas[currentStep.target_area]?.recommendation ?? null;
});
onMount(() => {
const unlisten: Array<() => void> = [];
(async () => {
guide = await getGuide();
config = await getConfig();
status = await getStatus();
timer = await getTimer();
focused = status.game_focused;
unlisten.push(
await onStep((i) => {
if (config) config = { ...config, current_step: i };
}),
);
unlisten.push(await onStatus((s) => (status = s)));
unlisten.push(await onConfig((c) => (config = c)));
unlisten.push(await onFocus((f) => (focused = f)));
unlisten.push(await onOverlayToggle(() => (userHidden = !userHidden)));
unlisten.push(await onOverlayLocked((l) => (locked = l)));
unlisten.push(await onGuideReload(async () => (guide = await getGuide())));
unlisten.push(await onTimer((t) => (timer = t)));
})();
return () => unlisten.forEach((u) => u());
});
// Keep the OS window height matched to the rendered content.
let positionRestored = false;
$effect(() => {
if (visible && boxHeight > 0) {
setOverlayHeight(Math.ceil(boxHeight) + 2);
// Once the window is shrunk to content, restore its saved position (the WM
// would clamp a still-tall window on-screen if we did this any earlier).
if (!positionRestored) {
positionRestored = true;
setTimeout(() => restoreOverlayPosition(), 150);
}
}
});
</script>
{#if guide && config && visible}
<div class="overlay" class:editing={!locked} style="font-size:{config.overlay_font_size}px;" bind:clientHeight={boxHeight}>
{#if !locked}
<div class="dragbar" data-tauri-drag-region>⠿ déplacer · reverrouille dans les réglages pour sauver</div>
{/if}
<div class="header">
<span class="act">Act {currentStep ? currentStep.section + 1 : "?"}</span>
{#if status}
<span class="lvl">Lv {status.level || "?"}</span>
{#if status.area_name}<span class="area">{status.area_name}</span>{/if}
{/if}
{#if recommendation && config.show_recommendation}
<span class="rec">rec. {recommendation}</span>
{/if}
<span class="prog">{current + 1}/{guide.steps.length}</span>
</div>
{#if config.timer_enabled && timer && timer.active}
<div class="timer" class:paused={timer.manual_pause || timer.auto_paused || timer.afk_paused}>
<span class="t-total">{fmtTime(timer.total_seconds + timer.act_seconds)}</span>
<span class="t-act">acte {timer.current_act} · {fmtTime(timer.act_seconds)}</span>
{#if timer.finished}
<span class="t-flag done">terminé</span>
{:else if timer.manual_pause}
<span class="t-flag">pause</span>
{:else if timer.afk_paused}
<span class="t-flag">{focused ? "AFK" : "fenêtre"}</span>
{:else if timer.auto_paused}
<span class="t-flag">ville</span>
{/if}
</div>
{#if timer.splits.some((s) => s > 0)}
<div class="splits">
{#each timer.splits as s, i}
{#if s > 0}<span class="split">A{i} {fmtTime(s)}</span>{/if}
{/each}
</div>
{/if}
{/if}
{#if currentStep}
<StepView step={currentStep} areas={guide.areas} current={true} showOptionals={config.show_optionals} />
{/if}
{#each lookahead as step}
<StepView {step} areas={guide.areas} showOptionals={config.show_optionals} />
{/each}
</div>
{/if}
<style>
.overlay {
background: rgba(15, 12, 8, 0.82);
border: 1px solid var(--border);
border-radius: 6px;
overflow: hidden;
width: 100%;
backdrop-filter: blur(2px);
}
.overlay.editing {
border-color: var(--accent);
box-shadow: 0 0 0 2px rgba(200, 162, 74, 0.4);
}
.dragbar {
background: var(--accent);
color: #14110d;
font-size: 0.8em;
text-align: center;
padding: 2px;
cursor: move;
user-select: none;
}
.header {
display: flex;
gap: 8px;
align-items: center;
padding: 4px 10px;
background: rgba(0, 0, 0, 0.35);
border-bottom: 1px solid var(--border);
font-size: 0.85em;
}
.header .act {
color: var(--accent);
font-weight: 700;
}
.header .lvl {
color: #cfa;
}
.header .area {
color: var(--accent2);
text-transform: capitalize;
}
.header .rec {
color: var(--muted);
}
.header .prog {
margin-left: auto;
color: var(--muted);
}
.timer {
display: flex;
gap: 10px;
align-items: center;
padding: 2px 10px;
background: rgba(0, 0, 0, 0.25);
border-bottom: 1px solid var(--border);
font-size: 0.85em;
}
.timer .t-total {
color: var(--accent);
font-weight: 700;
}
.timer .t-act {
color: var(--text);
}
.timer.paused .t-total,
.timer.paused .t-act {
color: var(--muted);
}
.timer .t-flag {
margin-left: auto;
color: #e0c84a;
text-transform: uppercase;
font-size: 0.85em;
}
.timer .t-flag.done {
color: #7CFC00;
}
.splits {
display: flex;
flex-wrap: wrap;
gap: 6px;
padding: 2px 10px 4px;
background: rgba(0, 0, 0, 0.18);
border-bottom: 1px solid var(--border);
font-size: 0.78em;
}
.splits .split {
color: var(--muted);
}
</style>

488
src/lib/Settings.svelte Normal file
View File

@ -0,0 +1,488 @@
<script lang="ts">
import { onMount } from "svelte";
import {
getConfig,
saveConfig,
getStatus,
getGuide,
detectLogs,
stepDelta,
setOverlayLocked,
toggleOverlay,
toggleLayout,
saveOverlayGeometry,
getTimer,
timerStart,
timerPause,
timerReset,
createProfile,
deleteProfile,
selectProfile,
onStatus,
onStep,
onConfig,
onGuideReload,
onTimer,
fmtTime,
type Config,
type Status,
type GuideData,
type RunTimer,
} from "$lib/api";
let config = $state<Config | null>(null);
let status = $state<Status | null>(null);
let guide = $state<GuideData | null>(null);
let timer = $state<RunTimer | null>(null);
let detected = $state<string[]>([]);
let overlayUnlocked = $state(false);
// --- character profiles ---
let newProfileName = $state("");
let newProfileLeagueStart = $state(true);
async function refreshDetect() {
detected = await detectLogs();
}
async function addProfile() {
const name = newProfileName.trim();
if (!name) return;
await createProfile(name, newProfileLeagueStart);
newProfileName = "";
// config (profiles + active_character) is pushed back via config://update
}
async function save() {
if (config) await saveConfig($state.snapshot(config));
}
async function toggleLock() {
overlayUnlocked = !overlayUnlocked;
await setOverlayLocked(!overlayUnlocked); // locked = click-through
if (!overlayUnlocked) await saveOverlayGeometry();
if (overlayUnlocked) config = await getConfig();
}
onMount(() => {
const unlisten: Array<() => void> = [];
(async () => {
config = await getConfig();
status = await getStatus();
guide = await getGuide();
timer = await getTimer();
await refreshDetect();
unlisten.push(await onStatus((s) => (status = s)));
unlisten.push(
await onStep((i) => {
if (config) config = { ...config, current_step: i };
}),
);
unlisten.push(await onConfig((c) => (config = c)));
unlisten.push(await onGuideReload(async () => (guide = await getGuide())));
unlisten.push(await onTimer((t) => (timer = t)));
})();
return () => unlisten.forEach((u) => u());
});
</script>
<main>
<h1>Exile UI <small>· Act-Tracker (PoE2)</small></h1>
{#if status}
<section class="status">
<div><b>{status.character || "—"}</b> ({status.class || "?"}) · Lv {status.level || "?"}</div>
<div>
Zone : <span class="hl">{status.area_name || status.area_id || "—"}</span>
{#if status.area_level}· niveau zone {status.area_level}{/if}
</div>
<div class="muted">
Jeu focus : {status.game_focused ? "✔️" : "✖️"} · areaID : {status.area_id || "—"}
</div>
</section>
{/if}
{#if config && guide}
<section>
<h2>Guide</h2>
<div class="row">
<button onclick={() => stepDelta(-1)}> Précédent</button>
<span class="prog">Étape {config.current_step + 1} / {guide.steps.length}</span>
<button onclick={() => stepDelta(1)}>Suivant ▶</button>
</div>
<div class="row">
<button onclick={() => toggleOverlay()}>Afficher/Masquer overlay</button>
<button onclick={toggleLock}>
{overlayUnlocked ? "🔒 Verrouiller (click-through)" : "🔓 Déverrouiller (déplacer)"}
</button>
</div>
{#if overlayUnlocked}
<p class="muted">Overlay déverrouillé : déplace/redimensionne la fenêtre, puis reverrouille pour sauvegarder.</p>
{/if}
</section>
<section>
<h2>Profils <small>· un profil = un personnage</small></h2>
<p class="muted">
Crée un profil par personnage (le nom doit correspondre au nom du perso
en jeu). La progression du guide est mémorisée <b>par profil</b>, et le
profil devient actif tout seul quand le log détecte ce personnage.
</p>
<div class="status">
Profil actif :
<span class="hl">{config.active_character || "— aucun —"}</span>
{#if status?.character && status.character !== config.active_character}
· perso en jeu : <span class="hl">{status.character}</span>
{#if !config.profiles.some((p) => p.name === status?.character)}
<span class="muted">(pas de profil)</span>
{/if}
{/if}
</div>
{#if config.profiles.length}
<div class="char-list">
{#each config.profiles as p}
<div class="char" class:active={config.active_character === p.name}>
<button class="char-pick" onclick={() => selectProfile(p.name)}>
<b>{p.name}</b>
<span class="muted">
étape {p.current_step + 1} · {p.league_start ? "league-start" : "standard"}
</span>
</button>
<button class="del" title="Supprimer ce profil" onclick={() => deleteProfile(p.name)}>✕</button>
</div>
{/each}
</div>
{:else}
<p class="muted">Aucun profil pour l'instant.</p>
{/if}
<div class="row">
<input
type="text"
bind:value={newProfileName}
placeholder="nom du personnage"
style="flex:1"
onkeydown={(e) => e.key === "Enter" && addProfile()}
/>
<label class="ck" style="white-space:nowrap">
<input type="checkbox" bind:checked={newProfileLeagueStart} />
league-start
</label>
<button onclick={addProfile} disabled={!newProfileName.trim()}>+ Créer</button>
</div>
</section>
<section>
<h2>Layouts de zones <small>· identificateur de layout (PoE2)</small></h2>
<p class="muted">
Ouvre une fenêtre interactive affichant les layouts possibles de la zone
courante. Clique celui qui correspond à ce que tu vois pour l'affiner, et
pivote/miroir l'image pour l'aligner sur l'orientation en jeu (chaque
instance de zone est tournée aléatoirement).
</p>
<label class="ck">
<input type="checkbox" bind:checked={config.feature_layouts} onchange={save} />
Activer le viewer de layouts
</label>
{#if config.feature_layouts}
<div class="row">
<button onclick={() => toggleLayout()}>Afficher / masquer la fenêtre layouts</button>
</div>
<div class="grid">
<label>Hotkey « layouts »
<input type="text" bind:value={config.hotkey_layout} onchange={save} />
</label>
<label>Taille image (px)
<input type="number" min="160" max="800" bind:value={config.layout_size} onchange={save} />
</label>
</div>
<p class="muted">
La fenêtre est interactive (elle prend le focus). Si KWin ne la garde pas
au-dessus du jeu, ajoute une règle de fenêtre pour « Exile UI Layouts ».
</p>
{/if}
</section>
<section>
<h2>Timer de campagne</h2>
<label class="ck">
<input type="checkbox" bind:checked={config.timer_enabled} onchange={save} />
Activer le timer
</label>
<label class="ck">
<input type="checkbox" bind:checked={config.timer_pause_in_town} onchange={save} />
Mettre en pause automatiquement en ville / hideout
</label>
<label class="ck">
<input type="checkbox" bind:checked={config.timer_afk_enabled} onchange={save} />
Anti-AFK : mettre en pause si la souris ne bouge plus
</label>
{#if config.timer_afk_enabled}
<label>Délai avant pause AFK (secondes)
<input type="number" min="10" bind:value={config.timer_afk_seconds} onchange={save} />
</label>
{/if}
<label class="ck">
<input type="checkbox" bind:checked={config.timer_pause_unfocused} onchange={save} />
Mettre en pause quand PoE2 n'a pas le focus (alt-tab)
</label>
{#if config.timer_enabled}
{#if timer && timer.active}
<div class="status timer-box">
<div>
Total : <b class="hl">{fmtTime(timer.total_seconds + timer.act_seconds)}</b>
· acte {timer.current_act} : {fmtTime(timer.act_seconds)}
{#if timer.finished}<span class="flag done">terminé</span>
{:else if timer.manual_pause}<span class="flag">en pause</span>
{:else if timer.afk_paused}<span class="flag">{status?.game_focused ? "AFK" : "fenêtre (pas de focus)"}</span>
{:else if timer.auto_paused}<span class="flag">pause auto (ville)</span>{/if}
</div>
{#if timer.run_name}<div class="muted">Run : {timer.run_name}</div>{/if}
{#if timer.splits.some((s) => s > 0)}
<div class="splits">
{#each timer.splits as s, i}
{#if s > 0}<span class="split">A{i}: {fmtTime(s)}</span>{/if}
{/each}
</div>
{/if}
</div>
{:else}
<p class="muted">Aucun run en cours. Le timer démarre tout seul en entrant dans la première zone de campagne, ou via « Démarrer ».</p>
{/if}
<div class="row">
<button onclick={() => timerStart()}> Démarrer / redémarrer</button>
<button onclick={() => timerPause()} disabled={!timer || !timer.active}>
{timer && timer.manual_pause ? "Reprendre" : "⏸ Pause"}
</button>
<button onclick={() => timerReset()} disabled={!timer || !timer.active}>↺ Reset</button>
</div>
<label>Hotkey « pause timer »
<input type="text" bind:value={config.hotkey_timer_pause} onchange={save} />
</label>
{/if}
</section>
<section>
<h2>Fichier de log (Client.txt)</h2>
<div class="row">
<input
type="text"
bind:value={config.log_path}
placeholder="/chemin/vers/Path of Exile 2/logs/Client.txt"
style="flex:1"
/>
<button onclick={save}>Enregistrer</button>
</div>
{#if detected.length}
<label>Détectés :
<select onchange={(e) => { if (config) { config.log_path = (e.target as HTMLSelectElement).value; save(); } }}>
<option value="">— choisir —</option>
{#each detected as d}<option value={d}>{d}</option>{/each}
</select>
</label>
{:else}
<p class="muted">Aucun log détecté automatiquement. Renseigne le chemin manuellement.</p>
{/if}
<button onclick={refreshDetect}>Re-scanner</button>
</section>
<section>
<h2>Réglages</h2>
<label class="ck">
<input type="checkbox" bind:checked={config.overlay_only_when_focused} onchange={save} />
Afficher l'overlay uniquement quand le jeu est au premier plan
</label>
<label class="ck">
<input type="checkbox" bind:checked={config.league_start} onchange={save} />
Mode « league-start » (profil) — sinon branche non-league-start du guide
</label>
<label class="ck">
<input type="checkbox" bind:checked={config.show_optionals} onchange={save} />
Afficher le contenu optionnel (profil) — loot/quêtes/rencontres « + »
</label>
<label class="ck">
<input type="checkbox" bind:checked={config.show_recommendation} onchange={save} />
Afficher les niveaux recommandés par zone
</label>
<label>Titre fenêtre du jeu (détection focus)
<input type="text" bind:value={config.poe_window_match} onchange={save} />
</label>
<div class="grid">
<label>Largeur overlay (px)
<input type="number" bind:value={config.overlay_width} onchange={save} />
</label>
<label>Taille police (px)
<input type="number" bind:value={config.overlay_font_size} onchange={save} />
</label>
<label>Étapes à anticiper
<input type="number" min="0" max="6" bind:value={config.lookahead} onchange={save} />
</label>
</div>
<div class="grid">
<label>Hotkey « suivant »
<input type="text" bind:value={config.hotkey_next} onchange={save} />
</label>
<label>Hotkey « précédent »
<input type="text" bind:value={config.hotkey_prev} onchange={save} />
</label>
<label>Hotkey « afficher/masquer »
<input type="text" bind:value={config.hotkey_toggle} onchange={save} />
</label>
</div>
<p class="muted">Format hotkey : ex. <code>Alt+X</code>, <code>Control+Shift+G</code>, <code>F8</code>.</p>
</section>
{/if}
</main>
<style>
main {
background: var(--bg);
min-height: 100vh;
padding: 16px 22px;
max-width: 760px;
}
h1 {
margin: 0 0 12px;
font-size: 1.4em;
color: var(--accent);
}
h1 small {
color: var(--muted);
font-size: 0.6em;
}
h2 {
font-size: 1.05em;
color: var(--accent2);
border-bottom: 1px solid var(--border);
padding-bottom: 4px;
}
section {
margin-bottom: 18px;
}
.status {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 6px;
padding: 10px 14px;
line-height: 1.6;
}
.hl {
color: var(--accent2);
}
.muted {
color: var(--muted);
font-size: 0.9em;
}
.row {
display: flex;
gap: 8px;
align-items: center;
margin: 6px 0;
}
.grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
margin: 6px 0;
}
label {
display: block;
margin: 6px 0;
font-size: 0.92em;
}
label.ck {
display: flex;
gap: 6px;
align-items: center;
}
input[type="text"],
input[type="number"],
select {
width: 100%;
background: #100d09;
color: var(--text);
border: 1px solid var(--border);
border-radius: 4px;
padding: 5px 7px;
}
label.ck input {
width: auto;
}
button {
background: #2a2218;
color: var(--text);
border: 1px solid var(--border);
border-radius: 4px;
padding: 6px 12px;
cursor: pointer;
}
button:hover {
border-color: var(--accent);
}
.prog {
color: var(--muted);
}
.timer-box {
margin: 8px 0;
}
.splits {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 6px;
}
.split {
background: #100d09;
border: 1px solid var(--border);
border-radius: 4px;
padding: 1px 6px;
font-size: 0.85em;
}
.flag {
margin-left: 8px;
color: #e0c84a;
text-transform: uppercase;
font-size: 0.85em;
}
.flag.done {
color: #7cfc00;
}
button:disabled {
opacity: 0.45;
cursor: default;
}
code {
background: #100d09;
padding: 1px 4px;
border-radius: 3px;
}
.char-list {
display: flex;
flex-direction: column;
gap: 4px;
margin: 8px 0;
}
.char {
display: flex;
align-items: stretch;
gap: 4px;
}
.char.active .char-pick {
border-color: var(--accent);
background: rgba(200, 162, 74, 0.12);
}
button.char-pick {
display: flex;
justify-content: space-between;
align-items: center;
flex: 1;
text-align: left;
gap: 10px;
}
button.del {
color: #e05555;
padding: 6px 10px;
}
</style>

92
src/lib/StepView.svelte Normal file
View File

@ -0,0 +1,92 @@
<script lang="ts">
import type { Area, Step } from "./api";
import { renderLine } from "./markup";
let {
step,
areas,
current = false,
showOptionals = true,
}: {
step: Step;
areas: Record<string, Area>;
current?: boolean;
showOptionals?: boolean;
} = $props();
const lines = $derived(
step.lines
.map((l) => renderLine(l, areas))
.filter((l) => showOptionals || l.kind !== "optional"),
);
</script>
<div class="step" class:current>
{#each lines as line}
<div class="line {line.kind}" class:indent={line.indent > 0}>
{@html line.html}
</div>
{/each}
</div>
<style>
.step {
padding: 6px 10px;
border-left: 3px solid transparent;
}
.step.current {
border-left-color: var(--accent);
background: rgba(200, 162, 74, 0.08);
}
.line {
line-height: 1.35;
margin: 1px 0;
}
.line.hint {
color: var(--muted);
font-size: 0.9em;
}
.line.optional {
color: #9fb88f;
font-size: 0.95em;
}
.line.info {
color: #d8a0a0;
font-size: 0.95em;
}
.line.indent {
padding-left: 16px;
}
:global(.step .area) {
color: var(--accent2);
font-weight: 600;
text-transform: capitalize;
}
:global(.step .chip) {
display: inline-block;
padding: 0 5px;
margin: 0 1px;
border-radius: 4px;
font-size: 0.82em;
vertical-align: middle;
background: #2a2218;
border: 1px solid var(--border);
}
:global(.step .chip.quest) {
background: #2a2030;
border-color: #5a4a6a;
color: #c9a6e0;
}
:global(.step .chip.img) {
background: #23282e;
border-color: #3a4654;
color: #a9c4dd;
}
:global(.step .icon) {
height: 1.25em;
width: auto;
vertical-align: -0.25em;
margin: 0 1px;
image-rendering: -webkit-optimize-contrast;
}
</style>

140
src/lib/api.ts Normal file
View File

@ -0,0 +1,140 @@
import { invoke } from "@tauri-apps/api/core";
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
export interface Config {
log_path: string | null;
poe_window_match: string;
overlay_only_when_focused: boolean;
overlay_x: number;
overlay_y: number;
overlay_width: number;
overlay_font_size: number;
hotkey_next: string;
hotkey_prev: string;
hotkey_toggle: string;
hotkey_timer_pause: string;
hotkey_layout: string;
feature_layouts: boolean;
layout_x: number;
layout_y: number;
layout_size: number;
timer_enabled: boolean;
timer_pause_in_town: boolean;
timer_afk_enabled: boolean;
timer_afk_seconds: number;
timer_pause_unfocused: boolean;
current_step: number;
show_optionals: boolean;
league_start: boolean;
lookahead: number;
show_recommendation: boolean;
active_character: string | null;
profiles: Profile[];
}
export interface Profile {
name: string;
current_step: number;
league_start: boolean;
show_optionals: boolean;
}
export interface Status {
character: string;
class: string;
level: number;
area_id: string;
area_name: string;
area_level: number;
area_seed: string;
act: number;
game_focused: boolean;
}
export interface Area {
id: string;
name: string;
recommendation: string | null;
group: number;
}
export interface Step {
lines: string[];
section: number;
target_area: string | null;
}
export interface GuideData {
steps: Step[];
areas: Record<string, Area>;
section_count: number;
}
export interface RunTimer {
active: boolean;
finished: boolean;
manual_pause: boolean;
auto_paused: boolean;
afk_paused: boolean;
total_seconds: number;
act_seconds: number;
current_act: number;
splits: number[];
run_name: string | null;
}
export const getConfig = () => invoke<Config>("get_config");
export const saveConfig = (cfg: Config) => invoke("save_config", { new: cfg });
export const getStatus = () => invoke<Status>("get_status");
export const getGuide = () => invoke<GuideData>("get_guide");
export const getGems = () => invoke<Record<string, Record<string, number[]>>>("get_gems");
export const detectLogs = () => invoke<string[]>("detect_logs");
export const getTimer = () => invoke<RunTimer>("get_timer");
export const timerStart = () => invoke("timer_start");
export const timerPause = () => invoke("timer_pause");
export const timerReset = () => invoke("timer_reset");
export const stepDelta = (delta: number) => invoke("step_delta", { delta });
export const stepGoto = (newIndex: number) => invoke("step_goto", { new: newIndex });
export const createProfile = (name: string, leagueStart: boolean) =>
invoke("create_profile", { name, leagueStart });
export const deleteProfile = (name: string) => invoke("delete_profile", { name });
export const selectProfile = (name: string) => invoke("select_profile", { name });
export const setOverlayLocked = (locked: boolean) => invoke("set_overlay_locked", { locked });
export const toggleOverlay = () => invoke("toggle_overlay");
export const saveOverlayGeometry = () => invoke("save_overlay_geometry");
export const setOverlayHeight = (height: number) => invoke("set_overlay_height", { height });
export const restoreOverlayPosition = () => invoke("restore_overlay_position");
export const toggleLayout = () => invoke("toggle_layout");
export const setLayoutSize = (width: number, height: number) =>
invoke("set_layout_size", { width, height });
export const saveLayoutGeometry = () => invoke("save_layout_geometry");
export const startLayoutDrag = () => invoke("start_layout_drag");
export const endLayoutDrag = () => invoke("end_layout_drag");
// Event helpers ------------------------------------------------------------
export const onStatus = (cb: (s: Status) => void): Promise<UnlistenFn> =>
listen<Status>("status://update", (e) => cb(e.payload));
export const onStep = (cb: (i: number) => void): Promise<UnlistenFn> =>
listen<number>("tracker://step", (e) => cb(e.payload));
export const onConfig = (cb: (c: Config) => void): Promise<UnlistenFn> =>
listen<Config>("config://update", (e) => cb(e.payload));
export const onFocus = (cb: (f: boolean) => void): Promise<UnlistenFn> =>
listen<boolean>("focus://update", (e) => cb(e.payload));
export const onOverlayToggle = (cb: () => void): Promise<UnlistenFn> =>
listen("overlay://toggle", () => cb());
export const onOverlayLocked = (cb: (locked: boolean) => void): Promise<UnlistenFn> =>
listen<boolean>("overlay://locked", (e) => cb(e.payload));
export const onGuideReload = (cb: () => void): Promise<UnlistenFn> =>
listen("guide://reload", () => cb());
export const onTimer = (cb: (t: RunTimer) => void): Promise<UnlistenFn> =>
listen<RunTimer>("timer://update", (e) => cb(e.payload));
/** Format seconds as h:mm:ss or m:ss. */
export function fmtTime(total: number): string {
const s = Math.floor(total);
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
const sec = s % 60;
const pad = (n: number) => n.toString().padStart(2, "0");
return h > 0 ? `${h}:${pad(m)}:${pad(sec)}` : `${m}:${pad(sec)}`;
}

75
src/lib/layouts.ts Normal file
View File

@ -0,0 +1,75 @@
// Zone-layout decision tree, ported from the AHK `act-decoder`.
//
// The bundled PoE2 layout images (static/layouts/) are named `<areaID> <path>.jpg`
// where the path is a decision tree encoded as underscore-separated segments:
// "1", "2", "3" = the candidate layouts you first see entering a zone
// "3_1", "3_2" = refinements of "3" revealed as you explore deeper
// "x", "x_x" = "none of these / not catalogued" dead-ends
// You pick the candidate matching what you see in-game, then keep refining until
// no children remain. (On disk the space is stored as "~" for URL-safe serving.)
export type LayoutManifest = Record<string, string[]>; // areaID -> sorted paths
let manifestPromise: Promise<LayoutManifest> | null = null;
/** Load (once) the areaID -> paths manifest generated at build time. */
export function loadManifest(): Promise<LayoutManifest> {
if (!manifestPromise) {
manifestPromise = fetch("/layouts/index.json")
.then((r) => (r.ok ? r.json() : {}))
.catch(() => ({}));
}
return manifestPromise;
}
/** URL of the image for a given area + decision path. */
export function imageUrl(areaId: string, path: string): string {
return `/layouts/${areaId}~${path}.jpg`;
}
const depth = (p: string): number => (p === "" ? 0 : p.split("_").length);
/** Order paths numerically per segment, "x" sorting last. */
function sortPaths(a: string, b: string): number {
const sa = a.split("_");
const sb = b.split("_");
const n = Math.min(sa.length, sb.length);
for (let i = 0; i < n; i++) {
if (sa[i] !== sb[i]) {
const na = sa[i] === "x" ? Infinity : parseInt(sa[i], 10);
const nb = sb[i] === "x" ? Infinity : parseInt(sb[i], 10);
return na - nb;
}
}
return sa.length - sb.length;
}
/**
* Direct children of `path` ("" = root) among `paths`: entries one segment
* deeper that extend `path`.
*/
export function childrenOf(paths: string[], path: string): string[] {
const want = depth(path) + 1;
const prefix = path === "" ? "" : path + "_";
return paths
.filter((p) => p !== path && depth(p) === want && (path === "" || p.startsWith(prefix)))
.sort(sortPaths);
}
/** A path is an "x" (none-of-these / not catalogued) marker. */
export function isDeadEnd(path: string): boolean {
return path.split("_")[0] === "x";
}
// Zones whose layout placement is fixed (or where rotation gave wrong results in
// the original), so the viewer hides its rotation controls. Ported from the AHK
// `rota_block`; keys there may be "<areaID>" or "<areaID> <path>" — we block the
// whole area if any key matches (a single orientation is applied per zone).
const ROTA_BLOCK_AREAS = new Set([
"g1_2", "g1_4", "g1_7", "g1_8", "g1_11", "g1_12", "g1_14", "g1_15",
"g2_2", "g2_4_3", "g2_6", "g3_11", "g3_16",
]);
export function rotationBlocked(areaId: string): boolean {
return ROTA_BLOCK_AREAS.has(areaId);
}

129
src/lib/markup.ts Normal file
View File

@ -0,0 +1,129 @@
import type { Area } from "./api";
// Named colors used by the guide markup; anything else that is 3/6 hex chars
// is treated as a hex color.
const COLOR_NAMES: Record<string, string> = {
red: "#e05555",
lime: "#7CFC00",
aqua: "#55e0e0",
yellow: "#e0c84a",
green: "#5fbf5f",
white: "#ffffff",
orange: "#e08a3c",
};
function resolveColor(name: string): string {
if (COLOR_NAMES[name]) return COLOR_NAMES[name];
if (/^[0-9a-fA-F]{6}$/.test(name) || /^[0-9a-fA-F]{3}$/.test(name)) return "#" + name;
return "inherit";
}
function escapeHtml(s: string): string {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
// Icon names available under static/icons/leveling/ (from the original AHK
// "leveling tracker" assets). Only these render as real <img>; anything else
// falls back to a text chip so unknown markup never shows a broken image.
const ICONS = new Set([
"0", "1", "2", "3", "4", "5", "6", "7",
"arena", "artificer", "b-rune", "checkpoint", "craft", "exa", "flasks",
"gcp", "hideout", "in-out", "in-out2", "jeweller", "lab", "portal",
"quest", "quest_2", "regal", "ring", "rune", "skill", "skill2", "skip",
"spirit", "spirit2", "support", "support2", "town", "waypoint",
]);
// Underscores stand in for spaces in plain guide text.
function plain(text: string, color: string): string {
const t = escapeHtml(text.replace(/_/g, " "));
if (!t) return "";
if (color === "inherit") return t;
return `<span style="color:${color}">${t}</span>`;
}
export interface RenderedLine {
html: string;
kind: "normal" | "hint" | "optional" | "info";
indent: number;
}
const TOKEN_RE = /\(([a-z]+)(?::([^)]*))?\)|areaid([a-z0-9_]+)/gi;
/** Render a single guide line (with embedded markup) into HTML + metadata. */
export function renderLine(raw: string, areas: Record<string, Area>): RenderedLine {
let line = raw;
// Classify the line for styling.
let kind: RenderedLine["kind"] = "normal";
let indent = 0;
const lower = line.toLowerCase();
if (lower.startsWith("(hint)")) {
kind = "hint";
line = line.slice("(hint)".length);
// leading underscores after (hint) indicate indentation
const m = line.match(/^_+/);
if (m) {
indent = 1;
line = line.slice(m[0].length);
}
} else if (lower.startsWith("optional:")) {
kind = "optional";
} else if (lower.includes("info:")) {
kind = "info";
}
// Pull out the trailing " ;; area name" annotation.
let areaName: string | null = null;
const sc = line.indexOf(";;");
if (sc >= 0) {
areaName = line.slice(sc + 2).trim();
line = line.slice(0, sc).trim();
}
let out = "";
let color = "inherit";
let last = 0;
let m: RegExpExecArray | null;
TOKEN_RE.lastIndex = 0;
while ((m = TOKEN_RE.exec(line)) !== null) {
out += plain(line.slice(last, m.index), color);
last = m.index + m[0].length;
const kindTok = m[1]?.toLowerCase();
const val = m[2] ?? "";
const areaId = m[3];
if (areaId !== undefined) {
const name = areaName || areas[areaId]?.name || areaId.replace(/_/g, " ");
out += `<span class="area">${escapeHtml(name)}</span>`;
areaName = null; // consume once
} else if (kindTok === "color") {
color = resolveColor(val.trim());
} else if (kindTok === "img") {
const name = val.trim();
const label = name.replace(/_/g, " ");
if (ICONS.has(name.toLowerCase())) {
out += `<img class="icon" src="/icons/leveling/${encodeURIComponent(
name.toLowerCase()
)}.png" alt="${escapeHtml(label)}" title="${escapeHtml(label)}" />`;
} else {
out += `<span class="chip img" data-img="${escapeHtml(name)}">${escapeHtml(
label
)}</span>`;
}
} else if (kindTok === "quest") {
out += `<span class="chip quest">${escapeHtml(val.replace(/_/g, " "))}</span>`;
} else if (kindTok === "emph") {
// ignored: treated as a plain emphasis marker
}
// (hint) handled at line level above
}
out += plain(line.slice(last), color);
// Any leftover area name annotation with no areaid token: append it.
if (areaName) {
out += ` <span class="area">${escapeHtml(areaName)}</span>`;
}
return { html: out, kind, indent };
}

View File

@ -0,0 +1,6 @@
<script lang="ts">
import "../app.css";
let { children } = $props();
</script>
{@render children()}

5
src/routes/+layout.ts Normal file
View File

@ -0,0 +1,5 @@
// Tauri doesn't have a Node.js server to do proper SSR
// so we use adapter-static with a fallback to index.html to put the site in SPA mode
// See: https://svelte.dev/docs/kit/single-page-apps
// See: https://v2.tauri.app/start/frontend/sveltekit/ for more info
export const ssr = false;

17
src/routes/+page.svelte Normal file
View File

@ -0,0 +1,17 @@
<script lang="ts">
import { getCurrentWindow } from "@tauri-apps/api/window";
import Settings from "$lib/Settings.svelte";
import Overlay from "$lib/Overlay.svelte";
import Layouts from "$lib/Layouts.svelte";
// All windows load the same SPA entry; pick the view by window label.
const label = getCurrentWindow().label;
</script>
{#if label === "overlay"}
<Overlay />
{:else if label === "layout"}
<Layouts />
{:else}
<Settings />
{/if}

BIN
static/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

BIN
static/icons/leveling/0.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

BIN
static/icons/leveling/1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

BIN
static/icons/leveling/2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

BIN
static/icons/leveling/3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

BIN
static/icons/leveling/4.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

BIN
static/icons/leveling/5.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

BIN
static/icons/leveling/6.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

BIN
static/icons/leveling/7.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 517 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 561 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

BIN
static/layouts/g1_11~1.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

BIN
static/layouts/g1_11~2.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

BIN
static/layouts/g1_12~1.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

BIN
static/layouts/g1_12~2.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Some files were not shown because too many files have changed in this diff Show More