Model and feed reference

The shapes afkj-parse builds from the raw tables: the locale-independent model it dumps to out/build/model/, and the JSON feed it publishes to out/api/ for stargazer, with out/internal/ for our tools. Covers projection, text rendering, the gates, the diff against the last imported feed, and parity with afkj-data-viewer.

Contents

  1. Overview
  2. The model
  3. The feed
  4. Projection
  5. Text rendering
  6. The model and feed gates
  7. The diff and the baseline
  8. Parity with afkj-data-viewer (record)
  9. Known quirks, revisited

1  Overview

1.1 The pipeline

./afkj-parse build reads raw tables, joins them into a model, projects the model into the feed, and publishes the feed only if every gate passes. The source is this repo's extract output (out/raw/, the default) or an AFKJourneyData checkout (--source ../AFKJourneyData); both are described in Raw data §2.

out/raw/ or ../AFKJourneyData/ [READ-ONLY] │ ▼ source gate (S1–S9) layout, tables, rows and consumed columns, array pool, season, locales, partitions, contract keys │ passed ▼ model (model/build.py) ───────────────► out/build/model/ (written whenever the model is built) │ ▼ model gate (M, M1–M18) identities, slugs, core slots, ladders, refinements, keys, config lists, buffs … │ passed, and no --limit ▼ feed, built in memory (feed/build.py, feed/project.py, feed/text.py) │ ▼ feed gate (F1–F10, F12–F15) stargazer's importer contract, its fields and names, and out/internal/ │ passed ▼ diff gate (D1–D4) ─────────────────────► out/build/diff.md (against out/baseline/; --no-diff skips it) │ passed ▼ publish ───────────────────────────────► out/api/, out/internal/ (each replaced as one tree) report ────────────────────────────────► out/build/report.json, report.md (every run)

A gate passes when it has no error finding; with --strict, warnings fail it too. A failed gate stops the stages after it, and the previous out/api/ and out/internal/ stay in place. The model dump is written as soon as the model exists, before its gate is judged, so a failed run still leaves a model to inspect. --limit N parses only the first N heroes and never writes the feed.

1.2 What each output is for

OutputPathRead byGuarded by
Model dumpout/build/model/ (--work)People and LLMs debugging a hero; nothing reads it backWritten after the source gate; the model gate is reported beside it
Feedout/api/ (--out)stargazer's importersSource, model and feed gates, all passed
Internal filesout/internal/, beside the feedThe diff, accept and the tools in tools/ and research/Published with the feed
Reportout/build/report.json, report.mdPeople, CIAlways written
Diff reportout/build/diff.mdPeople, before stargazer importsWritten when the feed gate passes (section 7)
Baselineout/baseline/ (--baseline)The next build's diffWritten only by ./afkj-parse accept (section 7.4)

report.json also records lists (the resolved config.json hero lists), config {path, sha256}, and, when the feed was published, feed {out, generatedAt, sha256, internalSha256}.

The projection reproduces the output of afkj-data-viewer's scripts/export-api.ts (contract v1) exactly, plus a few game ids; section 8 records how that was verified before the viewer was retired and deleted. out/api/ is that projection in stargazer's vocabulary (section 3).

2  The model

2.1 Conventions

The model is plain dicts and lists, built by src/afkj_parse/model/. There is no types module: this section is the shape documentation. Field names are camelCase. The model is locale-independent: every piece of text is held as a LocalizedRef and resolved per locale only when the feed is built.

A LocalizedRef (model/refs.py) is one of:

FormTestExample
A localization keyAn ASCII decimal string of 1 to 20 digits (is_key). Digit count is not a test; some real keys have 14 digits."89574582654232036"
A literalAny other string. Usually raw Chinese on dev rows, kept as the game stores it. "普通攻击(不用翻译)"
Nothingnull: the field is absent or not a string.null

Key semantics are in Raw data §5. Fields holding a ref end in Ref (nameRef, titleRef, descriptionRef, tipRef, …). Every key the model references is also recorded in a registry by the kind of record that holds it, so the model gate can check that each resolves (M7).

Numbers read from tables go through the viewer's num rule: a number stays, anything else becomes 0. Booleans such as isShow and isMelee are normalised to true/false.

2.2 The dump

write_dump (model/build.py) replaces out/build/model/ with:

FileHolds
heroes/<codename>.jsonOne hero record per parsed hero, named by Unit.Name
artifacts.jsonThe artifact list
phantimals.jsonThe phantimal list
charms.jsonThe charm file
keywords.json, factions.jsonThe two dictionaries
buffs.json, controlEffects.jsonThe buff records and control effects
meta.json{source, season, heroCount}: the source's version record, the live SeasonType, and the number of heroes

The in-memory model is the same object: {season, heroes, artifacts, phantimals, charms, keywords, factions, buffs, controlEffects}. season is the live season's SeasonType (the highest online Season row above 100; 108 in build 14368).

// meta.json, build 14368
{ "source": { "kind": "extract", "label": "extract 1.6.11, build 14368", "gameVersion": "1.6.11",
              "dataBuild": 14368, "extractor": "0.2.0" },
  "season": 108, "heroCount": 131 }

2.3 Hero

One record per hero candidate (a playable Unit with an enable flag, or listed in forceParse), in unit-id order (model/heroes.py). The roster rules are in Raw data §8.3.

FieldTypeFrom
codenamestringUnit.Name, trimmed
unitIdintUnit.ID
nameRefLocalizedRefThe name chain: DisplayName when it is a key, else the TavernPool banner name, else the Chinese literal's key by reverse lookup in cn, else the literal (Raw data §8.2)
titleRefLocalizedRefUnit.DisplayTitle
rarity, jobas storedUnitRarity, UnitJob
heroTagIdint | nullHeroTagID when above 0 (the faction)
gender, seasonType, damageTypeas storedGender, SeasonType, DamageType
isMeleeboolIsMelee is true
enabledboolThe unit's enable flag; false means the hero is parsed only because forceParse lists it
scriptstringUnit.Script, else the codename: the hero's battle script, whose settings are in HeroScripts (Raw data §10.5)
skillSlotslist of slotsThe hero's skill partition, grouped
passiveBuffslist of {buffId, displaySlot}Records filed with this hero by name, and the slot their name points to (null: hero-wide); see section 2.10
scriptBuffIdslist of intThe buff records the hero's script names, sorted; empty when the source has no HeroScripts (the dump); see section 2.10
// heroes/rs.json (Lumont), skillSlots abridged
{ "codename": "rs", "unitId": 11, "nameRef": "89574582654232036", "titleRef": "8225152550786035586",
  "rarity": 1, "job": 1, "heroTagId": 2, "gender": "male", "seasonType": null, "damageType": "AD",
  "isMelee": true, "enabled": true, "script": "rs", "skillSlots": [ … ], "passiveBuffs": [ … ],
  "scriptBuffIds": [3, 1106, 1107, 1108, 1111, 1112] }

2.4 Skill slot

build_slots (model/skills.py) groups the partition's rows by (DisplaySlot || Slot, DisplayName) in first-seen order, then sorts the slots by displaySlot (stable, so ties keep first-seen order). What the slot numbers mean is in Raw data §9.1.

FieldTypeMeaning
category"kit" | "internal" | "special-modes" special-modes for DisplaySlot 4001 (the *_imba_1 Honor Duel / Dura's Trials variants); internal for sub-trigger slots (DisplaySlot 100, 101, 102) or when every level is dev or hidden; otherwise kit. Only kit slots reach the feed.
displaySlotintThe group's bucket: DisplaySlot, or Slot when DisplaySlot is 0. The panel's six are 1 ultimate, 2 skill2, 3 skill3, 4 mastery, 5 ex, 6 awakening.
titleRefLocalizedRefThe first level's DisplayName
skillNamestringThe first level's SkillName ("" if absent)
skillTypeCodeslist of stringFrom UnitBriefBattleConfig.Skill<N>Type for this DisplaySlot: the closed vocabulary A_ad A_ap A_ctrl C_buff C_debuff D_heal D_shield, first-seen order, deduplicated (Raw data §9.5). Unknown codes are dropped and reported (M13).
knockBacknumberThe first level's KnockBack; the viewer reads a slot's physics from its first row
levelslist of levelsSorted by (skillLevel, skillId); the EX slot also carries the refinements after its ladder

2.5 Skill level

FieldTypeMeaning
skillIdintSkillID. The Refine 2 level keeps the Rivalry row's id; the Refine 4 level is -1.
skillLevelintSkillLevel. Refinements are numbered on from the EX slot's last level.
isDevboolThe dev marker 不用翻译 appears in the row's DisplayName, Description or SimpleDescription (Raw data §9.2)
isShowboolIsShow is not false (absent counts as shown)
descriptionRefLocalizedRefDescription; null on Refine 4
sArgslist of numberSArg0 up to the row's highest SArgN field, found dynamically; a missing index reads 0
plusRationumberPlusRatio (0 on refinements)
buffNamestring | nullBuff
buffIdint | nullBuffID when above 0. On a kit or special-modes level with no id, the id of the one record named Buff, if the name is unique (section 2.10)
unlockAwakenLevelintUnlockAwakenLevel; used by awakeningLv2Blocklist
cd, initCd, globalCd, costMp, gainMp, victimMp numberThe row's CD, InitCD, GlobalCD, CostMP, GainMP, VictimMP, as the game stores them (SKILL_NUMBERS): cooldown and first cooldown in seconds (9999 and above: no timer, as passives), the lock after any cast, the energy a cast costs, the energy the skill gives its caster, a multiplier on the energy its target gains (1 normal, 0 none). Null on a Refine 4, which has no row. Artifact, phantimal and charm levels carry them too
rangenumber | nullThe row's first TargetShapeArgs value (SKILL_RANGE_FIELD): the range in tiles the skill panel shows. Null when the row has none (most awakenings), and on a Refine 4. With the numbers above, SKILL_NUMBER_KEYS
refineTiernull | 2 | 4Set on the refinement levels only
refineStatsAddedlist of {code, value, nameRef}Refine 4 only: the PVP stats that are 0 at EX level 28 and positive at 29. The candidates are the game's: UnitAttr rows flagged IsPvPAttr that ExclusiveHeroLevel has a column for, in column order; nameRef is the stat's name (UnitAttr.Tips)

The refinements come from ExclusiveHeroLevel (Raw data §12.3, §12.4). Refine 2 is the class Rivalry skill: the lowest EX level with ExtraSkill set names a row in the single Rivalry caster's partition. Refine 4 is the set of new PVP stats. Refine 1 and 3 are skipped, as the in-game UI skips them. Lumont's EX slot ends like this:

{ "skillId": 101, "skillLevel": 5, "isDev": false, "isShow": true,
  "descriptionRef": "17598539104553094781", "sArgs": [0.6, 8, 0.15, 0.03, 20, 0, …],
  "plusRatio": 0, "buffName": null, "buffId": null, "unlockAwakenLevel": 0, "refineTier": 2, "refineStatsAdded": [] },
{ "skillId": -1, "skillLevel": 6, "isDev": false, "isShow": true, "descriptionRef": null, "sArgs": [],
  "plusRatio": 0, "buffName": null, "buffId": null, "unlockAwakenLevel": 0, "refineTier": 4,
  "refineStatsAdded": [ {"code": "PVPRES", "value": 10}, {"code": "PVPBLOCK", "value": 15},
                        {"code": "PVPCritD", "value": 10} ] }

A typical hero has six kit slots, a few internal ones (the basic attack, per-skill "skill strength" rows, sub-triggers) and one special-modes slot. In build 14368 the 131 parsed heroes hold 775 kit, 752 internal and 120 special-modes slots.

2.6 Artifact

model/artifacts.py takes the six permanent spell artifacts (SeasonType 1 and FairyType 1) and every Fairy row of the live season. Each anchors on the first SkillID of its Fairy row in the leaderavatar caster (4009), then takes every row of that file sharing the anchor's (Slot, SkillName) (Raw data §14). The list is sorted pre-season first, then by fairyId.

FieldTypeMeaning
fairyIdintFairy.FairyID
seasonTypeintFairy.SeasonType (1 for the permanent set)
set"pre-season" | "season"Permanent six, or the live season
nameRefLocalizedRefFairy.DisplayName
effectNameRefLocalizedRefThe first skill level's DisplayName
skillNamestringThe skill family's SkillName
maxLevelintFairy.MaxLevel
statBonuseslist of {code, value}The highest FairyLevel row's AttrParam1..4/AttrValue1..4; the code is the param array's first element, zero values are skipped
levelslist of {skillId, level, descriptionRef, sArgs, plusRatio} The family's rows, sorted by SkillLevel

2.7 Phantimal

model/phantimals.py takes exactly the units named by CampPet.CampPetUnitID whose skill file starts with camppet (Raw data §15). Only shown, non-dev rows are kept, grouped by DisplaySlot. The list is sorted by faction, then unit id.

FieldTypeMeaning
unitIdintUnit.ID
codenamestringUnit.Name
nameRefLocalizedRefUnit.DisplayName
factionint | nullUnit.HeroTagID, never CampPet.Camp
campSlotintCampPet.Camp, a slot number, not a faction
isMeleeboolUnit.IsMelee is true
damageTypestring | nullUnit.DamageType, as stored
attackRangenumber | nullThe range of the basic attack, the row at slot 0. That row is hidden, so it is not among skills
seasonTypeintThe live season
skillslist of {displaySlot, titleRef, skillName, levels}One per DisplaySlot, ascending; levels as for artifacts, sorted by SkillLevel

2.8 Charms

model/charms.py reads GemSuit (hero, quality, SkillID = base*100 + level) against the gem caster (4022) and GemQuality (Raw data §16). Unlike the other parts, charms are one object:

FieldTypeMeaning
tierNameRefslist of 4 keysThe GemQuality.Name of the first quality that unlocks each of levels 1 to 4
charms[].charmIdstringSkillName minus the gemskill_ prefix, e.g. ep6liveUpgrade; sorted by this id
charms[].skillNamestringThe level-1 row's SkillName
charms[].heroIdslist of intHeroes whose GemSuit rows collapse onto this base, ascending
charms[].levelslist of 4 {skillId, level, quality, descriptionRef, sArgs, plusRatio} One per level; each keeps its own row, since tiers can share a description and differ only in SArgs

2.9 Keywords and factions

PartShapeFrom
keywords{keyword: {key, tipRef}}SkillKeywords: Keyword and DisplayTips. The [kw]label[/] tags in descriptions name these keys (Raw data §11.4).
factions{HeroTagID: nameRef}UnitTag: Tag and DisplayName. In the JSON dump the ids become string keys.
// keywords.json                                        // factions.json
{ "weakest": { "key": "weakest",                        { "1": "10677098034559408920",
               "tipRef": "15060488439880168031" }, … }    "2": "8229542281409421451", … }

2.10 Buffs and control effects

model/buffs.py reads SkillBuff, SkillBuffUI and, when the source has it, HeroScripts (Raw data §10). buffs is {"<id>": record}, keyed by SkillBuff.ID, holding only the records some hero links, files by name or names in its script (1,158 of 3,616 in build 14368). controlEffects is {code: {labelRef, isBuff, popupType}}, one entry per SkillBuffUI row (17), keyed by BuffEffectName, with PopupText as the label.

A record holds raw fields only: no tags, marker or visibility classes, and no inferred targets.

FieldTypeFrom
id, nameint, stringID, Name
durationnumber | nullTime when above 0, else null; 9999 means permanent
isDot, dotDmgType, dotIntervalbool, string | null, number | null IsDot, DotDmgType, DotInterval
stats{code: number}The non-zero values among the 21 base stat codes (BUFF_STAT_BASES) and their _a/_x variants that the table has
controlEffectslist of stringControlEffects
addSource, shieldTypestring | nullAddSource, ShieldType
canDispel, clearOnDeath, clearOnCasterDeath, battleEndContinued, hidePopupboolThe matching fields, true only when stored as true
maxStackTimes, buffTypenumberMaxStackTimes, BuffType (0 when absent)

Linking rules:

The builder reports a row whose buff is not in SkillBuff, or whose Buff name differs from its BuffID's record, as M17 warnings (section 6.1).

// buffs.json (model dump), one record          // controlEffects.json
"1701": { "id": 1701, "name": "rabbitbow_ult",    { "stun": { "labelRef": "8916278348205271970",
  "duration": 6, "isDot": false,                             "isBuff": false, "popupType": 14 }, … }
  "dotDmgType": null, "dotInterval": 0,
  "stats": { "ARM_x": -0.3 }, "controlEffects": [], "addSource": "owner", "canDispel": true,
  "maxStackTimes": 1, "shieldType": null, "clearOnDeath": true, "clearOnCasterDeath": false,
  "battleEndContinued": false, "hidePopup": false, "buffType": 0 }

3  The feed

The build projects every file once in the game's own vocabulary (feed/project.py, section 4), then feed/contract.py splits that projection into two trees. out/api/ is stargazer's contract, version 2: only what stargazer shows, under stargazer's names and encoding. out/internal/, beside it, keeps the game's identities, raw numbers and buff records for the diff and our tools. Nothing in out/api/ names a game key, code or id: the feed gate holds it to contract.SCHEMA (F14) and to the contract's name tables (F15).

3.1 Layout

out/api/                   stargazer's contract, version 2
  manifest.json
  <locale>/                one folder per locale, by its standard code (de, ja, ko, zh-tw …)
    skills.json            every hero, keyed by slug, with the glossary and the panel terms
    artifacts.json
    phantimals.json
    charms.json
  skillNumbers.json        what the skill panel shows per level, locale-independent (section 3.8)
out/internal/              the game's vocabulary, for the diff and the tools
  keys.json                every stargazer id and name in out/api/, mapped to the game's (section 3.9)
  buffs.json               the buff registry (section 3.10)
  skillNumbers.json        the game's raw numbers per level (section 3.11)
  en/skills.json           the English heroes as the game names them (section 3.12)

Files are written with two-space indentation. With build 14368, 16 locales and 129 heroes: 66 files in out/api/ (15 MB) and 4 in out/internal/ (2 MB). Each tree is replaced whole on publish, so no stale file survives. What contract.py turns into stargazer's vocabulary:

The game'sIn out/api/Table
Locale folders ge jp kr po sp tw vnde ja ko pt es zh-tw vi; the other nine as they areLOCALE_CODES
Glossary keys (uncontrol, hpCut, dodg …)The English word players read (unaffected, max-hp-reduction, dodge-chance …), the owner's names of 2026-10-23KEYWORD_NAMES
Stat markers <ATK>, <HP>{atk}, {hp}STAT_MARKERS
Panel template blanks ${1}, ${2}{cooldown}, {initialCooldown}, {range}, {energy}TERM_BLANKS
Artifact stat codes (ATK, ARM, ATKHAST …)Stargazer's stats keys (atk, phys-def, atk-spd …)STAT_KEYS
Damage AD, APphysical, magicDAMAGE
SeasonType 108Season 8 (permanent artifacts: 0)SEASON_BASE
Camp slotThe phantimal's id; slot 5 counts for both Hypogean and Celestial (factions)DUAL_CAMP_FACTIONS
Charm names (ep8heal)s8-10: the season and the charm's place in the game's own order, its skill idnumbered; keys.json
Artifact fairyIdid: the place in its set by fairyId, from 1 (permanent) or 7 (season)numbered; keys.json
cd and initCd of 9999 and above; a range from 15 tilesNo cooldown; "global"NO_TIMER, GLOBAL_RANGE_AT

A glossary key, stat code or damage code with no entry in its table fails F15, and a marker or blank left unconverted fails F4, so nothing ships under its game name. Only glossary entries the exported text uses are exported (F15 checks that too).

3.2 manifest.json

{ "generatedAt": "2026-10-24T07:08:49.817Z",
  "locales": ["de", "en", "es", …, "zh", "zh-tw"],
  "localeNames": { "de": "Deutsch", "en": "English", …, "zh-tw": "繁體中文" },
  "heroCount": 129, "artifactCount": 18, "phantimalCount": 5, "charmCount": 19,
  "contractVersion": 2, "season": 8 }

generatedAt is UTC ISO 8601 with milliseconds, as JavaScript's toISOString() prints it. The data build the feed came from is in keys.json, not here.

3.3 _meta

Every locale file starts with _meta: generatedAt, locale, season and the file's count (heroCount, artifactCount, phantimalCount, charmCount). skills.json's also holds terms and keywords (section 4.5).

3.4 Heroes: skills.json

{_meta, heroes: {slug: entry}}, sorted by slug.

KeyTypeMeaning
slugstringSection 4.3
name, titlestring | nullResolved names (section 5.3)
skills{key: skill}Keys from ultimate, skill2, skill3, mastery, ex, awakening, present only when the slot has a level or a refinement
skills.*.namestring | nullThe skill's name
skills.*.levelslist of {level, description}Sorted by level; description is null when the text is missing
skills.*.refinementslist of {tier, description}EX only, only when present; sorted by tier
// en/skills.json, one hero, abridged
"lumont": { "slug": "lumont", "name": "Lumont", "title": "Benign Horn",
  "skills": {
    "ultimate": { "name": "Lumont's Charge",
      "levels": [ { "level": 1, "description": "… he deals {atk}[[240%]] damage to all adjacent enemies and
                    [[taunts|taunt]] them for [[3]]s. Lumont is [[unaffected|unaffected]] …" }, … ] },
    "ex": { "name": "Totem Slam", "levels": [ … ],
      "refinements": [
        { "tier": 2, "description": "In Rivalry Mode, when HP drops below [[60%]], reduces damage taken by [[15%]] …" },
        { "tier": 4, "description": "Refine 4: In Rivalry Mode, increases 10 Crit Resist, 15 Ranged DEF, 10 Crit DMG DEF." } ] },
    … } }

3.5 artifacts.json

{_meta, artifacts: {slug: entry}}, in model order (pre-season first, then by fairyId). slug, set and name are as in the model, with the name resolved; id and season are stargazer's (section 3.1); statBonuses is [{stat, value}] with stargazer's stats keys; levels is [{level, description}].

{ "slug": "awakening", "set": "pre-season", "name": "Awakening Spell", "id": 1, "season": 0,
  "statBonuses": [ {"stat": "atk", "value": 4.2}, {"stat": "phys-def", "value": 26.4}, … ],
  "levels": [ { "level": 1, "description": "[[5]]s after the battle starts, summons Radiant Life to restore
                [[3]] [[weakest]] allied heroes' max HP by [[7%]] every [[10]]s." }, … ] }

3.6 phantimals.json

{_meta, phantimals: {slug: entry}}, in model order: slug, name, faction (the faction's name in the locale), id, factions (stargazer's keys for every faction it counts for), damage, range (its attack range in tiles), season, and skills, [{name, levels: [{level, description}]}] ordered by DisplaySlot.

{ "slug": "wedge-of-matter", "name": "Wedge of Matter", "faction": "Celestial", "id": 5,
  "factions": ["hypogean", "celestial"], "damage": "magic", "range": 20, "season": 8, "skills": [ … ] }

3.7 charms.json

{_meta, tierNames, charms}. tierNames is the four resolved tier names (EN: Elite, Epic, Legendary, Mythic). charms is a list, not a map, in id order:

{ "id": "s8-19", "heroes": ["yamato-gabumon"],
  "tiers": [ "For every [[5]]s alive in battle, increases ATK by [[4%]] and Phys & Magic DEF by [[6%]], …", …3 more ] }

heroes is the sorted slugs of the charm's heroes, leaving out blocklisted ones. tiers is four descriptions; a missing one is "", which the gate rejects (F6). A charm keeps its number for the season unless the game puts a new charm before it; the diff warns when an existing charm's number changes (D6), since stargazer's charm tags are keyed by it.

3.8 skillNumbers.json (out/api/)

What the game's skill panel shows for each hero level, once for every locale:

{ "_meta": { "generatedAt": "…", "season": 8 },
  "heroes": { "eironn": { "initialEnergy": 200, "skills": {
      "ultimate": [ { "level": 1, "cooldown": 2, "initialCooldown": 0, "range": "global" }, … ], … } } } }

The levels are exactly those skills.json lists (F13). cooldown is left out when the skill has no timer, and initialCooldown with it; 0 stays, an instant skill. range is the tiles, "global" from 15 up, and absent when the row has none. initialEnergy is the hero's energy at battle start (startMp, 0 when unset).

3.9 keys.json

The map from out/api/'s ids and names back to the game's, for the diff and the tools:

{ "_meta": { "generatedAt": "…", "season": 8, "source": { "kind": "extract", "dataBuild": 14368 } },
  "heroes": { "lumont": { "codename": "rs", "unitId": 11 }, … },
  "artifacts": { "awakening": { "fairyId": 1 }, … },
  "phantimals": { "wedge-of-matter": { "codename": "…", "unitId": …, "campSlot": 5 }, … },
  "charms": { "s8-1": "ep8basic", …, "s8-19": "ep6liveUpgrade" },
  "keywords": { "unaffected": "uncontrol", … } }   // the 30 glossary keys the text uses

source.kind is extract or dump; dataBuild is the data build number, or null when unknown. tools/stargazer_tags.py charms() reads stargazer's charm files back into game names through it, and tools/battle_scripts.py takes codenames from it.

3.10 buffs.json

The buff registry for the heroes in the feed (feed/build.py _buffs, feed/project.py hero_buffs), in out/internal/. It is locale-independent apart from the control-effect labels, which are keyed by the game's locale codes. About 690 KB for build 14368.

KeyShape
_meta{schemaVersion: 1, generatedAt, heroCount, buffCount, season, source}
heroes{slug: {codename, slots: {key: {attached: [{level, buffId}], inferredPassives: [id]}}, inferredHeroWide: [id], scriptBuffs: [id]}}, sorted by slug
buffs{"<id>": record}, the model's records, only those some hero in this file references
controlEffects{code: {isBuff, popupType, label: {locale: text}}}, every SkillBuffUI code, labelled in every emitted locale

Lyca (rabbitbow) shows why the file exists: rabbitbow_ult (id 1701), filed under her ultimate, carries stats {ARM_x: -0.3}, a 30% Phys DEF cut her text never states.

"heroes": { "lyca": { "codename": "rabbitbow",
  "slots": {
    "ultimate": { "attached": [ {"level": 1, "buffId": 1707}, …, {"level": 5, "buffId": 1707} ],
                  "inferredPassives": [1701] },
    "skill2":   { "attached": [ {"level": 1, "buffId": 1702}, … ], "inferredPassives": [1703, 1708] },
    "skill3":   { "attached": [], "inferredPassives": [1704, 1705] },
    "ex":       { "attached": [], "inferredPassives": [1706, 1709] } },
  "inferredHeroWide": [], "scriptBuffs": [1701] }, … },
"controlEffects": { "stun": { "isBuff": false, "popupType": 14, "label": { "en": "Stunned", "zh": "眩晕", … } }, … }

Build 14368: 129 heroes, 1,154 records, 17 control effects. 36 codes on records have no popup entry; they are script-internal, and F10 lists them as info.

Against afkj-data-viewer's buff data For the same build, the passive sets and their slots match for 121 of 132 heroes, and the 53 records both share have identical fields. The other 11 heroes differ by 12 records that only a sub-trigger or dev row links (e.g. dragonBow_atk2stunBuff, via dragonbow_atk2b). The viewer dropped these; parse files them by the naming rule (here under skill 2).

3.11 skillNumbers.json (out/internal/)

Each skill level's cooldowns, energy and range as the game stores them, once for every locale (feed/project.py hero_numbers, artifact_numbers, phantimal_numbers); out/api/'s file (section 3.8) is derived from it. Each hero entry also carries the hero's energy from its Unit row (HERO_ENERGY): startMp (energy at battle start; 0 for 66 heroes, up to 1000 for three), baseMp (the maximum, 1000 for all) and victimMpCoef (a multiplier on the energy it gains when hit; 1 for most, from 0 to 2). About 630 KB for build 14368.

{ "_meta": { "schemaVersion": 1, "generatedAt": "…", "season": 108, "source": { … } },
  "heroes": { "eironn": { "codename": "…", "startMp": 200, "baseMp": 1000, "victimMpCoef": 1.2, "skills": {
      "skill2": [ { "level": 1, "cd": 7, "initCd": 1, "globalCd": 1, "costMp": 0, "gainMp": 56,
                    "victimMp": 1, "range": 1 }, … ], … } } },
  "artifacts": { "<slug>": [ { "level": 1, "cd": …, … } ] },
  "phantimals": { "<slug>": [ [ { "level": 1, … } ] ] } }   // one list per skill, in phantimals.json's order

3.12 en/skills.json (out/internal/)

The English heroes in the game's vocabulary: the same entries as out/api/en/skills.json plus each hero's codename and unitId and each slot's skillTypeCodes (only when non-empty), with the game's glossary keys, <ATK> markers and ${1} blanks, and every resolvable glossary entry. The diff takes skill-type changes from it; the Debuff and Haste nets and research/tempbuff_targets.py read its text.

4  Projection

Projection turns the model into one locale's entries (feed/project.py), and feed/build.py assembles every locale into a payload of {relative path: document}, which the feed gate checks before publish writes anything. Each function ports the matching one in export-api.ts.

4.1 Heroes

4.2 config.json lists

ListEffect on the feed
heroes.blocklistThe hero is removed before slugs are assigned: no entry, not counted, not listed on any charm. The model gate's hero checks skip it too. An entry with "permanent": true (a hero not in the public game) is never suggested for removal.
heroes.awakeningLv2BlocklistAwakening levels with unlockAwakenLevel of 2 or more are left out of that hero's awakening key.
heroes.forceParseLets a hero with no enable flag into the model (and so the feed).
locales.skipLocales never emitted (cn, a near-duplicate of zh).

Entries are {"hero": …, "note": …}, where hero is an English name, codename or Unit ID. Each must match exactly one hero, or the run stops with exit code 2.

4.3 Slugs

Slugs come from afkj_common.rules.assign_slugs, the one place both capabilities decide them, so image files and feed entries join by slug. The input is each entity's English name, resolved as in section 5.3.

4.4 Locales and their names

The emitted locales are every locale the source has, minus locales.skip, sorted. --locales en,zh narrows that set further; it never adds a skipped or missing locale, and a set without en is refused before anything is built (stargazer requires it, and the feed gate compares every locale with it). Slugs are always from English, so every locale has the same keys.

manifest.localeNames gives each locale's native name: the text of a fixed per-locale key (LOCALE_NAME_KEYS) in that locale's own dictionary. zh and tw share one "Chinese" key, so they are named explicitly (简体中文, 繁體中文). A locale with no known key, or whose dictionary lacks it, is named by its upper-cased code.

4.5 Keyword glossary and slot terms

"terms": { "ultimate": "Ultimate", "exclusiveEquipment": "Exclusive Equipment",
           "cooldown": "Cooldown: {cooldown}\nInitial Cooldown: {initialCooldown}", "range": "Tiles: {range}",
           "rangeGlobal": "Global", "initialEnergy": "Initial Energy: {energy}" }   // out/api/en

5  Text rendering

feed/text.py reproduces afkj-data-viewer's format.ts exactly; each function names the format.ts function it ports. The feed's consumers are JavaScript, so numbers follow JavaScript's rules. tests/test_parse_feed.py checks the number formatting against Node itself on 6,000 values, including near-ties and exponent ranges, when node is installed.

5.1 The feed's text encoding

Source textFeed textNotes
[kw]label[/][[label|kw]]When kw is in the glossary and the trimmed label has no |
[kw]label[/][[label]]Otherwise
{SArg0}, {PlusRatio}, …[[value]]formatValue
{SArg1%}[[value%]]formatPercent
<ATK>, <HP>verbatimAn uppercase-led tag is a stat code (Raw data §17)
{Field} the row lacks[[?]]The feed gate rejects it (F4)

Fields are looked up as lookupField does: SArgN by index into sArgs (out of range is missing); any other name as lower-first, as given, or all lowercase. Hero rows hold sArgs, plusRatio and knockBack; artifact, phantimal and charm rows hold sArgs and plusRatio; the Refine 2 row holds sArgs only. A % field whose value is not a finite number prints as String(value) without the %.

5.2 cleanText

Before interpolation, text is cleaned:

Korean angle brackets Korean text uses angle brackets as quotation marks (<공격력> 증가). The markup rule matches only ASCII lowercase tags, so these stay.

5.3 Names versus descriptions

Caseresolve (names, titles, tips)interpolate (descriptions)
Ref is nullnullnull
Key missing in the locale and in Englishnullnull
Text that cleans to nothing"(internal)"null
LiteralThe cleaned literal (or "(internal)")The literal, interpolated

5.4 English fallback

Each locale's text is looked up key by key with English behind it (Strings): a key absent from the locale falls back to English; a key present but empty stays empty (and then resolves as in section 5.3). The viewer's per-locale subsets behaved the same way.

5.5 JavaScript number rules

FunctionRuleExamples
toFixedRounds the exact binary value, ties up (Python's round() rounds to even and differs)0.125 → "0.13" (an exact tie); 2.675 → "2.67" (the double is just below the tie)
String()Shortest round-trip digits; integral numbers print without .0; positional for 1e-6 ≤ |x| < 1e21, exponent form outside3.0 → "3"; 0.000001 → "0.000001"; 1.5e-7 → "1.5e-7"; 1e21 → "1e+21"
formatValueThe magnitude (sign dropped); an integer as String(), otherwise toFixed(2) with trailing zeros removed-3 → "3"; 1.10 → "1.1"; 1.004 → "1"
formatPercentThe magnitude times 100 in floating point, then as String() if integral, otherwise toFixed(1) with trailing zeros removed, plus % 0.07 → "7%" (7.000000000000001); 0.155 → "15.5%"; -0.1234 → "12.3%"

5.6 The Refine 4 body

Refine 4 has no description row. Its body is the localized "Refinement" template (key 15370264355377780556, e.g. EN <color=${1}>Refine ${2}: In Rivalry Mode, increases ${3}.</color>), cleaned (which also drops ${1}, inside the colour tag), with ${2} the tier and ${3} the added stats as <value> <name> joined by ", ". Each name is the stat's own, in the same locale (UnitAttr.Tips); values print with String(). The source gate requires the template (S8), so there is no fallback text.

en  Refine 4: In Rivalry Mode, increases 10 Crit Resist, 15 Ranged DEF, 10 Crit DMG DEF.
zh  精炼4级:竞技模式中,提升10 抗暴击, 15 远程防御, 10 暴击耐性

6  The model and feed gates

6.1 The model gate

checks/model.py checks that the model is complete and consistent enough to project. Hero checks cover the heroes the feed will carry (the model minus blocklist). M collects the findings the builders raised on the way.

CheckFinds
M1Duplicate hero codenames or unit ids (error); fewer heroes than expectations.heroes.min.
M2Hero slug collisions (error); heroes without a usable English name (warning).
M3Heroes missing a core slot (ultimate, skill 2, skill 3).
M4A slot that repeats a level number (error), or whose levels are not 1..N (warning).
M5Malformed refinements: the Rivalry caster or its rows, more than one refinement per tier, Refine 4 without stats or with a stat that has no English name in UnitAttr (errors).
M6Awakening Lv2. Warning: a shown Lv2 level (unlockAwakenLevel ≥ 2) whose English description is a literal or a missing or empty key, on a hero not on awakeningLv2Blocklist; it suggests adding the hero. Info: a listed hero with no shown Lv2 to hide (IsShow hides it, or it has none); it suggests removing the entry.
M7Text keys missing or empty in EN or ZH: a warning for hero text (the feed emits null), an error for artifact, phantimal and charm text.
M9, M10, M11Artifacts, charms and phantimals against expectations and their own structure.
M12Config hygiene. Info, "may no longer be needed": a blocklist hero whose English panel text resolves, unless the entry is permanent; an awakeningLv2Blocklist hero whose shown Lv2 resolves in EN; a forceParse hero the game enables. Warning: an exported hero whose panel text is the client's withheld-text placeholder ("Under the influence of a mysterious power…", 在神秘力量的影响下, WITHHELD_TEXT_MARKERS); it suggests the blocklist. An entry that matches no hero never reaches M12: config loading stops the run with exit code 2.
M13Unknown skill-type codes, dropped (warning); UnitBriefBattleConfig rows with no parsed hero (info).
M14A skill partition holding rows of other casters (error).
M15Kit text that is a literal, not a key.
M17Buffs. Warning (from the builder): a row whose buff is not in SkillBuff, or whose Buff name differs from its BuffID's record. Info (from the builder): "the source has no hero scripts (HeroScripts): buffs are attributed by name only", on dump input. Info: the count line, "buffs: 194 levels link a record; 1102 records filed by name (136 hero-wide, 837 named by the hero's script too); 885 named by hero scripts, across 129 heroes" in build 14368.
M18What the feed leaves out of the skill panel. Info: hidden (IsShow false) Awakening Lv2 levels, the game's known use of the flag, and shown kit slots outside the six panel skills (on build 14368, prince slot 103). Warning: a hidden, non-dev level anywhere else on the panel, a new use of the flag to check in game before trusting it.

./afkj-parse roster prints M6's and M12's suggestions beside the hero list (CLI §10).

6.2 The feed gate

checks/feed.py checks stargazer's importer contract: every rule is one that stargazer's importers, or their silent filters, would otherwise trip over. "Major" locales are en and zh, the ones stargazer requires text in.

CheckRejects
F1A locale whose set of hero slugs differs from EN's.
F2A hero whose set of skill keys differs from its EN entry.
F3A [[label|key]] token whose key is not in that locale's _meta.keywords (hero and charm text); an empty or missing _meta.terms value (every term: the slot labels and the skill panel's labels); an empty keyword tip.
F4Unrendered markup or placeholders in any string of out/api/ («N», [[?]], $arrayRef, <sprite, <color), a game stat marker (<ATK>) or template blank (${) left unconverted; any integer beyond ±(253 − 1), which JavaScript would round.
F5Artifact slugs that differ from EN's; an EN artifact whose set is not pre-season/season, whose levels are not numbered 1..n, or whose EN and ZH level counts differ; a level without text in EN or ZH. A level without text in another locale is a warning.
F6Tier names that are not four non-empty strings; a charm without four non-empty tiers; hero lists that differ from EN's; a hero on more than one charm.
F7Phantimal slugs that differ from EN's; EN and ZH skill or level shapes that differ; a null faction; a null damage type or attack range; a level without text in EN or ZH. A level without text in another locale is a warning.
F8No manifest.json, or one that does not list en; a missing bulk file; a bulk file whose entry count differs from the manifest's count.
F9Stray files: anything on disk the feed does not list. Only check sees the disk.
F10Only when out/internal/buffs.json is there: a hero key that is not a hero in the feed; a referenced id (scriptBuffs included) with no record; a control effect with no label in an emitted locale. As info, it lists the control-effect codes on records with no popup entry (script-internal).
F12A hero level or refinement with no text in EN or ZH (stargazer drops these silently; the feed keeps a refinement whose text is missing, with a null description, so this check sees it). The finding suggests adding the hero to blocklist until the text ships. A gap in another locale is a warning, as in F5 and F7.
F13A skillNumbers.json that lists other heroes, slots or levels than the text files: out/api/'s against skills.json, out/internal/'s (when there) against skills.json, artifacts.json and phantimals.json.
F14An out/api/ file that departs from contract.SCHEMA: a field the contract doesn't list (a planted codename, a raw cd), a missing field, a wrong type, or a file the contract doesn't have.
F15A game name in out/api/: a glossary key with no own name in KEYWORD_NAMES, a glossary entry no exported text uses, a {marker} other than {atk} and {hp}, an artifact stat, damage type or charm id outside the contract's tables, phantimal factions that are not stargazer's keys, a range string other than "global".

F11 is not implemented: image checks were cut.

./afkj-parse check [--out DIR] runs this gate on a feed on disk (default out/api). It loads manifest.json, skillNumbers.json and each listed locale's bulk files; a missing one fails F8. out/internal/ beside it is loaded when there and checked by F10 and F13; a feed without it passes. Any other file under the directory is a stray (F9). Exit code 0 means the gate passed, 1 that it failed, 2 that the directory has no manifest.json.

7  The diff and the baseline

diff.py compares a new feed with the last one stargazer imported, the baseline in out/baseline/. It informs the decision to import. With no baseline, everything is new and stargazer's git diff after import is the review.

7.1 The diff gate

./afkj-parse build runs it after the feed gate passes, unless --no-diff. Its warnings fail it only under --strict, and a failed diff gate means the feed is not published.

CheckSeverityFinds
D1infoThe summary (heroes, artifacts, phantimals, charms and buff records added, removed, changed; shared text changed), "no changes since the baseline" only when nothing differs but timestamps, or "no baseline"
D2warningExisting heroes over the attention share: more than expectations.diff.attentionShare (10%) of their levels and refinements changed in EN or ZH
D3warningThe source is an older build than the baseline's
D4warningHeroes removed since the baseline
D5warningThe feed changed only in fields no section compares entry by entry (listed under "Changed fields"): look before importing, and teach the diff the field if it matters
D6warningExisting charms renumbered since the baseline: stargazer's charm tags are keyed by id, so each tag has to move to its charm's new id

7.2 diff.md

The build writes the report to out/build/diff.md; ./afkj-parse diff prints the same report for any two feeds. It compares the bulk files of out/api/ and the files of out/internal/.

From the 1.7.4 dump feed to build 14368: heroes +3 (Aster, Estrilda, Senea) and 21 changed, Isabella and Silvina gain Awakening L2, and the season flip reads as artifacts +12 −12, phantimals +5 −5, charms +19 −17.

7.3 One hero's texts

./afkj-parse diff --hero SLUG [--locale CODE] prints every changed text of one hero in one locale (en by default), old over new:

$ ./afkj-parse diff --hero karma --locale zh        # 1.7.4 dump feed → build 14368, abridged
karma (doublehead), zh: 2 changed

ultimate L1
  - None
  + 选择一名敌人为目标,喀尔马高举战斧砸向目标,对目标造成{atk}[[400%]]的伤害以及[[2.5]]秒眩晕。…

ex L3
  - …战斧的攻击命中敌人时会附带{hp}[[20%]]的魔法伤害。
  + …战斧的攻击命中敌人时会附带{hp}[[15%]]的魔法伤害。

--base takes a baseline directory or any feed directory, so two builds can be compared without accepting either.

7.4 The baseline and accept

./afkj-parse accept makes the feed stargazer imported the next diff's base. It copies the feed's files (manifest.json, skillNumbers.json and each locale's four bulk files) into out/baseline/api/ and out/internal/'s into out/baseline/internal/, as compact JSON, and writes out/baseline/_accepted.json:

{ "acceptedAt": "…", "feed": { "generatedAt": "…", "season": 8, "source": { … } },
  "config": { "path": "config.json", "sha256": "…" }, "lists": { "blocklist": [ … ], … } }

It refuses (exit code 1) when there is no build report, when the report failed, when the report's feed generatedAt differs from the feed's manifest, or when the feed's or the internal files no longer match the sha256 the build recorded after publishing them (an edit, a lost file, a copy), so only the very feed that passed every gate becomes a baseline. It also refuses (exit code 2) a baseline that overlaps the feed, the internal files, the work directory or the source the build read. make clean keeps out/baseline/; only accept replaces it.

The baseline lives only under out/, which git ignores, so a fresh clone has none. There build still passes, the diff gate reports "no baseline yet", and stargazer's git diff after the import is the comparison (Integrations §2.3). A baseline also carries its source: against an extract baseline, the same build read from the dump lists 14 buff records removed and changed links on 126 heroes (build 14368), because the dump has no HeroScripts (section 2.10), plus the source kind in the internal files' _meta. Heroes, artifacts, phantimals and charms compare equal. Accepting a dump-built feed moves that difference into the next extract diff, as records added.

8  Parity with afkj-data-viewer (record)

Before stargazer switched to this feed, it was compared with afkj-data-viewer's for the same input, file by file and leaf by leaf, ignoring generatedAt, the game ids the projection adds (unitId, fairyId, campSlot and the like, the manifest's season and source) and buffs.json. Both runs matched exactly, with no allowlist of intended differences:

OursTheirsFilesDifferences
Build 14368's extract outputafkj-data-viewer's committed public/api at 8c2fd7af2,1770
AFKJourneyData 1.7.4The viewer at 8c2fd7af, run on the same dump 2,1290

Measured 2026-09-26; on the 1.7.4 dump the feed gate's F12 fails on Karma's Ultimate L1 (a key empty in that dump only), so that run was built without the gate. Stargazer then imported this repo's feed of build 14368 and none of its generated files changed. The viewer was retired and deleted, and the comparison script is gone (git history keeps tests/parity.py); regressions now show in the diff against the last imported feed (section 7) and in the synthetic world's golden feed, tests/mini_feed.json, which both source layouts must build identically, apart from _meta.source.

9  Known quirks, revisited

Behaviours the feed had because afkj-data-viewer's feed had them, found in the P3 review (2026-09-26) and settled on 2026-09-27 with one principle: use the game's own data instead of rules kept by hand. The Refine 4 stat names are the game's (section 5.6; they had been a fixed English table, so 14 non-English locales changed and stargazer re-imported them once), the Refine 4 fallback text and the no-op ${1} replacement are gone, and skill-type codes sit on kit slots only. What remains is below.

9.1 Kept on purpose

BehaviourWhy it stays
Charm slugs are the game's charm ids (ep8aoe, ep6liveUpgrade), not slugified like heroes, artifacts and phantimals.Charms have no names in the game; the id is their identity, not a viewer rule. Stargazer keys its charm files on it.

9.2 Game-data behaviours (checked harmless)

BehaviourStatus
A kit slot outside the six panel keys never reaches the feed. Seen once: prince (Orion) has a kit slot at DisplaySlot 103 (prince_atk3b).A phase of Velvet Glove whose only text is already its levels 2 and 3. M18 lists every such slot, so a new one with text of its own is seen.
The keyword 784189237349263832 is all digits, so it is in _meta.keywords but can never match [kw]…[/], which needs a leading letter.No text in any locale uses a numeric keyword; a dead glossary entry that stargazer ignores.
A keyword label containing | renders as [[a|b]], which reads as keyed with key b.None in the data. If one appears, F3 fails on the unknown key, so it cannot pass silently.

9.3 Buff linking (conventions in buffs.json)

These rules come from the viewer's buff inference and affect only buffs.json, which stargazer does not read. They are kept apart from the rows' own links (attached) and the script's (scriptBuffs) so a client can weigh them.

QuirkStatus
Passives are filed by name: a <codename>_… record no row links belongs to that hero. A naming convention, not a fact.No skill row links them; the scripts apply them, and the hero's script confirms most: 837 of 1,102 in build 14368 are in its scriptBuffs. The rest stay a convention, and a record whose name happens to start with a codename is misfiled (rs_atk2_shield 1101 belongs to an enemy, and Lumont's script names only 1106).
A passive's slot comes from a token in its name; no token means hero-wide.136 of 1,102 records are hero-wide in build 14368. A misleading token files a record under the wrong slot.
Records that only a sub-trigger or dev row links are filed by name, like passives (dragonBow_atk2stunBuff via dragonbow_atk2b, under skill 2).12 records across 11 heroes in build 14368; the viewer dropped them. Linking internal rows directly would file them by fact.