Architecture & Internals

How the extractor is structured, how each format is decoded, and what it emits.

Contents

  1. Pipeline & package layout
  2. Read-only safety model
  3. Data sources
  4. Format: .jsone
  5. Format: .pkg
  6. Format: AutoGenConfig .bytes
  7. Format: Config/*.luae tables
  8. Format: AST textures
  9. Output format
  10. Verification
  11. Design history

1  Pipeline & package layout

The extractor is a small Python package. Data flows in one direction — a read-only source in, decoders in the middle, JSON out — with no write path back to the game.

src/afkj_common/       shared by extract and (from P1b) parse; standard library only
  rules.py             locale codes, slugs and slug assignment, hero lookup, the season rule
  textkey.py           xxHash64 and text_key
  output.py            contained paths, staged writes, recoverable directory replacement
src/afkj_parse/        the parse capability (./afkj-parse; in progress, see the TDD)
  source/              one interface over extract output (primary) and the AFKJourneyData dump
  checks/              validation gates and out/build/report.{json,md}
  config.py, model/    config.json hero lists; the model layer
src/afkj_extract/
  safety.py            Source / LocalSource, discover_container(), is_game_running(), output_guard()
  session.py           lazy archive/runtime/Array and shared domain data for one run
  cdn.py               the game's CDN: package file table, verified range reads
  formats/
    binary.py          bounded spans, decompression limits, FormatError
    jsone.py           Blowfish + LZ4-block JSON frames
    pkg.py             igame.pkg TOC reader (+ per-entry .jsone)
    autogen.py         AutoGenConfig binary map tables (localization)
    luatable.py        Config/*.luae tables via a restricted Lua 5.4 VM + Array pool
    texture.py         AST/ASTC textures -> sRGB-encoded Pillow PNG (lazy heavy imports)
    luadis.py          Lua 5.4 bytecode constants and integer settings (mystical.codee, hero scripts)
  game.py              session-bound rules: locale file paths, hero rows, the current season, HeroScripts
  stages/              locales, tables, textures (+ --hero art filter), portraits,
                       artifacts, phantimals: one module per stage
  manifest.py          out/manifest.json and the raw stages' _stage.json stamps
  cli.py               argparse CLI, banner, safety gates, stage dispatch
tests/test_*.py        offline fixtures and regression tests
tests/smoke.py         optional live checks; --full compares every table cell
tools/                 investigation aids: unreleased skills, Lua disassembler (tools/README.md)
research/              manual investigations and image checks (images.html)

formats/* decode bytes and validate their structure. safety.py owns source paths; ExtractionSession shares one archive, runtime, Array pool, and frequently reused tables within a run. English strings are cached for naming; other language maps and skill partitions are emitted without retaining the full corpus. afkj_common holds the rules and output helpers extract shares with the parser (slugs, hero lookup, the season rule, text keys), game.py the ones that need a session, each module in stages/ produces one output folder, afkj_common.output owns publication, and cli.py handles arguments and diagnostics. This is a small local tool: ordinary functions and one session object are enough.

2  Read-only safety model

Game data is read through Source.read_bytes(), exists(), and glob(). There is no source-write API. Output protection also depends on explicit path checks; a read-only interface alone cannot prevent a separately chosen output path from aliasing the source.

Future: other platforms The process guard is macOS-only. Its protected trees are macOS paths, its process check is libproc or pgrep, and container discovery reads macOS container metadata. On Linux, auto-discovery fails ("no container directory") after the check. On Windows, the check can't run (no libproc or pgrep) and refuses. On both, an explicit --source is outside the protected trees and is not checked. So a Windows PC-client install passed with --source would be read without the guard. Reads stay read-only either way. If the tools go multiplatform:

This protects normal single-user operations, not concurrent hostile filesystem changes or arbitrary untrusted Lua bytecode. Publication is per stage, not a transaction across the whole invocation. Avoid concurrent runs targeting the same output directory.

3  Data sources

App bundleSandbox container
Path /Applications/AFK Journey.app/…/Data/Raw/ ~/Library/Containers/<uuid>/Data/Documents/data/
Ownershiproot — unwritable by ususer — writable ⚠ (the risk surface)
Contentspreinstall seed: igame.pkg, a few configs, 280 bundle packs in aa/iOS/ — incomplete, and older than the container's copiesfull resources: igame.pkg, AutoGenConfigBytes/, ~6.6k bundle packs
Used forfiles that exist hereeverything real — strictly read-only

The extractor auto-discovers the container; --source can point it at a manual copy (e.g. a dump on another disk) instead.

Languages. A language file (Config/AutoGenConfigBytes/LanguageDb<CODE>.bytes) only exists locally once that language has been selected in-game, so a typical install has just a couple. The extractor emits every one it finds (all 17 possible) and the parser filters: the feed leaves out cn as a near-duplicate of zh (252,529/252,543 strings identical) but still reads it internally, and its in-game selector shows 16 because cn is the hidden Simplified source variant. afkj-extract --list prints which locales are present vs. missing so you know what to download in-game; --locales-from cdn fetches all 17 from the CDN instead (integrations §1).

Updates and patches. The loose files are patched in place, so what the extractor reads is always the current resource version. For reference, in Data/Documents/ (one level above data/):

3.1 What's on disk (packages and file kinds)

A census of both locations, 1.6.11 (2026-09-18), from research/package_inventory.py (12 s; it walks .lpak headers without decompressing). Container: 33,308 files, 16.9 GB. App bundle: 429 files, 1.37 GB. "Bundles" counts the UnityFS bundles inside the packs (images §3). Where the app and the container have the same file, the container's is newer and is the one the game uses.

Path (container data/)FilesSizeWhat it is / how to read it
igame.pkg + .pkg.bin2137 MB Lua config tables and scripts, protobuf schema (sections 5, 7). Tables come from here.
Config/AutoGenConfigBytes/*.bytes1762 MB Localization and index tables (section 6); Config/*.luae (2) are the older Lua language tables
*.jsone (root, Map/, MapExport/)6812 MBEncrypted JSON config (section 4)
ui/**/*.png12,272490 MB Loose AST textures: portraits, card art, item and artifact icons (section 8). Portraits and artifacts come from here.
aa/iOS/<hash>_aa_module_GeneralRes.lpak5,6409.8 GB Addressables packs, 174,352 bundles: models, animation, FX, prefabs, most UI atlases (images §3)
aa/iOS/<hash>_aa_module_HDRes.lpak371150 MBThe HDRes module's packs, 7,109 bundles
aa/iOS/tiny_asset_bundles_[ep0N_|ep_common_|seasonundefined_]aa_module_{GeneralRes,HDRes}_NNN.lpak 206370 MBSmall bundles packed together, ~170 per pack (34,585 bundles), grouped by episode. Holds e.g. the Camp and Job atlases.
aa/iOS/newbie_asset_bundles_aa_module_{GeneralRes,HDRes}_NNN.lpak131.0 GB 15,097 bundles; holds e.g. the Hero, HeroDetail and Battle_Ready atlases
aa/iOS/pre_install_{unmanaged_assets,shader,common_dependency}_…lpak, builtin_…lpak 464 MBSeeded by the app, updated here; pre_install_unmanaged_assets holds the Common atlas (class, faction, rarity icons)
aa/catalog.bundle, catalog.hexbin, hashver_catalog.bundle3135 MB Addressables catalogs: the bundle's catalog TextAsset lists asset paths in plain text; the .hexbin starts AddressablesMainContentCatalog. aa/iOS/ also has one stray 3.7 KB .bundle (a single material).
prgroup_GeneralRes_VT_<hash>.lpak, HDRes/prgroup_HDRes_VT_<hash>.lpak 287 + 6756 MBVirtual-texture tiles for the maps (3,070 bundles); one pack is not UnityFS
binary_NodeCanvas_{DialogueTree,FlowCanvas,BehaviorTree}_<hash>.lpak344 MB Not UnityFS (they start ff ff ff ff): NodeCanvas graphs by the names; not investigated
Map/, MapExport/, MapAtlas/, MemoryMapData/, HDRes/*.mapdata 9,1991.36 GBWorld data (JSON config excluded): per-map .map .chunk .dcol .clua .visiblerect .hexbin (287 each), splines, water, collision, heights, nav (.waynet), terrain atlas definitions (MapAtlas/<scene>/*Def.atlasdef, 1,404). Not investigated.
Sound/*.bank, Video/*.usm4,281 + 23.2 GBFMOD sound banks, CRI movies
InjectPatch/*.bytes2375 MBIFix C# hotfixes (above)
*.grass, *.ibt, backup/, _assets_temp/, patch/, multilanguage/ 3 MBLogin/showcase scene data; the folders are empty download staging

The app bundle's Data/Raw/ holds igame.pkg, one AutoGenConfig file, 45 preinstall ui/icon images, 85 sound banks, a movie, and aa/iOS/ with 280 packs (0.96 GB, 20,519 bundles: 276 hashed packs, the three pre_install_* packs and builtin). Its pre_install_unmanaged_assets is an older build of the container's (141 vs 132 bundles, a different Common atlas).

4  Format: .jsone verified

Blowfish-encrypted, LZ4-compressed JSON. Used for small loose config (game version, etc.). formats/jsone.py:

offset 0 : magic  FF FF FF FF
offset 4 : declaredSize u32 LE
offset 8+: Blowfish-ECB(key="b8c0aeff2944d1614d6bc63dc6b0a537", 8-byte blocks)
           with a per-block half-swap: reverse each 4-byte half BEFORE and AFTER
           the cipher call (reconciles the impl's word order with LE data)

decrypted payload:
  [prefixLength u32][prefix][decompressedSize i32][LZ4-block data]  ->  UTF-8 JSON

API: jsone.decode(bytes) -> bytes, jsone.decode_json(bytes) -> obj.

5  Format: .pkg verified

A custom indexed container (not a ZIP). Two files work together:

Entries hold compiled Lua (Src/*.luae game logic and, crucially, Config/*.luae data tables) plus the protobuf schema Pb/igame.pbe. API: PkgArchive(pkg, toc).read(name) -> bytes (decodes the entry's .jsone frame), .names(), name in archive.

6  Format: AutoGenConfig .bytes verified

The game's binary map format, used for localization (LanguageDb{EN,CN}.bytes) and index tables. A hash-map serialized as four regions:

header (16 bytes): u32 version=0, u32 headerSize=16, u32 entryCount, u32 slotCount
region A: slotCount  x 16-byte hash slots         (lookup index — unused for a dump)
region B: entryCount x 16-byte records            (u64 key, u32 valuePtr, u32 chain)
region C: value pool

string value @ valuePtr:  [u16 len LE][utf-8 bytes]

To dump, iterate region B (offset headerSize + slotCount*16) reading each (key, valuePtr) and decoding the value. autogen.decode_string_map(bytes) -> {key: text} handles the string case, which is all localization needs. (Typed/int columns use a tagged word encoding that isn't reversed; moot for the parser, since the tables it needs come from Lua, below.)

Keys are xxHash64 of the Chinese source text. Every localization key is xxHash64 (seed 0) of the UTF-8 Simplified Chinese string it translates (afkj_common.textkey.text_key, checked on 3,000 of 3,000 entries). Tables in this build store that Chinese text raw (Unit.DisplayName, skill Description/SimpleDescription), so any language resolves as LanguageDb<LANG>[text_key(field)]; an all-digit field is already a key. The Chinese locale file does not list every source string, so the key has to be computed rather than looked up.

Some keys ship blank. A key can be present with an empty value in every language. Karma's Ultimate Lv1 description (key 15602405291547514597) is one: the game shows its English in-game, but no file on disk holds it. The search covered both language files (current and the older .luae), every decoded igame.pkg entry, every AutoGenConfig table, the HTTP caches and stores (Library/Caches, igame.kv, *_expedite.dat, including gzip/zlib), and every TextAsset/MonoBehaviour in the Unity bundles: all 6,237 container packs, split into their 231,342 bundles (1.6 M objects, research/bundle_text_search.py; a first pass had opened each pack whole and seen only its first bundle). The guess then was runtime delivery; in fact the text shipped late: build 14368 (2026-09-25) carries it under the same key in every language. The in-game text also added "+ Ult Power" terms (UAP) that no template contains, so the UI appends those itself. When a description is blank, take it from an in-game screenshot until a later build fills it. Check first that the game shows the skill at all: a row with IsShow false is an internal sub-skill (Raw data §9.2).

7  Format: Config/*.luae tables verified

The centerpiece. Every hero/skill table is a compiled Lua 5.4 data module inside igame.pkg — Config/Unit.luae, Config/SkillBuff.luae, Config/TavernPool.luae, Config/Skill/Skill_*.luae, and so on. They are standard Lua 5.4 bytecode (no custom opcodes), which is what makes the following possible.

7.1 Restricted execution of trusted game data

luatable.py runs stock Lua 5.4 bytecode through lupa. Each module receives fresh copies of the table, string, and math library tables, a small pure base API, and the game's pure isnumber/table.bseek helpers. File/process libraries and the Python bridge are not exposed in the module environment. Unknown globals fail with their names. The shared Array pool is deliberate; other module configuration is fresh. This in-process API restriction is intended for trusted local game files and is not a security sandbox for hostile bytecode.

7.2 What a module builds

Running a module assigns two things into config:

config.<Name>HeaderTable = { field -> columnIndex }        -- e.g. { ID=0, Name=1, HP=26, ... }
config.<Name>Table       = <accessor>                       -- lazy row view over a flat cell array

accessor.__dataTable = { [1]=flatCells, [2]=rowKeys, Len=cellCount }

flatCells is a sparse array of rowCount × stride cells (a missing cell means "use the column default"); stride = max(columnIndex) + 1. Row r, column c lives at flatCells[r*stride + c + 1]. Skill partitions nest one level deeper (config.Skill.Skill_0Table); find_tables() handles both.

7.3 Exact schema recovery

Defaults and array-column indices are captured by the row materializer. The extractor follows the accessor metatable one level for ordinary tables or two for grouped tables, then matches the captured header and data by Lua identity. Supported scalar and array materializers have three and eight upvalues respectively. Unknown shapes fail explicitly. Header indices may be noncontiguous; stride remains max(columnIndex) + 1. Missing defaults become null, matching Lua nil, and empty array defaults become []. No type-based default guessing is used.

7.4 Array resolution

Config/Array.luae stores flat values in A[1] and one-based start offsets with a final sentinel in A[2]. Reference N is A[1][A[2][N] .. A[2][N+1]-1]. Numeric indexing preserves nil holes as JSON null; iteration through .values() would lose their positions. Offsets and references are validated. For game 1.6.11, reference 110932 is [null, "false", "true"], and 110933 is ["atk", "atk6", "atkb"], verified against the game's own accessors.

7.5 Reconstruction and grouped keys

For each row, absent cells use the recovered default, numeric array cells resolve through the pool, inline tables become lists/maps, and scalar cells retain their values. Grouped tables store group/size pairs in __dataTable[3]. Their inner keys repeat across groups, so rows use JSON-encoded composite keys such as "[1,1]", with keyFields and rowKeyFormat: "json-array" metadata. This prevents overwriting earlier groups. Single-key table keys are unchanged.

SkillIndex.luae is a plain ID-to-partition map, exported separately as {fields: ["ID", "Partition"], arrayFields: [], rows: {...}}.

8  Format: AST textures verified

The game's loose images (under ui/, ~11,800 of them) are ASTC textures in a thin wrapper — named .png but not actually PNG. formats/texture.py:

offset 0  : magic  "AST"
offset 3  : width  u16 LE
offset 5  : height u16 LE
offset 7  : format u8       (ASTC block size: 0x0d = 6x6, 0x0e = 8x8)
offset 8  : decompressedSize i32 LE
offset 12 : LZ4-block-compressed ASTC blocks

decode: LZ4 decompress -> texture2ddecoder.decode_astc(w,h,bw,bh) -> BGRA
        -> flip vertically (GPU origin is bottom-left) -> Pillow RGBA
        -> sRGB-encode RGB (texels are linear; skip with --linear) -> PNG

The block-size map is verified against the whole corpus (ceil(w/bw)·ceil(h/bh)·16 == decompressedSize for every file). Heavy deps (Pillow, texture2ddecoder — the [textures] extra) are imported lazily so the rest of the package doesn't require them. The textures stage is opt-in and mirrors the ui/ tree into out/raw/textures/; a --textures GLOB filter subsets the ~12k images, and --hero selects one hero's art (below).

Colour handling, finding a hero's art, art in Unity bundles and UI sprites are in Images (§1–4), with the portrait, artifact and Phantimal exports (§5–6).

9  Output format

The output shape is ours to choose, since afkj-parse is its reader, so it favors being directly consumable over mirroring AFKJourneyData's _meta/$arrayRef structure.

out/
  manifest.json                 { schemaVersion: 2, tool, toolVersion, gameVersion, generatedAt,
                                  source, scope, status, options, stats, error? }
  raw/locales/en.json           { "<key>": "<text>" }        (301k strings; runtime key resolution)
  raw/locales/_stage.json       { formatVersion, extractor, gameVersion, fileTable, from: install | cdn, cdnBuild?, locales }
  raw/tables/Unit.json          { fields:[...], arrayFields:[...], rows: { "<key>": { ...full row... } } }
  raw/tables/Skill/Skill_0.json per-skill partitions
  raw/tables/HeroScripts.json   { Script, Name, Value } rows: hero battle script settings (raw data §10.5)
  raw/tables/_stage.json        { formatVersion, extractor, gameVersion, fileTable, complete }
  raw/cdn/<build>/…             the CDN download cache (--locales-from cdn)
  raw/textures/**/*.png         decoded AST/ASTC art (only when the textures stage runs)
  images/heroes/<slug>.png      hero roster portraits (portraits stage, images §5)
  images/heroes/alt/, manifest.csv   skin alternates, provenance, in_reference
  images/artifacts/<slug>.png   permanent and current-season artifacts; manifest.csv `set` (images §6)
  images/phantimals/<slug>.png  the current season's Phantimals (images §6.5)
  tools/                        outputs of tools/

raw/ keeps the game's own names so a finding can be traced to its source; images/ uses the slugs the feed uses (afkj_common.rules.assign_slugs), so a client joins a feed entry to its image by slug. Each raw stage stamps its own directory: fileTable is xxHash64 of the install's afk.bin, and the CDN option refuses to run unless the CDN's file table hashes the same, so the stamp proves text and tables are one build. formatVersion is the contract with the parser; complete is false after a filtered run.

Table rows are full objects (every field present, defaults applied, arrays resolved inline), keyed by primary key or the composite grouped key described above. Localization stays a flat {key: text} dict per language — the form the viewer already resolves at runtime for locale switching; inlining strings into rows would multiply size across 17 locales and break that mechanism.

manifest.json describes the last invocation: selected stages/tables/heroes, limits, color and reference options, completion status and statistics. Unselected outputs can be older. Preflight failures and dry runs leave the previous manifest untouched. Full stages replace stale files; filtered/limited stages preserve unselected files. Runtime failures record a failed manifest when the destination remains writable.

10  Verification

Use make check for Ruff and the offline regression suite, and make build for wheel/source packaging. Generated fixtures exercise binary truncation and bounds, schema/default recovery, array holes, grouped keys, path aliases, publication failures, filtered runs and CLI errors without game files.

11  Design history

The design review that started this project (afkj-extractor-review.html, in git history) was retired on 2026-09-25; its findings live in the sections above. What it recorded that isn't stated elsewhere:

11.1 Parse design decisions

These are from the parse-capability plan (2026-09-25 to 27), retired when the work finished. Each row gives the choice, what was rejected and why, so a change can be weighed against the reason. A later row carries its own date.

DecisionChoiceRejected, and why
Where parse livesIn this repo: package afkj_parse, launcher ./afkj-parse, beside afkj_extract.chaldea/parser/: superseded when extract and parse moved together.
Where the feed livesout/api/, local and gitignored, rebuilt each update. ./afkj-parse accept, run after stargazer's import, copies the bulk files and out/internal/ to out/baseline/ for the next diff. With no baseline (a fresh clone) the diff lists everything as added, and stargazer's git diff is the review.Committing the feed (reversed, to keep the repo small); no baseline (the diff routine would lose its base).
LanguagePython ≥ 3.9 on the existing toolchain (ruff, unittest, make check). No new runtime dependency, no type checker.TypeScript: a second toolchain, every shared rule twice, or a cross-repo hop.
Internal modelout/build/model/: a write-only dump for diagnosis, with no stability promise and never read back. The product is the feed.Committing it.
Model scopeLean: what the feed and gates read, the one-line Unit scalars, and the buff registry (raw SkillBuff fields in internal/buffs.json). Summons, the EX item and mastery are documented only.Dropping the buff registry; keeping every structural join.
Configconfig.json at the root, committed, entries {hero, note} (an English name, codename or Unit ID), validated by config.py.A Python module (code-shaped data is edited less safely); TOML (tomllib needs 3.11); YAML (a dependency); codename-only entries (not what a person types).
Feed contractv2 (2026-10-24): out/api/ carries only what stargazer shows, in its own vocabulary (no game keys, codes or ids), held to a schema; the game's ids, raw numbers and buff records go to out/internal/ for our tools. Any change to out/api/ needs the user (Model §3).v1 with additive game ids (until 2026-10-23): it showed where the data came from. Filtering in stargazer's importers: the translation tables would have stayed in stargazer's public code.
Second source and fallbackExtract output primary, the AFKJourneyData dump as fallback, chosen by an explicit whole-run --source. The source gate's failure suggests it.Automatic fallback (hides the primary failure); per-table mixing (pairs tables and text from different builds).
Updating without the install (2026-10-11)A cloud session builds from a --depth 1 clone of the dump and imports into stargazer. Stargazer's git diff is the comparison, and nothing is accepted there (Integrations §2.3). The local machine under Remote Control is the backup.A baseline rebuilt from the previous dump commit (an older dump need not pass today's gates: 1.7.4 fails F12 on Karma's Ultimate L1); a baseline kept in git (game text in the repo); the local machine as the only path (one power cut ends it).
LocalesEmit every locale the source ships except cn, which stays resident for the name fallback and the key rule.Emitting cn (stargazer ignores it).
Refine 4 labelsEach stat's UnitAttr.Tips in the same locale; candidate codes are the IsPvPAttr rows ExclusiveHeroLevel has a column for. Game data over a hand-kept table.A hand-kept EN table in every locale (the original rule).
Package layoutThree packages in one distribution: afkj_common (standard library only), afkj_extract, afkj_parse. Extract and parse depend on common, never on each other.Parse importing extract (pulls in lz4, Crypto and safety); one renamed package (churn).
CDN localesA stage of extract: --locales-from cdn, with the stage stamp and output safety.A separate tool and a second layout for the adapter.
Docs formatHTML pages with the shared nav.Markdown (two doc formats in one repo).
Unreleased Awakening Lv2Read IsShow: hidden levels never reach the feed. The config list is an override, and M6 reports disagreements. M18 flags a hidden, non-dev level elsewhere on the panel for review.A hand list as the only mechanism (the game already states it).
Stargazer's season-file rulesIn stargazer's importers. The feed adds only game facts (fairyId, unitId, campSlot, isMelee, damageType, attackRange, seasonType).The feed emitting stargazer's ids (couples it to one client); a script here writing into stargazer (a cross-repo write).
The old viewerRetired. The feed was verified identical to its output before stargazer switched (Model §8); its heuristics are summarised in Battle scripts §10.Keeping its UI on top of out/build/model/ (the brief dropped UI).

The rule behind several of these, and the one to apply to new cases: take the game's own data over rules kept by hand.