@poe2-toolkit/item-extractor
v1.0.0
Published
Builds Path of Exile 2 item data and icons straight from the official GGPK / patch server. Code only - ships no game data or art.
Downloads
390
Maintainers
Readme
@poe2-toolkit/item-extractor
Builds Path of Exile 2 item data - normal-rarity bases and uniques - plus their 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/item-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 { extractItems } from '@poe2-toolkit/item-extractor';
const source = await createCdnSource({
patch: '4.5.4.1',
tablesDir: './tables/English',
cacheDir: './.cache',
});
const { data, icons } = await extractItems(source);
patchis whatever version the patch server currently serves; a stale version 404s, so pass the one you actually want to extract.
extractItems(source) resolves to an ItemBundle - the item data plus decoded
icons:
interface ItemBundle {
data: ItemData; // items keyed by display name (bases + uniques)
icons: ItemIconsResult; // decoded icon PNGs + a pack/skip report
}The two steps are exported separately too: buildItems(source) for the data and
buildItemIcons(source, data) for the PNGs.
Field-level docs live on the exported types themselves - Item, ItemReq,
ItemArmour, ItemWeapon, ItemIconsResult - 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 items (ItemData)
ItemData is a plain object keyed by display name - the base type line for a
normal item (Vaal Cuirass), the unique's name for a unique (Kaom's Heart).
Each value is an Item. A normal base and a unique:
"Vaal Cuirass": {
"rarity": "normal",
"icon": "Art/2DItems/Armours/BodyArmours/Basetypes/BodyStr08.dds",
"itemClass": "Body Armour",
"category": null,
"twoHanded": false,
"req": { "str": 60, "dex": 0, "int": 0 },
"armour": { "armour": 145, "evasion": 0, "energyShield": 0, "ward": 0, "block": 0 },
"weapon": null,
"spirit": 0,
"dropLevel": 37,
"flavourText": null,
"modDomain": "Item",
"tags": ["armour", "body_armour", "default", "str_armour", "vaal_basetype"]
}
"Kaom's Heart": {
"rarity": "unique",
"icon": "Art/2DItems/Armours/BodyArmours/Uniques/KaomsHeart.dds",
"itemClass": null,
"category": "Body Armour",
"twoHanded": false,
"req": { "str": 0, "dex": 0, "int": 0 },
"armour": null,
"weapon": null,
"spirit": 0,
"dropLevel": 0,
"flavourText": ["The warrior who fears will fall."],
"modDomain": null,
"tags": []
}Every field is present on every entry, but which ones carry a value follows from
rarity:
itemClassvscategoryare mutually exclusive. A base hasitemClass(ItemClasses.Id) andcategory: null; a unique hascategory(UniqueStashTypes.Id, the stash slot) anditemClass: null. .dat has no unique-to-base-type link (see How it works), so a unique's closest-to-a-class is its stash category. The two vocabularies differ -SwordTwoHandvsTwo Hand Sword,WarstaffvsQuarterstaff. Unique flasks are the one exception: the stash lumps them all underFlask, but each is refined toLife FlaskorMana Flask, read from the base flask model itsItemVisualIdentity.AOFilepoints at (the only unique-to-base signal .dat leaks; unique equipment has a bespoke model, so no base is recoverable there).reqon a unique is always{ str: 0, dex: 0, int: 0 }- the requirement lives on the unique's (unknown) base type, so treat it as not populated, not as "no requirement". A base'sreqis the real str/dex/int to equip.armouris the base's defensive stats (armour,evasion,energyShield,wardfromArmourTypes,blockfromShieldTypes), ornullwhen the base has no defensive row - weapons, jewels, flasks, every non-armour base. A shield merges both tables into one value; an ordinary armour hasblock: 0. Anullobject means "no defensive row", a0field means "has the stat, value 0". It is alwaysnullon a unique, the same not-populated caveat asreq.weaponis the base's offensive stats fromWeaponTypes, in raw GGPK units:damageMin/damageMax(physical - base weapons deal physical only, elemental and chaos come from mods),critical(crit chance x 100,500is 5.00 %),attackTime(milliseconds, attacks per second is1000 / attackTime),rangeMax(melee strike range) andreloadTime(milliseconds, non-zero only on crossbows).nullwhen the base has no weapon row - armour, jewellery, flasks, off-hands and caster weapons (sceptres, wands, staves carry no base attack stats). Alwaysnullon a unique, the same not-populated caveat asreq.spiritis the base spirit granted (ItemSpirit.SpiritGranted), non-zero only on sceptre bases.0when the base has no spirit row, and always0on a unique (not populated).dropLevelis the level the base starts dropping at (BaseItemTypes.DropLevel). Real bases start at 1; a unique's0is not populated, like itsreq.twoHandedis derived, fromitemClassfor bases and from the weaponcategoryfor uniques, so it is correct for uniques even without a base type.flavourTextis the unique's lore, as separate lines (GGG stores explicit line breaks). It isnullon bases and on any unique without one - only uniques carry it.modDomainis the base's mod domain (ModDomainsvocabulary:Itemfor ordinary equipment,Flaskfor flasks and charms, ...). A mod only rolls on an item of the mod's own domain, so this is the first filter when joining to@poe2-toolkit/mod-extractor- match the domain, then thetags.nullon uniques and on bases whose domain has no name.tagsare the item's effective mod-matching tags (Tags.Idvocabulary:armour,body_armour,str_armour,weapon,default, ...) - the base's own tags plus the tags its item class contributes plusdefault. This is the set GGG matches a mod against within a domain, so together withmodDomainit is how items join to@poe2-toolkit/mod-extractor(see below). Empty on uniques, whose base type - and thus its tags - is not in .dat.- Bases win name clashes. Bases are added first (first displayable base for a name wins); uniques fold in after and never overwrite a base of the same name.
Joining items to mods
An item and a mod from @poe2-toolkit/mod-extractor share
two vocabularies - the mod domain (item.modDomain / mod.domain) and the spawn
tags (item.tags / mod.spawnWeights[].tag) - so the mods that can roll on an item
are a pure filter: no lookup tables, no shared code. A mod can roll when it is in the
item's domain and the first of its spawnWeights whose tag the item carries
has a positive weight. The domain filter is not optional - many mods carry a positive
default weight, so tag-matching alone leaks mods from unrelated domains (Monster,
Heist, Atlas, ...):
function compatibleMods(item: { modDomain: string | null; tags: string[] }, mods: ModData): string[] {
return Object.entries(mods)
.filter(([, mod]) => {
if (mod.domain !== item.modDomain) return false;
const gate = mod.spawnWeights.find((sw) => sw.tag === 'default' || item.tags.includes(sw.tag));
return gate != null && gate.weight > 0;
})
.map(([id]) => id);
}So a life flask (modDomain: "Flask", tags: ["default", "flask", "life_flask"])
draws only Flask-domain mods whose spawn tags include life_flask (or default),
and a body armour (modDomain: "Item") draws only Item-domain mods - never the
flask's pool, and never a Monster affix.
icons: the decoded PNGs (ItemIconsResult)
icons.icons is PNG bytes keyed by output path - each item's icon DDS path with
its extension swapped to .png (e.g. Art/.../KaomsHeart.png), which the CLI
writes as files under icons/. Icons are deduplicated, so it's one PNG per
distinct icon across all items. icons.report counts what happened:
packed decoded successfully, missing could not be served or decoded (skipped,
never substituted from a vendored asset).
CLI: write the bundle to disk
poe2-item-extract \
--patch 4.5.4.1 \
--tables ./tables/English \
--cache ./.cache \
--out ./out/itemsAll four flags are required. It writes items.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
Required tables.
buildItemsreads these GGPK tables through the source:BaseItemTypes,ItemClasses,ItemVisualIdentity,AttributeRequirements,ArmourTypes,ShieldTypes,WeaponTypes,ItemSpirit,Tags,UniqueStashLayout,Words,UniqueStashTypes,FlavourText. AcreateCdnSourcereads each fromtablesDiron demand, so your dat extract must decode all of them - a missing table throws when the build reaches it.WeaponTypesandItemSpiritare new in 0.12;ArmourTypesandShieldTypesin 0.11 - add them to your extract config when upgrading.
- Normal bases join
BaseItemTypesto itsItemClasses,ItemVisualIdentity(icon),AttributeRequirementsandArmourTypes/ShieldTypes(defences). Only displayable equipment bases (those with a visual identity) are kept;[DNT]dev placeholders are dropped. - Defensive stats come from
ArmourTypes(armour / evasion / energy shield / ward) andShieldTypes(block), both keyed on theBaseItemTypesrow index likeAttributeRequirements. A shield is in both tables and its values are merged; a base in neither carriesarmour: null, so "no defensive row" stays distinct from "row present, stat 0". - Offensive stats come from
WeaponTypes(physical damage, crit, attack time, range, reload time) and spirit fromItemSpirit, both keyed on theBaseItemTypesrow index like the defences. Values stay in raw GGPK units - crit chance x 100, attack/reload time in milliseconds - normalisation is left to the consumer. The schema ships twoWeaponTypesvariants (PoE1 and PoE2); the columns here (BaseItemType,CritChance, ...) are the PoE2 ones your dat extract must select. - Uniques come from
UniqueStashLayout(the authoritative unique list), joined withWordsfor the name,ItemVisualIdentityfor the icon andUniqueStashTypesfor the category. .dat has no unique-to-base-type link (the base a unique rolls on is decided at drop generation, not stored), so a unique carries its stashcategory(the item slot) instead of a concrete base type. The lone exception is flasks: a unique flask reuses a base flask model, so itsItemVisualIdentity.AOFilestill names the base (.../FlaskLife7Drop.ao), which refines theFlaskstash slot toLife Flask/Mana Flask. Path of Building hand-maintains this base per unique flask; here it is read straight from the model path. Unique equipment has a bespoke model, so no base leaks there. - Flavour text comes from
FlavourText, which has no foreign key to the unique: it lines up by theItemVisualIdentity/FlavourTextid with the_-suffixed art variant dropped (FourUniqueRing33_a->FourUniqueRing33), the same join Path of Building uses. - Two-handedness is derived from the item class, not from base-level tags (bases don't inherit weapon-class tags), which is the reliable signal.
modDomainisBaseItemTypes.ModDomainmapped through theModDomainsenum - the same enum@poe2-toolkit/mod-extractormapsMods.Domainthrough, so the two strings join directly. Flasks and charms share theFlaskdomain; ordinary gear isItem.tagsunion the base's ownBaseItemTypes.Tagswith the tags its item class contributes anddefault. PoE2 stores only the specific tags on a base and leaves the class tags (armour,weapon,bow, ...) to the class, and GGPK has no single table for that inheritance, so the class map is carried here and validated 1:1 against Path of Building's base tags for every base (an optional test).- Icons are kept as their raw GGPK DDS paths in the data and decoded to PNG by
buildItemIcons. An icon the source cannot serve is skipped and reported, never pulled from a vendored asset. - Flask icons ship in GGPK as a horizontal 3-frame layer sheet - the glass
container with its cap (frame 0), a middle frame, and the coloured liquid fill
(frame 2) - so the raw art is three bottles wide.
buildItemIconscomposites the fill over the container, the same as the in-game icon, so a flask icon is one full bottle with its cap, not three layers. Charms and all other items are single-frame and copied as-is.
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.
