Skip to content

Instantly share code, notes, and snippets.

@jrobinson3k1
Created January 8, 2025 17:26
Show Gist options
  • Select an option

  • Save jrobinson3k1/b4bcc6154402987dd46e9a9c3b30f30f to your computer and use it in GitHub Desktop.

Select an option

Save jrobinson3k1/b4bcc6154402987dd46e9a9c3b30f30f to your computer and use it in GitHub Desktop.
// parses a v3 PSG file
object PassiveTreeParser {
fun readData(inputStream: InputStream): SkillTreeGraph {
val data = DataTypeReader(inputStream, order = ByteOrder.LITTLE_ENDIAN).readPassiveTreeData()
val remainingBytes = inputStream.readAllBytes()
require(remainingBytes.isEmpty()) { "$remainingBytes bytes were not processed" }
return data
}
private fun DataTypeReader.readPassiveTreeData(): SkillTreeGraph =
when (val version = readByte().toInt()) {
3 -> V3Decoder.decode(this)
else -> throw UnsupportedOperationException("Missing decoder for version $version")
}
private object V3Decoder {
fun decode(reader: DataTypeReader): SkillTreeGraph = with(reader) {
val type = readByte()
val orbitCount = readByte()
val orbits = (0 until orbitCount).map { readByte().toUByte() }
val rootNodeCount = readInt()
val rootNodeIds = (0 until rootNodeCount).map { readInt().also { skip(4) } }
val nodeGroupCount = readInt()
val nodeGroups = (0 until nodeGroupCount).map { readNodeGroup() }
return SkillTreeGraph(
version = 3,
type = type.toInt(),
orbits = orbits.map { it.toInt() },
rootIds = rootNodeIds,
groups = nodeGroups,
)
}
private fun DataTypeReader.readNodeGroup(): SkillTreeGraph.Group {
val x = readFloat()
val y = readFloat()
val groupAssociationKey = readInt()
val groupBackgroundOverride = readInt()
val isJewelPositionReference = readByte().toInt() != 0
val nodeCount = readInt()
val nodes = (0 until nodeCount).map { readNode() }
return SkillTreeGraph.Group(
location = x to y,
groupAssociationKey = groupAssociationKey,
groupBackgroundOverride = groupBackgroundOverride,
isJewelPositionReference = isJewelPositionReference,
nodes = nodes,
)
}
private fun DataTypeReader.readNode(): SkillTreeGraph.NodeRef {
val id = readInt()
val radius = readInt()
val position = readInt()
val connectionCount = readInt()
val nodeConnections = (0 until connectionCount).map { readConnection() }
return SkillTreeGraph.NodeRef(
id = id,
orbit = radius,
orbitIndex = position,
connectedNodeIds = nodeConnections,
)
}
private fun DataTypeReader.readConnection(): SkillTreeGraph.ConnectionRef {
val nodeId = readInt()
val orbit = readInt()
return SkillTreeGraph.ConnectionRef(
nodeId = nodeId,
orbit = orbit,
)
}
}
}
data class SkillTreeGraph(
val version: Int,
val type: Int,
val orbits: List<Int>,
val rootIds: List<Int>,
val groups: List<Group>,
) {
data class Group(
val location: Pair<Float, Float>,
val groupAssociationKey: Int,
val groupBackgroundOverride: Int,
val isJewelPositionReference: Boolean,
val nodes: List<NodeRef>,
)
data class NodeRef(
val id: Int,
val orbit: Int,
val orbitIndex: Int,
val connectedNodeIds: List<ConnectionRef>,
)
data class ConnectionRef(
val nodeId: Int,
val orbit: Int,
)
}
@jrobinson3k1
Copy link
Author

@kesor nice! Glad this was helpful for you. Let me know if you need any aspects of it explained. I'm not sure what your eventual goal is, but if you're wanting to visually build out the passive skill tree then you have many more hurdles to jump ahead of you.

The only data-driven information I haven't been able to uncover is the actual radius value for each orbit. There is a json file in the game files, but it's for the PoE1 tree (only has 6 orbits). I'm using these values, which I scrapped from poedb's passive tree:

[0, 82, 162, 335, 493, 662, 846, 249, 1020, 1200]

All other data I've found in the dat64c files. Of note, PassiveSkills.dat64c has a skill graph key in each row that corresponds to the node id from the PSG. https://github.com/adainrivers/poe2-data/ is a great resource for checking out what's available in the dat64c files.

@jrobinson3k1
Copy link
Author

jrobinson3k1 commented Jan 9, 2025

Also, orbit inside Connection needs to be signed. The rest I think are safe to be unsigned except the x and y floats. The sign on the orbit value inside the connection determines which of the two intersection circles to use when drawing connecting arcs between nodes. A value of zero mean that an arc should be drawn along the shared orbit of two nodes who share the same group and orbit. For all other connections with an orbit value of zero, a straight line should be drawn between them.

@kesor
Copy link

kesor commented Jan 9, 2025

@jrobinson3k1 I started digging into these binaries because I wanted to build a dependency graph, for calculation purposes. But I did end up cobbling a quick visualization for the x,y coords, and it does look a bit wonky, but maybe it is just the code that chatgpt wrote that is wrong idk.

Now that I have all the attribute data, names, icons, dependencies/relationships of the skills ... I can see the whole picture of how to make a script that will find "optimal" builds given different constraints. My worry was that hard-coding all the data manually would quickly become out-of-sync with the actual game, which is why I wanted to read it directly from the game files instead.

And I already had the datc64 decoded, there are several places that have the "schema" for each of the files available. Noted the graph_id column in that database, which is why I wanted to read the psg.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment