@poe2-toolkit/gem-extractor
v1.0.0
Published
Builds Path of Exile 2 gem data and icons straight from the official GGPK / patch server. Code only - ships no game data or art.
Maintainers
Readme
@poe2-toolkit/gem-extractor
Builds Path of Exile 2 gem data and icons straight from the official GGPK / patch server, in a flat shape a build front-end can consume.
It mirrors @poe2-toolkit/tree-extractor: source-agnostic,
built on a @poe2-toolkit/ggpk source, returning formatted data
rather than writing into the package.
Code only. This package ships no game data and no art. Everything it produces is read from the patch server at run time and handed back to you.
Install
npm install @poe2-toolkit/gem-extractor @poe2-toolkit/ggpkNode 18+. ESM only. TypeScript types are included.
The contract
The library returns formatted data. It performs no I/O of its own beyond what the source serves, and it never writes to disk.
import { createCdnSource } from '@poe2-toolkit/ggpk';
import { extractGems } from '@poe2-toolkit/gem-extractor';
const source = await createCdnSource({
patch: '4.5.4.1',
tablesDir: './tables/English',
cacheDir: './.cache',
});
const { data, icons } = await extractGems(source);
patchis whatever version the patch server currently serves; a stale version 404s, so pass the one you actually want to extract.
extractGems(source) resolves to a GemBundle - the gem data plus decoded
icons:
interface GemBundle {
data: GemData; // gems + per-level requirement curves + tooltip scaling
icons: GemIconsResult; // decoded icon PNGs + hover art + a pack/skip report
}The two steps are exported separately too: buildGems(source) for the data and
buildGemIcons(source, data) for the PNGs. The ported helpers
gemStatRequirement and stripBbcode are exported for reuse.
Field-level docs live on the exported types themselves - Gem, GemReq,
GemLevel, GemRequirement, GemIconsResult - so your editor shows each
field's meaning on hover and they ship in the .d.ts. The rest of this section
is the shape and the rules that the types alone don't tell you.
data: the gems (GemData)
GemData has two maps, both keyed by the last path segment of the base item
id (Path of Building's normalizeGemId, e.g. SkillGemIceNova); the last
segment wins on a collision, matching how a consumer looks gems up.
data.gems maps that key to a Gem. An active gem and a support gem:
"SkillGemIceNova": {
"name": "Ice Nova",
"kind": "active",
"color": "b",
"tags": ["Spell", "AoE", "Cold", "Duration", "Nova", "Repeatable"],
"description": "Conjure a wave of ice in all directions, Knocking Back enemies based on how close they are to you...",
"req": { "str": 0, "dex": 0, "int": 100, "level": 1 },
"icon": "Art/2DArt/SkillIcons/SorceressIceNova.dds"
}
"SupportGemFireInfusion": {
"name": "Fire Attunement",
"kind": "support",
"color": "r",
"tags": ["Support", "Fire"],
"req": { "str": 100, "dex": 0, "int": 0, "level": 1 },
"icon": "Art/2DArt/SkillIcons/Support/AddedFireDamageSupport.dds"
}data.requirements maps the same key to a GemRequirement - the per-level
attribute/level curve, keyed by gem level:
"SkillGemIceNova": {
"name": "Ice Nova",
"levels": {
"1": { "requiredLevel": 1, "str": 0, "dex": 0, "int": 0 },
"2": { "requiredLevel": 3, "str": 0, "dex": 0, "int": 9 },
"3": { "requiredLevel": 6, "str": 0, "dex": 0, "int": 14 }
}
}The rules the shape alone doesn't tell you:
kindisactive,supportorspirit(a persistent buff), andcolorisr(str),g(dex),b(int) orw(any).reqon aGemis percent-of-attribute weights plus a minimum characterlevel. Support gems and zero-weight attributes require nothing, so a support gem's per-level curve values are all0(see the requirement formula in How it works).requirementsomits gems with no per-level curve. Many supports have none, so they appear ingemsbut not inrequirements.- Icons come from different tables by kind. Active and spirit gems take their
icon from the active skill; supports take theirs from
SupportGems.Icon. descriptionisnullwhen the source text is empty, otherwise the skill / support text with bbcode stripped.hoverImageisSkillGems.UI_Image, raw GGPK path,nullwhen absent. Only active/spirit (skill) gems carry one - no support gem has hover art in the game, confirmed, sonullthere is the correct, permanent state, not a gap. Among active/spirit gems, coverage is genuinely sparse: 531SkillGemsrows resolve to a real, named, player-facingActiveSkill(e.g.Temporal Chains,Enfeeble,Discipline,Incinerate,Purity of Fire,Herald of Ice,Wither), and only 98 of those haveUI_Imageset - checked individually, not a duplicate-row or monster-variant artifact. On patch 4.5.4.3 the art for the other ~433 skill gems simply isn't in the data yet. This is the game's own data, not a bug in this extractor - a consumer should treat a missinghoverImageas "no background art for this gem (yet)," not as an extraction failure, and should expect this to fill in over future patches.
data.scaling maps the same key to a GemScaling - the resolved tooltip
scaling per gem level, plus quality bonus lines:
"SkillGemArc": {
"name": "Arc",
"levels": [
{
"level": 1, "cost": 8, "castTime": 1.1, "cooldown": null, "reservation": null,
"spellCritChance": 9, "attackCritChance": 0,
"stats": [
{ "text": "200% more damage when Lightning Infused", "min": 200, "max": 200 },
{ "text": "Deals 1 to 13 Lightning Damage", "min": 1, "max": 13 }
]
},
{ "level": 20, "cost": 81, "castTime": 1.1, "spellCritChance": 9,
"stats": [{ "text": "Deals 20 to 386 Lightning Damage", "min": 20, "max": 386 }] }
],
"qualityStats": [
{ "text": "Skills Chain +2 times", "min": 0, "max": 2 }
]
}Verified against a live extract, level-for-level, against Arc's in-game tooltip:
cost, cast time, crit chance and every scaling stat line match exactly (the
(min—max) figures the UI would show are your gem-level-1 and gem-level-20
entries side by side - scaling intentionally hands back per-level numbers, not
a pre-rendered range string, so a level-scaling slider has real numbers to work
with).
The rules the shape alone doesn't tell you:
levelshas one entry perGemLevelthe source defines - typically 1-40 for a levelling active/spirit gem, but often just one for a support gem with flat (non-scaling) bonuses.castTimeprefers the per-levelAttackTime(for attack skills, which can vary with weapon speed) and falls back to the flatGrantedEffects.CastTime(for spells, which don't scale by level) whenAttackTimeis0.statslines come from three sources, all rendered through the same stat-description engine:GrantedEffectStatSets.ConstantStats(fixed across every level),GrantedEffectStatSetsPerLevel.AdditionalStats(this level's value), andFloatStatspaired positionally withBaseResolvedValuestwo at a time (GGG's min/max convention, e.g. a damage roll) - verified against a live extract, not assumed from the schema alone.qualityStatsare resolved at 20 quality (the normal cap);minis always0, the value at 0 quality, since every quality bonus scales linearly.- A stat id with no matching description block is silently dropped, not
reported as an error - this is expected for the source data's internal-only
flag stats (e.g.
active_skill_consumes_a_lightning_infusion), which have no player-facing text at all. data.scalingomits a gem whoseGrantedEffectsrow has noStatSet. On a live extract every real gem resolved one, so in practice this only matters for malformed rows.
One field intentionally isn't here yet: a gem's display Tier (the number
poe2db shows, e.g. 5 for Arc). SkillGems.Tier exists in the schema but only
ranges 0-3 on a live extract and doesn't match the displayed value; the
UncutGemTiers table turned out to be a 1-20 ladder for the Uncut Gem currency
items themselves, not a per-gem lookup. Where the tooltip's Tier actually
comes from is unresolved - flagged here rather than guessed at.
icons: the decoded PNGs (GemIconsResult)
icons.icons is PNG bytes keyed by output path - each gem's icon and
hoverImage DDS paths with their extension swapped to .png, which the CLI
writes as files under icons/. Paths are deduplicated, so it's one PNG per
distinct path across all gems. icons.report counts what happened: packed
decoded successfully, missing could not be served or decoded (skipped, never
substituted from a vendored asset - expect a large missing count here purely
from the sparse hoverImage coverage noted above).
CLI: write the bundle to disk
poe2-gem-extract \
--patch 4.5.4.1 \
--tables ./tables/English \
--cache ./.cache \
--out ./out/gemsAll four flags are required. It writes gems.json, gem_requirements.json,
gem_scaling.json, and the icon PNG tree under icons/. Output is PNG + JSON;
converting to WebP for the web is a separate publish step left to you.
How it works
- Data joins the relational
SkillGems->GemEffects->GrantedEffects->ActiveSkillschain into one flat record per gem. Support gems take their icon fromSupportGemsand their text fromGemEffects.SupportText; active gems take theirs fromActiveSkills.[DNT]dev placeholders are dropped. - The per-level requirement curve is ported verbatim from Path of Building's
calcLib.getGemStatRequirement(CalcTools.lua), so the numbers match the game: support gems and zero-percent attributes require nothing, and a result under 8 rounds to 0. Required character level isfloor(ActorLevel)fromGrantedEffectsPerLevel. - The tooltip scaling joins a second chain rooted at
GrantedEffects.StatSet:GrantedEffectStatSetsfor the level-independent part andGrantedEffectStatSetsPerLevelfor the per-GemLevelvalues, plusGrantedEffectQualityStatsfor quality bonuses. Stat text is rendered via@poe2-toolkit/ggpk'sbuildStatIndex/renderBlockagainst two.csdfiles -data/statdescriptions/skill_stat_descriptions.csdfirst, falling back to the generaldata/statdescriptions/stat_descriptions.csd(shared with item mods) for stats defined there instead. - Icons and hover art are kept as their raw GGPK DDS paths in the data and
decoded to PNG by
buildGemIcons. A path the source cannot serve is skipped and reported, never pulled from a vendored asset.
Tables this needs beyond what was already pinned
GrantedEffectStatSets, GrantedEffectStatSetsPerLevel,
GrantedEffectQualityStats and Stats are new requirements - a source whose
config doesn't export them will throw reading the missing table file.
GrantedEffects additionally needs its CastTime column pinned (StatSet was
already required for nothing until now). Everything else this package reads was
already pinned by the existing gem build.
Attributions and legal
This is an unofficial, fan-made project, not affiliated with, endorsed by, or sponsored by Grinding Gear Games. "Path of Exile 2" is a trademark of Grinding Gear Games, and all game content, data, and art are their property. This package ships code only and stores nothing derived from the game. Thank you to Grinding Gear Games for making Path of Exile 2.
GGPK access is provided by @poe2-toolkit/ggpk, which builds on
pathofexile-dat (MIT, © SnosMe).
Full attribution is in the repository NOTICE.
License
MIT - see LICENSE.
