Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
168 changes: 168 additions & 0 deletions docs/atlas/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
# Atlas tab

The Atlas tab lets you plan atlas passive trees inside Path of Building. Like the in-game atlas, they belong to you rather than to a single build: every build shows the same atlas trees, changes are saved automatically, and they can be shared as a link. The tab is fully separate from build calculations: allocating atlas nodes never changes a calculated value and never triggers a recalculation.

This page explains how the feature works end to end: where the data comes from, how it is loaded and drawn, and how atlas trees are saved and shared.

## Overview

```
https://github.com/grindinggear/atlastree-export (one tag per league)
│ python3 src/Export/Atlas/import_atlas_tree.py
▼
src/TreeData/Atlas/<key>/tree.lua + images.lua
src/TreeData/Atlas/Images/ (sprite sheets, stored once)
src/Data/AtlasTreeVersions.lua
│ main:LoadAtlasTree(key) (Modules/Main.lua, cached)
▼
AtlasTree one atlas tree version: nodes, positions, links, sprites
│
AtlasSpec one named atlas plan: allocated nodes, variants, undo, save/load, links, stat summary
│
AtlasLibrary all atlas trees, stored in AtlasTrees.xml in the user folder
│
AtlasTab the tab: atlas tree list, version picker, buttons, popups
├─ AtlasTreeView draws the tree, handles hover/click/zoom/pan
└─ AtlasStatPanel right-hand panel with summed stats
```

## Where the atlas trees come from

Grinding Gear Games publishes the official atlas tree data at <https://github.com/grindinggear/atlastree-export>. That's the same data their own website and community planners use. Every league gets a git tag (`3.30.0`, `3.29.1`, `3.25.0-ruthless`, …), and each tag contains:

- `data.json`: every node (name, stats, icon, group, orbit, connections), every group (position), the orbit constants, the point total, and the sprite sheet layout
- `assets/`: the sprite sheets (node icons, frames, connector lines, group backgrounds, the atlas background) at several zoom levels

PoB doesn't download this at runtime. A script converts it once and the result is committed, the same as PoB does for the passive tree:

```bash
python3 src/Export/Atlas/import_atlas_tree.py # every version
python3 src/Export/Atlas/import_atlas_tree.py --tag 3.31.0 # just one new version
```

The script:

1. Lists the tags through the GitHub API and drops release candidates (`-rc`). A trailing `-atlas` is ignored, and when two tags describe the same version, the `-atlas` one wins.
2. Turns each tag into a **version key**: `3.30.0` becomes `3_30`, `3.29.1` becomes `3_29_1`, and `3.25.0-ruthless` becomes `3_25_ruthless`. Keys are stored in users' build files, so they must never change.
3. Downloads `data.json` and writes it as a Lua table to `src/TreeData/Atlas/<key>/tree.lua`.
4. Downloads only the sprite sheets PoB draws: zoom `0.3835`, or the largest one available. If a file is missing from the tag it falls back to GGG's CDN.
Many leagues share identical images, so each image is stored once in `src/TreeData/Atlas/Images/` under its name plus a short content hash. A per-version `images.lua` maps the names used in `tree.lua` to those files. A full import removes images no version uses anymore.
5. Rewrites `src/Data/AtlasTreeVersions.lua`, the ordered version list shown in the version dropdown. The newest non-Ruthless version is the default.

Files are written only when they change, so re-running the script is safe. Step-by-step instructions and troubleshooting are in [importing-atlas-versions.md](importing-atlas-versions.md).

### Export formats

GGG's export comes in two shapes, and both are supported:

| Versions | Sprite data | Images |
|---|---|---|
| 3.18.0 | `skillSprites` and `assets` (one image per asset, hosted on GGG's CDN) | The CDN images are gone. Frames, lines and backgrounds are borrowed from the newest version, which uses the same sprite names. |
| 3.18.1 and later | `sprites` (sprite sheets shipped in the tag) | Complete |

## Loading a version: `AtlasTree`

`main:LoadAtlasTree(key)` creates an `AtlasTree` the first time a version is needed and caches it. An unknown key falls back to the newest version.

The loader:

- **Node positions:** computes each node's position with the same orbit maths as the passive tree. `PassiveTree:CalcOrbitAngles` is reused read-only, and position = group centre plus orbit radius at the node's angle.
- **Node types:** gives every node one of `Root`, `Normal`, `Notable`, `Keystone`, `Mastery` or `Wormhole`. The root and the nameless start node are always allocated and never counted.
- **Links:** builds an undirected `linked` list per node from `out` and `in`. Masteries are decorative and are never linked.
- **Connectors:** builds the lines between nodes. Nodes in the same group and orbit get an arc, the rest get a straight line. Gateway pairs are linked for pathing, but no line is drawn between them because it would cross the whole map.
- **Stat text:** resolves GGG markup such as `[Thread|Threads]` to `Threads`.
- **Variant notables:** reads `variantEffects`, the effects a variant notable can have.
- **Sprites:** loads the sprite sheets into a sprite map for node icons and an asset table for frames, lines and backgrounds.

Atlas stats are plain text. They are never run through PoB's modifier parser.

## An atlas plan: `AtlasSpec`

An `AtlasSpec` is one named atlas tree, such as "Breach farming". It holds:

- the version key
- the allocated nodes
- the chosen effect of each variant notable
- an undo history

It handles the following:

- **Shortest path:** a breadth-first search starting from every allocated node at once. The first time it reaches the target, the path is final. Masteries are skipped.
- **Removing nodes:** removes the clicked node and every allocated node that can no longer reach the start.
- **Changing version:** keeps the node ids that exist in the new version and drops the rest (the tab shows how many were dropped).
- **Stat summary:**
- Identical stats are summed. Their numbers are replaced by placeholders, matched, added up and put back.
- Each stat is placed in a category: the first mechanic (mastery) name found in its text, such as Breach, Incursion or Ritual. Stats that name no mechanic, such as "increased Maps found", go under "Misc", which is listed last.
- Variant notables count only their chosen effect.

## The tab

- **`AtlasTab`** creates the controls:
- the atlas tree dropdown and the "Manage atlas trees" list
- the version dropdown
- Clear, Import, Export and search
- Collapse All and Expand All

Ctrl+Z / Ctrl+Y undo and redo, Ctrl+F jumps to search, and Ctrl+M opens the atlas tree manager.
- **`AtlasTreeView`** draws the background, group backgrounds, connectors, nodes and tooltips:
- Hover shows the path to allocate, or the nodes that would be removed in red.
- Hovering a node rings every other node with the same name. For example, hovering a Gateway shows its partner, and hovering a mechanic's cluster icon (such as Breach) shows every Breach cluster on the tree.
- Clicking a node calls back into the tab. Clicking a variant notable opens the effect dropdown.
- **`AtlasStatPanel`** draws the point counter and the collapsible stat categories. Hovering a stat rings the nodes that give it.

## Hooks into Path of Building

Only two existing files are changed, and only with additions:

- **`Modules/Main.lua`:** loads the version list and adds `main:LoadAtlasTree`.
- **`Modules/Build.lua`:**
- adds the **Atlas** button and the `ATLAS` view mode
- creates `atlasTab`
- registers `<Atlas>` as a load-only legacy section, so older build files can be imported
- saves `viewMode` as `TREE` when the Atlas tab is open, so the build opens normally in other PoB versions

## Saving

Atlas trees are stored in `AtlasTrees.xml` in PoB's user folder (`main.userPath`, normally `Documents\Path of Building`, next to `Settings.xml` and the Builds folder). That folder survives program updates and reinstalls. `AtlasLibrary` loads the file the first time a build is opened, and the Atlas tab writes it after every change. There's no save button and build files are never touched.

```xml
<AtlasTrees activeSpec="1">
<Spec title="Breach farming" treeVersion="3_30" nodes="57123,63753">
<Variant nodeId="45404" effect="55878"/>
</Spec>
</AtlasTrees>
```

An unrecognised `treeVersion` falls back to the newest version, and node ids that don't exist are skipped.

Earlier versions of the tab saved atlas trees in an `<Atlas>` section of the build file. When such a build is opened, its atlas trees are imported into the library once. Duplicates and empty atlas trees are skipped, and the build itself is not modified.

## Sharing

**Export** produces an official-style link: `https://www.pathofexile.com/fullscreen-atlas-skill-tree/<payload>`. The payload uses the same binary layout as passive tree links:

- version 6
- class 0 and ascendancy 0
- the node ids
- zero cluster nodes
- variant choices as effect/node pairs

All of it is base64url-encoded. **Import** accepts the full link or just the payload and loads it into the current atlas tree at its current version.

## Testing

```bash
docker compose run busted-tests # whole suite, as in CONTRIBUTING.md
docker compose run busted-tests busted --lua=luajit ../spec/System/TestAtlasTab_spec.lua # one spec
```

The atlas specs (`spec/System/TestAtlas*_spec.lua`) cover:

- loading every version
- pathing, removal and Gateways
- variants
- the library file, importing from older build files, and link round trips
- the stat summary
- drawing, tooltips and same-name highlighting

`TestAtlasNonInterference_spec.lua` loads the bundled test builds with and without atlas data. It allocates atlas nodes and checks that every calculated value is unchanged and no recalculation was triggered.
63 changes: 63 additions & 0 deletions docs/atlas/importing-atlas-versions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# Importing atlas tree versions

The Atlas tab reads atlas passive tree data from `src/TreeData/Atlas/<key>/`, plus the version list `src/Data/AtlasTreeVersions.lua`. Both are generated from Grinding Gear Games' official export at <https://github.com/grindinggear/atlastree-export>, where every league gets a git tag (for example `3.30.0` or `3.25.0-ruthless`).

When GGG publishes a new tag, run the import script and commit the result. No manual editing is needed.

## Prerequisites

- Python 3.8 or newer. Only the standard library is used.
- Network access to `api.github.com` and `raw.githubusercontent.com`.

## Import everything (recommended)

From the repository root:

```bash
python3 src/Export/Atlas/import_atlas_tree.py
```

The script:

1. Lists every tag in the export repository.
2. Picks the usable ones:
- Release candidates (`-rc`) are skipped.
- A trailing `-atlas` is ignored.
- If two tags describe the same version, the one ending in `-atlas` wins.
3. For each version, writes:
- `src/TreeData/Atlas/<key>/tree.lua`, which is `data.json` converted to a Lua table.
- `src/TreeData/Atlas/<key>/images.lua`, which maps the sprite sheets the tree uses (at the zoom level PoB draws: `0.3835`, or the largest available) to files in `src/TreeData/Atlas/Images/`. Each image is stored once, even when several leagues use it. A full import also removes images no version uses anymore.
4. Rewrites `src/Data/AtlasTreeVersions.lua`, ordered oldest to newest, with each Ruthless version right after its normal version.

Re-running is safe: files are rewritten only when their content changed. Each run still downloads every `data.json` to compare, so it takes a couple of minutes.

### How tags become keys

| Tag | Key | Shown in PoB as |
|---|---|---|
| `3.30.0` | `3_30` | 3.30.0 |
| `3.29.1` | `3_29_1` | 3.29.1 |
| `3.25.0-ruthless` | `3_25_ruthless` | 3.25.0 (Ruthless) |
| `3.21.0-atlas` | `3_21` | 3.21.0 |

**Never rename or delete an existing key.** Keys are stored in users' build files.

## Import a single new tag

```bash
python3 src/Export/Atlas/import_atlas_tree.py --tag 3.31.0
```

This imports only that tag and adds it to the existing version list. Use `--tag` several times to import several tags.

## After importing

1. Start PoB, open a build, go to **Atlas** and pick the new version in the version dropdown. Check that the tree draws and nodes can be allocated.
2. When preparing a release, run `python3 update_manifest.py` so the new files are listed in `manifest.xml`.
3. Commit `src/TreeData/Atlas/` (including any new files in `Images/`) and `src/Data/AtlasTreeVersions.lua`.

## Troubleshooting

- **HTTP 403 from api.github.com:** you hit GitHub's anonymous rate limit. Create a token with no scopes and run `GITHUB_TOKEN=<token> python3 src/Export/Atlas/import_atlas_tree.py`.
- **`missing asset: …` lines:** the image is in neither the tag's `assets/` folder nor GGG's CDN. This happens for `3.18.0`, whose frame, line and background art is gone from the CDN. PoB draws those parts using the newest imported version's art, which has the same sprite names, so the warning is expected for that version.
- **`Unsupported tag(s)`:** the tag doesn't look like `X.Y.Z`, `X.Y.Z-ruthless` or `X.Y.Z-atlas`. If GGG changes its naming, update `TAG_RE` in the script.
61 changes: 61 additions & 0 deletions spec/System/TestAtlasNonInterference_spec.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
describe("Atlas non-interference", function()
local libraryPath

local function snapshot()
local values = { }
for key, value in pairs(build.calcsTab.mainOutput) do
if type(value) == "number" then
values[key] = value
end
end
return values
end

local function atlasSection()
local parts = { '<Atlas activeSpec="1">' }
for index, target in ipairs({ 63753, 45404, 3204 }) do
local spec = new("AtlasSpec"):AtlasSpec("3_30")
spec:AllocNode(spec.tree.nodes[target])
table.insert(parts, '<Spec title="Plan ' .. index .. '" treeVersion="3_30" nodes="' .. table.concat(spec:GetAllocatedIds(), ",") .. '"/>')
end
table.insert(parts, "</Atlas>")
return table.concat(parts)
end

before_each(function()
libraryPath = os.tmpname()
os.remove(libraryPath)
main.atlasLibrary = new("AtlasLibrary"):AtlasLibrary(libraryPath)
end)

after_each(function()
os.remove(libraryPath)
end)

local buildFiles = { }
for file in lfs.dir("../spec/TestBuilds/3.13") do
if file:match("%.lua$") then
table.insert(buildFiles, file)
end
end
table.sort(buildFiles)

for _, file in ipairs(buildFiles) do
local name = file:gsub("%.lua$", "")
it("keeps calc output identical for " .. name, function()
local testBuild = LoadModule("../spec/TestBuilds/3.13/" .. file)
loadBuildFromXML(testBuild.xml, name)
local before = snapshot()
loadBuildFromXML(testBuild.xml:gsub("</PathOfBuilding>", atlasSection() .. "</PathOfBuilding>"), name)
assert.are.equal(3, #build.atlasTab.specList)
assert.is_true(build.atlasTab.specList[3]:CountAllocNodes() > 0)
assert.are.same(before, snapshot())
build.buildFlag = false
build.controls.modeAtlas.onClick()
build.atlasTab:HandleNodeClick(build.atlasTab.spec.tree.nodes[17000])
runCallback("OnFrame")
assert.is_false(build.buildFlag)
assert.are.same(before, snapshot())
end)
end
end)
57 changes: 57 additions & 0 deletions spec/System/TestAtlasSpec_spec.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
describe("AtlasSpec", function()
local spec, nodes
before_each(function()
spec = new("AtlasSpec"):AtlasSpec("3_30")
nodes = spec.tree.nodes
end)

it("paths, allocates and deallocates", function()
assert.are.equal(0, spec:CountAllocNodes())
assert.is_not_nil(spec.allocNodes[29045])
local path = spec:GetPathToNode(nodes[63753])
assert.are.equal(12, #path)
assert.are.equal(nodes[63753], path[#path])
spec:AllocNode(nodes[63753])
assert.are.equal(12, spec:CountAllocNodes())
assert.are.equal(0, #spec:GetPathToNode(nodes[63753]))
assert.are.equal(0, #spec:GetPathToNode(spec.tree.masteries[1]))
spec:DeallocNode(nodes[57123])
assert.is_nil(spec.allocNodes[63753])
assert.are.equal(10, spec:CountAllocNodes())
end)

it("handles variants and gateways", function()
spec:AllocNode(nodes[45404])
assert.is_true(spec:SetVariant(45404, 55878))
assert.is_false(spec:SetVariant(45404, 1))
assert.are.equal(55878, spec.variantSelections[45404])
spec:DeallocNode(nodes[45404])
assert.is_nil(spec.variantSelections[45404])
spec:AllocNode(nodes[17000])
assert.are.equal(1, #spec:GetPathToNode(nodes[13842]))
end)

it("round-trips xml, versions and undo", function()
spec.title = "Breach"
spec:AllocNode(nodes[45404])
spec:SetVariant(45404, 5301)
local xml = { elem = "Spec" }
spec:Save(xml)
local loaded = new("AtlasSpec"):AtlasSpec("3_30")
loaded:Load(xml)
assert.are.same(spec:GetAllocatedIds(), loaded:GetAllocatedIds())
assert.are.equal(5301, loaded.variantSelections[45404])
assert.are.equal("Breach", loaded.title)
local unknown = new("AtlasSpec"):AtlasSpec("3_30")
unknown:Load({ elem = "Spec", attrib = { treeVersion = "9_99", nodes = "57123" } })
assert.are.equal(latestAtlasTreeVersion, unknown.treeVersion)
spec:AddUndoState()
spec:ResetNodes()
spec:AddUndoState()
spec:Undo()
assert.are.equal(13, spec:CountAllocNodes())
local dropped = spec:SetTreeVersion("3_18")
assert.are.equal("3_18", spec.treeVersion)
assert.is_true(dropped >= 0)
end)
end)
Loading