src/data/ turns clipboard text into things the trade API understands. It is fed by a bundle
downloaded at runtime from JIRPOS/PathOfPriceCheck-Data
— nothing is baked into the binary, so a league only needs a data build.
data/updaterfetchesreleases/latest/download/manifest.jsonat startup on a worker thread, downloads and sha256-verifies each asset, installs, and maps it. Deliberately not the GitHub API: unauthenticatedapi.github.comallows 60 requests an hour per IP. It is re-checked while the application runs; when isApp::refresh_checks, in architecture.md.data/installwrites a fresh<cache>/data/<version>/directory and flips a one-linecurrentfile by rename. Never write over a live bundle — Windows will not replace or delete a memory-mapped file. Superseded directories are reclaimed byprune()at the next startup, before anything is mapped.manifest.jsonis untrusted input: asset names are restricted to a flat[A-Za-z0-9._-], https is required, and sizes are capped.data/game_datamemory-maps the ndjson and parses records on first hit, so a loaded bundle costs about a megabyte resident and no startup time. Lookups go through published fnv1a32 indices (data/index); a hit is a run, because colliding keys are kept and re-verified.en-unique-mods.ndjsonand its name index are optional: bundles published before that dataset existed simply do not have them, andhas_unique_mods()is what tells "this unique has no record" apart from "nothing here has one". The wiki attribution it is licensed on rides in the manifest'ssource.unique_mods_attributionand is written through byinstall, because an attribution that stays behind in the publisher's repo is not an attribution — Settings renders it.en-items-base.index.bin— base → the uniques that drop on it, which is all an unidentified unique states — is optional in the same way, andhas_unique_bases()is what tells a base nothing drops on from a bundle that cannot be asked.source.exchange_itemsrides the same path and is read back ashas_exchange_flags()— the bundle-level signal saying whetherBaseType::exchangemeans anything, because unlike a whole missing file an absent boolean cannot tell "no data" from "no".installwrites it only when non-zero, since a 0 would claim the opposite of what it means. See the currency-exchange section.en-mod-pools.ndjsonand its index are optional in the same wayen-unique-mods.ndjsonis — see the mod-pool section.source.mod_poolsis written through beside the other two so the installed bundle records what the build produced, but it is not what gates anything: a whole missing file says that already, andhas_mod_pools()reads the index.- The mod pools are the one thing here that does not start from an item in hand.
en-mod-pools.ndjsonis, per mod domain, every modifier that domain can spawn — the whole set, whether or not anything is holding one. It exists so a modifier can be rated in Settings before it has ever been rolled; see map-check.md, which owns the feature. The pool describes and never gates. What it lists is what spawns naturally, which is strictly less than what an item can print: an essence, a craft, a veiled mod or Harvest all put modifiers on an item whose weights would never have produced them, and the published list is trimmed by naming conventions besides. A printed modifier no entry covers is normal, renders as it always did, and is rateable on the spot. Nothing may use a pool to reject a line, hide one, or decide it failed to parse. One record —PoolMod— is one wording-set, not one roll: the tiers of an affix print the same wordings and a verdict attaches to a wording, so they collapse, andmin/maxspan the lowest tier's floor to the highest tier's ceiling in displayed units.tiersandmodsare provenance for the debug log. APoolStatwith notrade_idis the ordinary case and not a gap — the pool is rated, not searched, and a wording trade indexes under two hashes is one the build refuses to pick between, here as everywhere else. Two lookups, because the feature needs both directions:mod_pool(domain)is the whole pool, for the settings list, filled by one pass over the file the first time it is asked for (a few hundred records, and a pool is only ever wanted entire);find_pool_mods(domain, wording)goes the other way, from a wording resolved off an item, through an index keyed on"{domain}::{wording}". The domain is part of the key, because a map, a chart and a heist area are separate pools — a map and a chart word 42 modifiers identically, and 58 of the heist pool's 90 entries word their printed stat as a map's does — and an answer mixing them would offer a chart's affix for a map. Which domain an item rolls from ismod_domain_for(base, item_class), notBaseType::mod_domain. The base is asked first and its class answers where it cannot, and that fallback is not a nicety: trade lists all 491 maps under one entry whose game row is a stand-in sitting with the stackable currency in domain 43, so a map's own record deliberately states no domain at all and theMapsclass is what knows the answer is 5. The other way round would be wrong — a class holding genuinely different things (Jewels covers two domains) publishes none, and only a base can answer for those. data/lexiconis every word the client prints, for one language: the section labels (Item Class,Rarity,Requirements,Sockets,Note), the flag lines, the rarity and influence names, the mod-type suffixes and Advanced Mod Descriptions generation words, the property labels, the usage-note needles, the base-line decorations (Superior,Blighted Map,(Tier,Foulborn,Vaal) and the chart shapes.item/parseused to compare each of those against a literal, which is the whole of why the app reads an English client and nothing else. It is game data, so the bundle is the authority —<lang>-lexicon.json, read byGameData::openand reachable asGameData::lexicon(). The English table is compiled in as the default because every bundle published so far carries no lexicon at all, and a lexicon overlays rather than replaces: an entry it does not name stays English, so a partial translation degrades to English instead of to a blank rule that matches every line.has_lexicon()is what says which of the two happened. Three shapes, and the difference is load-bearing:Termis a single string, aTermListis a set (the fixed-order ones are indexed by an enum —Raritiesbyitem::Rarity,ModSuffixesandGenerationsbyModType— so a language's list has to keep the order and a replaced list is replaced whole; five of them are ordered by a trade filter key or option id instead —ChartShapes,UltimatumChallenges,UltimatumRewards,HeistJobsandHeistObjectiveValues— which is what lets the client's own words be sent as the id the site wants), and the property and item-class tables are keyed the other way round, printed label to key, so a translated one replaces the English outright. Three entries here are not the client's wording at all:Term::SanctumEffectPrefix, theHasa sanctum boon or affliction's stat is worded with, where the item prints the name alone under aMinor Boons:label; andLogbookFactionPrefix/LogbookAreaPrefix, theHas Logbook Faction:andHas Logbook Area:an Expedition Logbook's destination is searched under, where the item prints the two as bare names in a block of their own. They live here because a translated bundle translates the stat along with everything else and the lookup is by exact wording, so they are per-language in the same way the rest of this table is — and because keeping the join in the lexicon is what keeps a compiled-in list of the four expedition factions out of the parser. See strategy-logbook.md. An empty entry never matches, deliberately:starts_with("")is true of every line, andModType::Explicithas no generation word of its own.parse_itemandlooks_like_itemtake a lexicon and have no default. The language is the one input this layer cannot infer, and a default is precisely the bug — the app would go on reading English after the user said their client is not. Tests state it once, intests/parse_en.hpp.data/stat_normalizeturns+42 to maximum Lifeinto# to maximum Lifeand its fallbacks.NORMALIZATION.mdin the data repo is normative and this must reproduce it exactly — a divergence does not crash, it silently mismatches a mod and returns a confident wrong price.normalize_testreplays the conformance vectors shipped with every data release. A modifier can roll over a list instead of an interval, and the game prints that range the same way — "Maximum number of Sentinels of Purity (Animated Weapons-Holy Armaments) is Doubled" rolls over the minion skill gems.scan_numberscannot see it: there is no number in front of the parenthesis to carry the bounds.strip_named_rangesdrops it, and the roll is the name left in the wording, which is what trade indexes. It only ever adds candidates — a wording whose parenthesis is genuinely part of it,Unique Monsters (Blood-Filled Vessel), is enumerated as printed first and never reaches the stripped form.data/stat_matcherjoins clipboard lines into one modifier and resolves it to a stat and a roll. Mod type is the primary disambiguator: explicit/implicit/fractured/crafted/enchant variants share a wording and differ only by trade namespace.kMaxModLinesis 8, not the two a hybrid needs: a modifier that enumerates its alternatives is as long as its list — Bound Fate's "Every # seconds, gain one of the following" is seven lines and the class-connection jewel is eight, which is the longest wording in the data. Below the true maximum the join can never be built and every line of such a modifier is reported unrecognised on its own. Reaching further is safe because a shorter join always wins: the loop returns at the first join that resolves, so the cap only bounds a failed match. Two separate negation concepts —matcher.negate(the wording is inverse; store the roll canonically) andtrade.inverted(the trade site indexes the opposite sign; applied at query-build time, not here). An inverse wording's Advanced Mod Descriptions range is printed high to low —64(65-60)% reduced Effect of Curses on you during Effect— soNumberToken::bound_min/maxare ordered at parse time, not taken as printed. Negating an already-descending pair leaves it descending, and a filter wanting at least -60 and at most -65 matches nothing.