Skip to content
Closed
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
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
23 changes: 15 additions & 8 deletions src/Classes/PassiveTree.lua
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,9 @@ local PassiveTreeClass = newClass("PassiveTree", function(self, treeVersion)

self.size = m_min(self.max_x - self.min_x, self.max_y - self.min_y) * self.scaleImage * 1.1

for i = 0, 6 do
-- Shift classes from 1-indexed (JSON/Lua) to 0-indexed
local classCount = #self.classes
for i = 0, classCount - 1 do
self.classes[i] = self.classes[i + 1]
self.classes[i + 1] = nil
end
Expand All @@ -94,7 +96,9 @@ local PassiveTreeClass = newClass("PassiveTree", function(self, treeVersion)
class.classes = class.ascendancies
class.classes[0] = { name = "None" }
self.classNameMap[class.name] = classId
self.classIntegerIdMap[class.integerId] = classId
if class.integerId then
self.classIntegerIdMap[class.integerId] = classId
end
for ascendClassId, ascendClass in pairs(class.classes) do
self.ascendNameMap[ascendClass.id or ascendClass.name] = {
classId = classId,
Expand All @@ -121,6 +125,7 @@ local PassiveTreeClass = newClass("PassiveTree", function(self, treeVersion)
ConPrintf("Loading passive tree assets...")
for name, data in pairs(self.assets) do
self:LoadImage(data[1], data, "MIPMAP")
data.found = data.width > 0
end

self.ddsMap = { }
Expand Down Expand Up @@ -189,12 +194,14 @@ local PassiveTreeClass = newClass("PassiveTree", function(self, treeVersion)
end
elseif node.isAscendancyStart then
node.type = "AscendClassStart"
local ascendClass = self.ascendNameMap[node.ascendancyName].ascendClass
ascendClass.startNodeId = node.id
if node.isSwitchable then
for ascName, _ in pairs(node.options) do
local option = self.ascendNameMap[ascName].ascendClass
option.startNodeId = node.id
if self.ascendNameMap[node.ascendancyName] then
local ascendClass = self.ascendNameMap[node.ascendancyName].ascendClass
ascendClass.startNodeId = node.id
if node.isSwitchable then
for ascName, _ in pairs(node.options) do
local option = self.ascendNameMap[ascName].ascendClass
option.startNodeId = node.id
end
end
end
elseif node.isOnlyImage then
Expand Down
8 changes: 6 additions & 2 deletions src/Classes/PassiveTreeView.lua
Original file line number Diff line number Diff line change
Expand Up @@ -1308,8 +1308,12 @@ function PassiveTreeViewClass:DrawAsset(data, x, y, scale, isHalf)
if isHalf then
DrawImage(data.handle, x - width, y - height * 2, width * 2, height * 2)
DrawImage(data.handle, x - width, y, width * 2, height * 2, 0, 1, 1, 0)
else
elseif type(data[1]) == "number" then
-- Grid-tiled spritesheet: data[1] is the 1-based tile index the engine slices
DrawImage(data.handle, x - width, y - height, width * 2, height * 2, unpack(data))
else
-- Single-image asset (e.g. an extracted 0.5 node icon): draw the whole image
DrawImage(data.handle, x - width, y - height, width * 2, height * 2)
end
end

Expand Down Expand Up @@ -1469,7 +1473,7 @@ function PassiveTreeViewClass:DoesNodeMatchSearchParams(node)
end

-- Check unlock ascendancy
if node.unlockConstraint then
if node.unlockConstraint and node.unlockConstraint.ascendancy then
err, needMatches = PCall(search, node.unlockConstraint.ascendancy:lower(), needMatches)
if err then return false end
if #needMatches == 0 then
Expand Down
4,772 changes: 4,599 additions & 173 deletions src/Data/ModCache.lua

Large diffs are not rendered by default.

11 changes: 9 additions & 2 deletions src/Data/Uniques/Special/Generated.lua
Original file line number Diff line number Diff line change
Expand Up @@ -104,8 +104,15 @@ do
table.insert(data.uniques.generated, table.concat(prism, "\n"))
end

local treedata = LoadModule("TreeData/" .. latestTreeVersion .. "/tree.lua")
local nodes = treedata.nodes
local ok, treedata = pcall(LoadModule, "TreeData/" .. latestTreeVersion .. "/tree.lua")
if not ok or not treedata then
-- Fall back to previous tree version if latest tree.lua hasn't been generated yet
for i = #treeVersionList - 1, 1, -1 do
ok, treedata = pcall(LoadModule, "TreeData/" .. treeVersionList[i] .. "/tree.lua")
if ok and treedata then break end
end
end
local nodes = treedata and treedata.nodes or {}

do
local megalomaniac = {
Expand Down
273 changes: 273 additions & 0 deletions src/Export/convert_05_tree.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,273 @@
#!/usr/bin/env python3
"""
Convert PoE2 0.5 passive tree data.json to 0.4-compatible format for PoB.
Usage: python convert_05_to_04.py <0.5_data.json> <0.4_tree.json> <output.json>

The 0.4 tree.json is needed to copy over assets/constants/nodeOverlay/connectionArt/ddsCoords
that were removed from the 0.5 schema but are still required by PoB's PassiveTree.lua parser.
"""
import json
import sys
import re

# 0.4-compatible options for generic attribute nodes. Every 0.5 isGenericAttribute
# node is a +5 STR/DEX/INT choice (confirmed by the raw "+5 to any Attribute" stat
# and matching 0.4's 293 identical attribute nodes). ids/values mirror the GGG
# export script's base_attributes (str 26297, dex 14927, int 57022); icons use the
# 0.5 .png paths that the icon extractor registers as assets.
ATTRIBUTE_OPTIONS = [
{"id": 26297, "name": "Strength", "icon": "Art/2DArt/SkillIcons/passives/plusstrength.png", "stats": ["+5 to Strength"]},
{"id": 14927, "name": "Dexterity", "icon": "Art/2DArt/SkillIcons/passives/plusdexterity.png", "stats": ["+5 to Dexterity"]},
{"id": 57022, "name": "Intelligence", "icon": "Art/2DArt/SkillIcons/passives/plusintelligence.png", "stats": ["+5 to Intelligence"]},
]

def strip_stat_markup(stat_text):
"""Strip [Tag|Display] and <tag>{content} markup from stat text.
[PuppetMaster|Puppet Master] -> Puppet Master
[Command] -> Command (no display text, use tag as-is)
<underline>{Hollow Focus} -> Hollow Focus
"""
def replace_tag(m):
tag = m.group(1)
display = m.group(2)
return display if display else tag
# Strip [Tag|Display] markup
result = re.sub(r'\[([^\]|]+)\|?([^\]]*)\]', replace_tag, stat_text)
# Strip <tag>{content} markup -> content
result = re.sub(r'<[^>]+>\{([^}]+)\}', r'\1', result)
# Strip remaining <tag> and </tag>
result = re.sub(r'</?[a-zA-Z]+>', '', result)
return result

def build_ascendancy_id_to_name(classes):
"""Build mapping from ascendancyId (e.g. 'Ranger1') -> ascendancy name (e.g. 'Deadeye')"""
mapping = {}
for cls in classes:
for asc in cls.get('ascendancies', []):
if asc and asc.get('id') and asc.get('name'):
mapping[asc['id']] = asc['name']
return mapping

def build_class_index_to_name(classes):
"""Build mapping from class index -> class name"""
return {i: cls['name'] for i, cls in enumerate(classes)}

def convert(new_data, old_data):
out = {}

# Copy static assets from 0.4 that 0.5 removed
for key in ['assets', 'connectionArt', 'constants', 'ddsCoords', 'nodeOverlay']:
if key in old_data:
out[key] = old_data[key]

# Copy bounds
for key in ['min_x', 'min_y', 'max_x', 'max_y', 'tree']:
out[key] = new_data[key]

# Jewel slots
out['jewelSlots'] = new_data.get('jewelSlots', [])

# --- CLASSES ---
asc_id_to_name = build_ascendancy_id_to_name(new_data['classes'])
idx_to_name = build_class_index_to_name(new_data['classes'])

converted_classes = []
for i, cls in enumerate(new_data['classes']):
new_cls = {
'name': cls['name'],
'base_str': cls.get('base_str', 0),
'base_dex': cls.get('base_dex', 0),
'base_int': cls.get('base_int', 0),
'integerId': i, # Synthesize integerId from array index
}
# Convert ascendancies
new_ascs = []
for asc in cls.get('ascendancies', []):
if asc is None:
continue
# Skip unrevealed ascendancies (name=None)
if not asc.get('name'):
continue
new_asc = {
'id': asc.get('name', ''), # 0.4 uses name as id
'internalId': asc.get('id', ''), # 0.4's internalId = 0.5's id
'name': asc.get('name', ''),
}
new_ascs.append(new_asc)
new_cls['ascendancies'] = new_ascs
converted_classes.append(new_cls)

out['classes'] = converted_classes

# --- Build edges lookup for connections ---
# 0.5 uses edges array + out/in on nodes
# 0.4 uses connections array on each node: [{id: X, orbit: Y}, ...]
# We need to convert out/in references to connections format

# --- NODES ---
converted_nodes = {}
for nid, node in new_data['nodes'].items():
if nid == 'root':
continue # Skip root pseudo-node

new_node = {}

# Direct copies
for key in ['group', 'orbit', 'orbitIndex', 'icon', 'name', 'skill',
'isKeystone', 'isNotable', 'isJewelSocket', 'isMultipleChoice',
'isMultipleChoiceOption', 'isAscendancyStart',
'activeEffectImage', 'recipe',
'stats', 'unlockConstraint']:
if key in node:
new_node[key] = node[key]

# Extract grantedSkill details and append to stats so they show in PoB tooltips
if node.get('grantedSkill'):
gs = node['grantedSkill']
extra_stats = []
# Skill name and description
if gs.get('typeLine'):
extra_stats.append(f"--- Granted Skill: {gs['typeLine']} ---")
# Properties (cost, cast time, cooldown, etc.)
for prop in gs.get('properties', []):
if prop.get('values') and prop['values']:
extra_stats.append(f"{prop['name']}: {prop['values'][0][0]}")
# Weapon requirements
for req in gs.get('weaponRequirements', []):
if req.get('values') and req['values']:
extra_stats.append(f"Requires: {req['values'][0][0]}")
# Description
if gs.get('secDescrText'):
extra_stats.append(gs['secDescrText'])
# Stats from gem tabs
for tab in gs.get('gemTabs', []):
tab_name = tab.get('name')
for page in tab.get('pages', []):
skill_name = page.get('skillName', '')
if skill_name and tab_name != skill_name:
extra_stats.append(f"[{skill_name}]")
# Page properties
for prop in page.get('properties', []):
if prop.get('values') and prop['values']:
extra_stats.append(f"{prop['name']}: {prop['values'][0][0]}")
for stat in page.get('stats', []):
extra_stats.append(stat)
# Append to existing stats
if extra_stats:
if 'stats' not in new_node:
new_node['stats'] = []
new_node['stats'] = new_node['stats'] + extra_stats

# Clean up all stat markup tags [Tag|Display] -> Display
if 'stats' in new_node:
new_node['stats'] = [strip_stat_markup(s) for s in new_node['stats']]

# flavourText: 0.5 can have it as a table/object, PoB expects a string
if 'flavourText' in node:
ft = node['flavourText']
if isinstance(ft, str):
new_node['flavourText'] = strip_stat_markup(ft)
elif isinstance(ft, dict):
new_node['flavourText'] = strip_stat_markup(ft.get('text', str(ft)))
elif isinstance(ft, list):
new_node['flavourText'] = strip_stat_markup('\n'.join(str(x) for x in ft))
else:
new_node['flavourText'] = strip_stat_markup(str(ft))

# Field renames
if node.get('ascendancyId'):
# Map ascendancyId -> ascendancyName using the name from the class data
asc_name = asc_id_to_name.get(node['ascendancyId'])
if not asc_name:
# Unrevealed ascendancy — skip this node entirely
continue
new_node['ascendancyName'] = asc_name

if node.get('classStartIndex') is not None:
# Map class indices to class names
names = [idx_to_name.get(idx, str(idx)) for idx in node['classStartIndex']]
new_node['classesStart'] = names

# 0.5 isGenericAttribute -> 0.4 isAttribute + options (the STR/DEX/INT chooser).
# PassiveTree.lua's ProcessNode iterates node.options for isAttribute nodes.
# isSwitchable is intentionally NOT set: the export script reserves that flag
# for class/ascendancy override nodes, which use name-keyed options instead.
if node.get('isGenericAttribute'):
new_node['isAttribute'] = True
new_node['options'] = [
{"id": o["id"], "name": o["name"], "icon": o["icon"], "stats": list(o["stats"])}
for o in ATTRIBUTE_OPTIONS
]

if node.get('isFree'):
new_node['isFreeAllocate'] = True

if node.get('isJewelSocket'):
new_node['containJewelSocket'] = True

if node.get('isMastery'):
new_node['isOnlyImage'] = True # Masteries rendered as image nodes in 0.4

# Connections: convert out[] to connections format
connections = []
for target in node.get('out', []):
target_str = str(target)
if target_str != 'root':
connections.append({'id': int(target_str) if target_str.isdigit() else target, 'orbit': 0})
new_node['connections'] = connections

# nodeOverlay placeholder (0.5 doesn't have per-node overlays)
# PassiveTree.lua will fall back to the global nodeOverlay

converted_nodes[nid] = new_node

out['nodes'] = converted_nodes

# --- GROUPS ---
# 0.4 uses a list (JSON array), 0.5 uses a dict (JSON object with string keys)
# PoB expects array-indexed groups. Also convert node IDs from strings to ints.
if isinstance(new_data.get('groups', {}), dict):
# Convert dict to list, preserving order by numeric key
max_key = max(int(k) for k in new_data['groups'].keys())
groups_list = [None] * (max_key + 1)
for gid, group in new_data['groups'].items():
g = dict(group)
# Convert node IDs from strings to ints
g['nodes'] = [int(n) if isinstance(n, str) and n.isdigit() else n for n in g.get('nodes', [])]
groups_list[int(gid)] = g
# Remove None gaps — PoB iterates groups as an array
out['groups'] = [g for g in groups_list if g is not None]
else:
out['groups'] = new_data.get('groups', [])

return out

def main():
if len(sys.argv) != 4:
print(f"Usage: {sys.argv[0]} <0.5_data.json> <0.4_tree.json> <output_data.json>")
sys.exit(1)

new_path, old_path, out_path = sys.argv[1], sys.argv[2], sys.argv[3]

print(f"Loading 0.5 data: {new_path}")
with open(new_path) as f:
new_data = json.load(f)

print(f"Loading 0.4 reference: {old_path}")
with open(old_path) as f:
old_data = json.load(f)

print("Converting...")
result = convert(new_data, old_data)

print(f"Writing: {out_path}")
with open(out_path, 'w') as f:
json.dump(result, f)

print(f"Done. {len(result['nodes'])} nodes, {len(result['classes'])} classes.")
for cls in result['classes']:
ascs = [a['name'] for a in cls['ascendancies']]
print(f" {cls['name']}: {ascs if ascs else 'no ascendancies'}")

if __name__ == '__main__':
main()
Loading