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../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.
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.
| Output | Path | Read by | Guarded by |
|---|---|---|---|
| Model dump | out/build/model/ (--work) | People and LLMs debugging a hero; nothing reads it back | Written after the source gate; the model gate is reported beside it |
| Feed | out/api/ (--out) | stargazer's importers | Source, model and feed gates, all passed |
| Internal files | out/internal/, beside the feed | The diff, accept and
the tools in tools/ and research/ | Published with the feed |
| Report | out/build/report.json, report.md | People, CI | Always written |
| Diff report | out/build/diff.md | People, before stargazer imports | Written when the feed gate passes (section 7) |
| Baseline | out/baseline/ (--baseline) | The next build's diff | Written 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).
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:
| Form | Test | Example |
|---|---|---|
| A localization key | An ASCII decimal string of 1 to 20 digits (is_key). Digit count
is not a test; some real keys have 14 digits. | "89574582654232036" |
| A literal | Any other string. Usually raw Chinese on dev rows, kept as the game stores it. | "普通攻击(不用翻译)" |
| Nothing | null: 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.
write_dump (model/build.py) replaces out/build/model/ with:
| File | Holds |
|---|---|
heroes/<codename>.json | One hero record per parsed hero,
named by Unit.Name |
artifacts.json | The artifact list |
phantimals.json | The phantimal list |
charms.json | The charm file |
keywords.json, factions.json | The two dictionaries |
buffs.json, controlEffects.json | The 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 }
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.
| Field | Type | From |
|---|---|---|
codename | string | Unit.Name, trimmed |
unitId | int | Unit.ID |
nameRef | LocalizedRef | The 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) |
titleRef | LocalizedRef | Unit.DisplayTitle |
rarity, job | as stored | UnitRarity, UnitJob |
heroTagId | int | null | HeroTagID when above 0 (the faction) |
gender, seasonType, damageType | as stored | Gender,
SeasonType, DamageType |
isMelee | bool | IsMelee is true |
enabled | bool | The unit's enable flag; false means the hero is parsed
only because forceParse lists it |
script | string | Unit.Script, else the codename: the hero's battle
script, whose settings are in HeroScripts (Raw data §10.5) |
skillSlots | list of slots | The hero's skill partition, grouped |
passiveBuffs | list of {buffId, displaySlot} | Records filed with this
hero by name, and the slot their name points to (null: hero-wide); see
section 2.10 |
scriptBuffIds | list of int | The 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] }
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.
| Field | Type | Meaning |
|---|---|---|
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. |
displaySlot | int | The 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. |
titleRef | LocalizedRef | The first level's DisplayName |
skillName | string | The first level's SkillName ("" if absent) |
skillTypeCodes | list of string | From 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). |
knockBack | number | The first level's KnockBack; the viewer reads a
slot's physics from its first row |
levels | list of levels | Sorted by
(skillLevel, skillId); the EX slot also carries the refinements after its ladder |
| Field | Type | Meaning |
|---|---|---|
skillId | int | SkillID. The Refine 2 level keeps the Rivalry row's id;
the Refine 4 level is -1. |
skillLevel | int | SkillLevel. Refinements are numbered on from the
EX slot's last level. |
isDev | bool | The dev marker 不用翻译 appears in the row's
DisplayName, Description or SimpleDescription
(Raw data §9.2) |
isShow | bool | IsShow is not false (absent counts as shown) |
descriptionRef | LocalizedRef | Description; null on Refine 4 |
sArgs | list of number | SArg0 up to the row's highest
SArgN field, found dynamically; a missing index reads 0 |
plusRatio | number | PlusRatio (0 on refinements) |
buffName | string | null | Buff |
buffId | int | null | BuffID 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) |
unlockAwakenLevel | int | UnlockAwakenLevel; used by
awakeningLv2Blocklist |
cd, initCd, globalCd, costMp, gainMp,
victimMp |
number | The 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 |
range | number | null | The 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 |
refineTier | null | 2 | 4 | Set on the refinement levels only |
refineStatsAdded | list 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.
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.
| Field | Type | Meaning |
|---|---|---|
fairyId | int | Fairy.FairyID |
seasonType | int | Fairy.SeasonType (1 for the permanent set) |
set | "pre-season" | "season" | Permanent six, or the live season |
nameRef | LocalizedRef | Fairy.DisplayName |
effectNameRef | LocalizedRef | The first skill level's DisplayName |
skillName | string | The skill family's SkillName |
maxLevel | int | Fairy.MaxLevel |
statBonuses | list 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 |
levels | list of {skillId, level, descriptionRef, sArgs, plusRatio} |
The family's rows, sorted by SkillLevel |
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.
| Field | Type | Meaning |
|---|---|---|
unitId | int | Unit.ID |
codename | string | Unit.Name |
nameRef | LocalizedRef | Unit.DisplayName |
faction | int | null | Unit.HeroTagID, never CampPet.Camp |
campSlot | int | CampPet.Camp, a slot number, not a faction |
isMelee | bool | Unit.IsMelee is true |
damageType | string | null | Unit.DamageType, as stored |
attackRange | number | null | The range of the basic attack, the row at
slot 0. That row is hidden, so it is not among skills |
seasonType | int | The live season |
skills | list of {displaySlot, titleRef, skillName, levels} | One per
DisplaySlot, ascending; levels as for artifacts, sorted by SkillLevel |
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:
| Field | Type | Meaning |
|---|---|---|
tierNameRefs | list of 4 keys | The GemQuality.Name of the first
quality that unlocks each of levels 1 to 4 |
charms[].charmId | string | SkillName minus the gemskill_
prefix, e.g. ep6liveUpgrade; sorted by this id |
charms[].skillName | string | The level-1 row's SkillName |
charms[].heroIds | list of int | Heroes whose GemSuit rows collapse
onto this base, ascending |
charms[].levels | list 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 |
| Part | Shape | From |
|---|---|---|
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", … }
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.
| Field | Type | From |
|---|---|---|
id, name | int, string | ID, Name |
duration | number | null | Time when above 0, else null; 9999 means
permanent |
isDot, dotDmgType, dotInterval | bool, 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 |
controlEffects | list of string | ControlEffects |
addSource, shieldType | string | null | AddSource,
ShieldType |
canDispel, clearOnDeath, clearOnCasterDeath,
battleEndContinued, hidePopup | bool | The matching fields, true
only when stored as true |
maxStackTimes, buffType | number | MaxStackTimes,
BuffType (0 when absent) |
Linking rules:
BuffID, or, when
the row has no id, by its Buff name if exactly one record has that name. Internal slots (dev and
sub-trigger rows) link nothing. In build 14368, all 338 rows with a BuffID point at the record their
Buff names, and 194 levels link a record.SkillBuff names are shared by records that
differ, e.g. rs_atk2_debuff (ids 1109 and 1110, with different ARM_x and
MR_x).<codename>_… (the part before the
first _, case-insensitive) that no row links is that hero's passive. This is a naming convention, not
a fact.atk4 → EX,
atk5 → EX, atk3, atk2, ult, awake (and
awaken) → Awakening, mastery, imba → special-modes (4001), atk
→ basic (0), that starts a _ segment of the name after the codename. No token means hero-wide
(displaySlot: null). In build 14368, 1,102 records are filed by name, 136 of them hero-wide.scriptBuffIds are the HeroScripts
settings of its script that name a buff: the name contains buff (BUFF_SETTING),
is not a quantity (BUFF_QUANTITY), and the value is a SkillBuff id
(Raw data §10.5). This is a fact: the script applies them. In build 14368
hero scripts name 885 records; 837 of the 1,102 filed by name are among them, and 14 records (shared ones such as
common_taunt) are kept only because a script names them. Without the table, the list is empty and
M17 notes it.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 }
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).
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's | In out/api/ | Table |
|---|---|---|
Locale folders ge jp kr po sp tw vn | de ja ko pt es zh-tw vi; the other nine
as they are | LOCALE_CODES |
Glossary keys (uncontrol, hpCut, dodg …) | The English word
players read (unaffected, max-hp-reduction, dodge-chance …), the owner's
names of 2026-10-23 | KEYWORD_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, AP | physical, magic | DAMAGE |
SeasonType 108 | Season 8 (permanent artifacts: 0) | SEASON_BASE |
| Camp slot | The 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 id | numbered; keys.json |
Artifact fairyId | id: 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 tiles | No 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).
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.
_metaEvery 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).
skills.json{_meta, heroes: {slug: entry}}, sorted by slug.
| Key | Type | Meaning |
|---|---|---|
slug | string | Section 4.3 |
name, title | string | null | Resolved 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.*.name | string | null | The skill's name |
skills.*.levels | list of {level, description} | Sorted by level;
description is null when the text is missing |
skills.*.refinements | list 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." } ] },
… } }
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." }, … ] }
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": [ … ] }
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.
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).
keys.jsonThe 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.
buffs.jsonThe 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.
| Key | Shape |
|---|---|
_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 |
ultimate, skill2, skill3, mastery, ex, awakening, basic,
special-modes, in that order, present only when the slot has something.attached (a fact) lists the shown feed levels that link a record: not dev, not
hidden, not refinements, and honouring awakeningLv2Blocklist.inferredPassives and inferredHeroWide (a convention)
are the hero's passiveBuffs, filed under the slot their name points to, or hero-wide when it points
to none.scriptBuffs (a fact) are the hero's scriptBuffIds: the records its
own battle script names. An inferred passive also listed there is confirmed by the hero's script. Empty on dump
input.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.
dragonBow_atk2stunBuff, via dragonbow_atk2b). The viewer dropped these; parse files them
by the naming rule (here under skill 2).
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
skills.json, artifacts.json and
phantimals.json list, in their order; F13 checks it. Refinements have none.cd and initCd of 9999 and above (9999,
99999, 999999, 9999990) mean the skill has no timer: a passive, or an ultimate gated by energy
(costMp 1000).cooldown term, in every locale:
"Cooldown: ${1}\nInitial Cooldown: ${2}" / "冷却时间:${1}\n初始冷却时间:${2}", with
${1} the cooldown and ${2} the initial cooldown ({cooldown} and
{initialCooldown} in out/api/).Src/game_module3.luae,
the hero skill view; confirmed in game on 2026-09-27: Eironn's ultimate shows a cooldown tag, and his ranges read
1 or "Global"): for the selected slot it walks the shown levels (the slot's IsShow rows) the
hero has unlocked and takes the highest one, so upgrading a skill reveals each level's values. It shows three
tags. Cooldown: cd as ${1}s, hidden from 60 up; its
tooltip is the cooldown term with cd and initCd. Range:
range as the bare number, or the rangeGlobal term ("Global") from 15 up, hidden when
null; its tooltip is the range term ("Tiles: ${1}" / "技能范围:${1}").
Initial energy: on the ultimate only, the hero's startMp (0 when unset); its tooltip is
the initialEnergy term. Energy a skill grants later (an EX or awakening level's "gains 300 extra
Initial Energy") is in that level's text, not in startMp.range is the same on every level except Marilee's Ultimate (2, then 3) and Skill 3 (2, then 1)
on build 14368; three ultimates have 0 (Dionel, Lamentis, Thador), which the panel would show as 0.skill2 L1 cd 8 → 7), so a balance change that
touches no text still shows.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.
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.
kit slots with that
DisplaySlot merge into one key (ultimate, skill2, skill3, mastery, ex, awakening). The first non-empty
skillTypeCodes and the first name that resolves win. internal and
special-modes slots, and kit slots at any other DisplaySlot, are not projected.isDev or without
isShow is left out. A level whose text is missing stays, with a null description.refineTier goes to
refinements as {tier, description}. Refine 4 renders the template
body from refineStatsAdded; Refine 2 interpolates the Rivalry text against its
sArgs only. A refinement without text is dropped.{sArgs, plusRatio, knockBack},
with the slot's knockBack.config.json lists| List | Effect on the feed |
|---|---|
heroes.blocklist | The 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.awakeningLv2Blocklist | Awakening levels with unlockAwakenLevel of 2 or
more are left out of that hero's awakening key. |
heroes.forceParse | Lets a hero with no enable flag into the model (and so the feed). |
locales.skip | Locales 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.
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.
' and ’, turn every run of other
characters into -, trim dashes (Elijah & Lailah → elijah-lailah). No English
name, or a name that slugs to nothing, falls back to the codename.Vine Snare Spell →
vine-snare); the fallback is fairy-<id>.-<id> appended, in
ascending id order. Hero collisions are a model-gate error (M2).charmId unchanged.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.
SkillKeywords gets a key only if its tip resolves in
English. Those keys form keyword_keys: in hero and charm text a [kw]label[/] tag becomes
[[label|kw]] only for these keys. Artifact and phantimal text never carries keys; their tags become
[[label]]. In out/api/, skills.json's _meta.keywords maps the
keys this locale's hero and charm text uses, under their own names, sorted, to their tips resolved in the
locale; out/internal/en/skills.json keeps every resolvable entry under its game key._meta.terms holds ultimate, exclusiveEquipment and
the skill panel's labels cooldown, range, rangeGlobal and
initialEnergy (section 3.11): fixed keys (SLOT_TERM_HASHES) looked up in the locale and
cleaned, "" when missing, with named blanks in out/api/. stargazer's importers take
their slot labels from the first two. The source gate checks the keys exist (S8)."terms": { "ultimate": "Ultimate", "exclusiveEquipment": "Exclusive Equipment",
"cooldown": "Cooldown: {cooldown}\nInitial Cooldown: {initialCooldown}", "range": "Tiles: {range}",
"rangeGlobal": "Global", "initialEnergy": "Initial Energy: {energy}" } // out/api/en
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.
| Source text | Feed text | Notes |
|---|---|---|
[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> | verbatim | An 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 %.
cleanTextBefore interpolation, text is cleaned:
(不用翻译), in half- or full-width parentheses, is removed.<color=…>, <sprite …>, <nobr>) is removed:
any tag that starts with an ASCII lowercase letter. The game's own stat codes are uppercase and stay.trim() whitespace set.<공격력> 증가). The markup rule matches only
ASCII lowercase tags, so these stay.
| Case | resolve (names, titles, tips) | interpolate (descriptions) |
|---|---|---|
| Ref is null | null | null |
| Key missing in the locale and in English | null | null |
| Text that cleans to nothing | "(internal)" | null |
| Literal | The cleaned literal (or "(internal)") | The literal, interpolated |
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.
| Function | Rule | Examples |
|---|---|---|
toFixed | Rounds 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 outside | 3.0 → "3";
0.000001 → "0.000001"; 1.5e-7 → "1.5e-7"; 1e21 →
"1e+21" |
formatValue | The magnitude (sign dropped); an integer as String(), otherwise
toFixed(2) with trailing zeros removed | -3 → "3"; 1.10 →
"1.1"; 1.004 → "1" |
formatPercent | The 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%" |
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 暴击耐性
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.
| Check | Finds |
|---|---|
| M1 | Duplicate hero codenames or unit ids (error); fewer heroes than expectations.heroes.min. |
| M2 | Hero slug collisions (error); heroes without a usable English name (warning). |
| M3 | Heroes missing a core slot (ultimate, skill 2, skill 3). |
| M4 | A slot that repeats a level number (error), or whose levels are not 1..N (warning). |
| M5 | Malformed 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). |
| M6 | Awakening 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. |
| M7 | Text 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, M11 | Artifacts, charms and phantimals against expectations and their own
structure. |
| M12 | Config 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. |
| M13 | Unknown skill-type codes, dropped (warning); UnitBriefBattleConfig rows with no
parsed hero (info). |
| M14 | A skill partition holding rows of other casters (error). |
| M15 | Kit text that is a literal, not a key. |
| M17 | Buffs. 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. |
| M18 | What 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).
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.
| Check | Rejects |
|---|---|
| F1 | A locale whose set of hero slugs differs from EN's. |
| F2 | A hero whose set of skill keys differs from its EN entry. |
| F3 | A [[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. |
| F4 | Unrendered 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. |
| F5 | Artifact 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. |
| F6 | Tier 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. |
| F7 | Phantimal 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. |
| F8 | No 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. |
| F9 | Stray files: anything on disk the feed does not list. Only check sees the disk. |
| F10 | Only 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). |
| F12 | A 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. |
| F13 | A 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. |
| F14 | An 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. |
| F15 | A 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.
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.
./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.
| Check | Severity | Finds |
|---|---|---|
| D1 | info | The 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" |
| D2 | warning | Existing heroes over the attention share: more than
expectations.diff.attentionShare (10%) of their levels and refinements changed in EN or ZH |
| D3 | warning | The source is an older build than the baseline's |
| D4 | warning | Heroes removed since the baseline |
| D5 | warning | The 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 |
| D6 | warning | Existing 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 |
diff.mdThe 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/.
keys.json: hero and
phantimal codename, artifact fairyId, charm name. A slug change reads as a rename, and a charm whose
number moved as one too (D6). When a side has no keys.json, both sides match by slug.→ (gone); a key the baseline lacks is an addition and stays out of
the notes), and the number of changed texts per locale (the hero's name, slot names, levels, refinements). An addition or removal names the blocklist or forceParse edit that explains
it, from the last build report's lists against _accepted.json's.set, id, season and statBonuses, a
phantimal's id, factions, damage and range, a charm's
heroes.1701 rabbitbow_ult: stats.ARM_x -0.3 → -0.25); heroes whose buff links changed
(scriptBuffs count as links). Not compared when a
side has no buffs.json.keys.json._meta.terms labels and charm
tier names, each with the locales it changed in.internal/skillNumbers.json heroes.*.skills.ex[].range: added ×508) with
generatedAt ignored; the first 40 patterns are listed. A file present on one side only reads
(the whole file).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.
./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.
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.
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:
| Ours | Theirs | Files | Differences |
|---|---|---|---|
| Build 14368's extract output | afkj-data-viewer's committed public/api at
8c2fd7af | 2,177 | 0 |
| AFKJourneyData 1.7.4 | The viewer at 8c2fd7af, run on the same dump |
2,129 | 0 |
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.
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.
| Behaviour | Why 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. |
| Behaviour | Status |
|---|---|
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. |
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.
| Quirk | Status |
|---|---|
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. |