Stages, converters and atoms¶
The asset export is built from three layers:
- Stages apply one unit of work to one subject (a bundle, a content id, a model key, a view). Each has a name and a version; a task is a stage applied to one subject, and its key covers exactly what its output depends on (contracts.md).
- Converters turn the objects of one class inside
unity.exportinto artifacts. Each carries its own version; a bundle's task includes only the converters of the classes the bundle holds. - Atoms are the pure functions stages and converters are made of: no file or network IO, no threads, the same output for the same input. Each atom has exactly one reference implementation.
Stages¶
Every stage is at version 1, except unity.census (version 2: it lists the scripts animation clip bindings name)
and cri.movie (version 2: every stream kind, codec and channel of a USM), and catalog.index (version 2:
gzip input and Japanese remote asset placeholders).
| Stage | Subject | Inputs | Outputs |
|---|---|---|---|
catalog.index |
a catalog (remote + APK) | the catalog files | every location (with the bundle hash, crc and size), bundles and raw remote files |
unity.census |
a bundle | the bundle | serialized files, externals, objects (file, path id, class, size, type hash, name, script, the scripts an AnimationClip's bindings name), container, scene hashes, dependencies, script fields |
link.scripts |
global | the censuses holding MonoScripts | serialized file and path id -> assembly, namespace, class |
unity.export |
a bundle | the bundle, the script entries its MonoBehaviours use | the objects' artifacts and one item per object |
sprite.crop |
a SpriteAtlas | the atlas' JSON, the images of its textures, the metas of the sprites of other bundles it packs | the sprites' images |
cri.audio |
ACB content | acb (a raw file of the catalog, or the acb artifact of a cue sheet held in a bundle), awb (an AWB the catalog pairs with it), boot (the game's boot data: the key's source) |
FLAC per stream, cues.json, streams.json |
cri.movie |
USM content | usm (a raw file of the catalog, or the usm artifact of a movie held in a bundle), boot |
every stream as stored (video, alpha, audio, subtitles), movie.mkv (or movie.webm) |
live2d.model |
a Live2D model key (Character/Live2D/<group>/<name>/model/<name>) whose bundle is selected |
bundle:<n>: the bundles of the key's dependency closure in the order the extractor loads them (the order is part of the key); context internalId (the key's location); atom live2d.extract_model (the extractor of nnnotes export live2d, run unchanged) |
live2d.model:<key>#<path>: every file nnnotes export live2d writes for the model, <path> relative to the model's directory; placed under <key>/ |
spine.skeleton |
a SkeletonDataAsset of a selected bundle, by its stable address <bundle>:<pathId> (in a bundle of several serialized files the file's tag follows the bundle name, as in link.addresses) |
bundle:<stable name>: the bundles holding the serialized files the asset's file reaches through its externals; context object (the asset's object id); atom spot.write_skeleton (the Spine writer of nnnotes export spot) |
spine.skeleton:<subject>#<file>: the skeleton (.json or .skel), its atlases (.atlas) and their page images, the bytes nnnotes export spot writes into spine/; one item for the asset |
link.addresses |
global | catalog index, censuses | key -> objects, serialized file -> bundle, bundle -> keys |
link.artifacts |
global | the above, the results | object id -> artifact ids |
view.<name> |
a catalog (main) |
master:<Table> per master table it reads, rules (the view's rule and the resolvers it uses), addresses (the part of the address table it read: the keys looked up and the prefixes listed); no parameters, atoms or context |
view.<name>:<subject>#view (facts: rows, entries, gaps, unreferenced) |
view.voices |
a catalog (main) |
master:<Table> per master table its rules read, rules, addresses, sheets (each cue sheet the cri.audio results are known by that a voice names or the rules' prefixes list: its task and ACB content id), acb:<sha256> (the ACB of each decoded one), episodes (per story episode and part, the unity.export artifact holding the object its key names), adv:<sha256> (those contents); context audio (the keys of those cri.audio results); runs after unity.export and cri.audio |
view.voices:<subject>#view (facts: rows, entries, gaps, unreferenced streams) |
view.voices is a stage of its own (nnnotes.pipeline.voices:VoiceStage), not one of the master-only views of
nnnotes.pipeline.views:stages: besides master tables and the address table it reads the story episodes of the
unity.export results, cri.audio results and ACB tables (views.md).
Converters (unity.export)¶
unity.export reads one bundle alone and gives every object of it exactly one item status: exported,
contained (part of another artifact), generic (typetree JSON because the specialized form could not be made;
the reason says why), unsupported (a class the stage does not convert; its typetree JSON is written as well) or
failed (not even the typetree JSON could be made). A converter's documented limit gives the generic typetree JSON
with its reason code; any other error of a converter gives it with generic.error and the error's message.
| Class | Converter | Artifacts | Atoms | Notes |
|---|---|---|---|---|
| Texture2D | tex.png/1 |
image: PNG of the first mip level (HDR ASTC: .astc) |
texture.decode, png.encode, astc.container |
further mip levels as facts; a texture without pixels: empty.texture |
| Sprite | sprite.png/1 |
image: PNG on the sprite's rect (packing undone, tight mask applied), meta: JSON |
texture.decode, sprite.crop, png.encode, mesh.arrays |
the texture is decoded once per task; a sprite whose SpriteAtlas is in another bundle gets its meta here (with its mesh and object facts) and its image from sprite.crop |
| Mesh | mesh.glb/1 |
mesh: binary glTF in the mesh's local space |
mesh.arrays, gltf.write |
see gltf.write below; limits of the skin and blend shapes as issues of the artifact |
| GameObject (a root) | prefab.json/1 |
prefab: the hierarchy's nodes (path, path ids, transform) with their components' fields |
the objects below it are contained; a root of a scene's file names its scene |
|
| Material | material.json/1 |
json: shader, keywords, textures, floats, colours |
||
| Shader | shader.json/1 |
json: the parsed form (the bytes of nnnotes export shader's <name>.json), program:<platform>/s<S>p<P>_<stage>_<N>: each compiled sub-program as stored (.glsl, .metal, .vkprog), index: the variants (platform, subshader, pass, stage, GPU program type, keywords, program role), typetree: the typetree JSON |
a shader without a parsed form: its typetree with generic.error |
|
| AnimationClip | clip.json/1 |
json: the Mecanim clip data with its bindings resolved in the bundle |
a legacy or compressed clip: generic.clip.legacy |
|
| AnimatorController | controller.json/1 |
json: layers, state machines, parameters |
an AnimatorOverrideController: generic.controller.override |
|
| MonoBehaviour (not in a hierarchy) | mono.json/2, cri.usm/1 |
json: $script (assembly, namespace, class) and the typetree; a cue sheet asset also acb, a movie asset holding its USM also usm (see below) |
sniff |
the class from the task's script table |
| TextAsset | data/1 |
data: the stored bytes |
sniff |
extension from the container path, else from the content |
| Font | font/1 |
font: the font file |
sniff |
|
| MonoScript | scripts.json/1 |
<task id>#scripts.json: every MonoScript of the bundle |
||
| AssetBundle | bundle.json/1 |
<task id>#bundle.json: serialized files, externals, the AssetBundle objects |
||
| any other class | generic.json/1 |
json: the typetree |
sniff |
A task's key includes the atoms reader and sniff, the converter generic.json, and the converters (with their
atoms) of the classes the bundle's census lists, so a converter's version bump runs again exactly the bundles holding
its classes. classes (a list of class names) limits a task to the objects of those classes and the objects their
hierarchies contain.
Object JSON is canon/1 in the object's own field order:
- a reference is
{"$ref": {"file": ..., "pathId": ...}}, withclassandnamewhen the target is in the bundle; a null reference isnull. Inside a prefab a GameObject, Transform or component of the same prefab is named by its path:{"gameObject": path},{"transform": path},{"component": class, "gameObject": path}; - a byte array (a vector of UInt8, SInt8 or char, or TypelessData) shorter than
blobMinbytes (4096) is{"$hex": "..."}; a longer one is{"$blob": {"sha256": ..., "size": ..., "format": ...}}and its bytes are the artifact<object id>#blob:<field path>(field names and array indices joined by/, the format bysniff); - a string whose stored bytes are not UTF-8 is
{"$hex": "<the stored bytes>"}; - NaN is
{"$float": "nan"}, infinities1e999/-1e999.
A cue sheet held in a bundle is a MonoBehaviour in one of two layouts; either gives the artifact
<object id>#acb (kind cri.acb, facts: cueSheet, layout), which cri.audio decodes:
- a SplitAcbData (fields
_cueSheetName,_chunks): its chunk TextAssets (of the same bundle, exported as well) joined in order, every byte XORed with the one mask that gives the ACB signature@UTF(layoutsplit; its refs name the chunks); - an asset named like the sheet whose
implementationmanaged reference is a CriSerializedBytesAssetImpl: itsdata.databytes (layoutembedded), whatever their size; that field of the JSON is the{"$blob"}of theacbartifact. One that names an AWB object givesunsupported.cri.awb_external.
A movie asset (CriWare.Assets CriManaUsmAsset) whose implementation is a CriSerializedBytesAssetImpl holds its USM
the same way: its data.data bytes, whatever their size, are the artifact <object id>#usm (kind cri.usm, facts:
movie, the asset's name), which cri.movie converts. Converter cri.usm/1 does this; it is in the key of a bundle
only when the bundle's script context names that class, so no other bundle's key changes with it.
sprite.crop makes one task per SpriteAtlas that sprites of other bundles use (subject <the atlas bundle's
subject>:<atlas path id>). Its inputs are the atlas' JSON, the meta of each such sprite and the images of the
atlas textures they use; it writes <sprite object id>#image with the same bytes unity.export writes for a sprite
whose atlas is in its own bundle.
CRI stages¶
cri.audio and cri.movie take their subjects by content: a task is cri.audio:<sha256 of the ACB> or
cri.movie:<sha256 of the USM>, so an ACB or a USM stored both as a raw file and in a bundle, or in several bundles,
is decoded once. Raw files are told apart by their first bytes (@UTF ACB, AFS2 AWB, CRID USM). Both read the
HCA keycode from the boot input when the task runs; the key itself is never part of a task, a record or a log.
Each task has one item (object: the task id; class ACB or USM): exported, or unsupported with a reason code.
Their artifacts belong to no object; the original layout places them under each name the stage gives the content:
the catalog keys that load a raw file, Cri/Sound/<cue sheet> for a cue sheet held in a bundle (the sheet's name
when the catalog has no such key), the keys of its bundle that end in the movie asset's name for a movie held in a
bundle.
| Stage | Parameters | Atoms | Artifacts (<task id>#<role>) |
|---|---|---|---|
cri.audio |
flac.level (8): ffmpeg's FLAC compression level 0-12 (every level decodes to the same samples) |
hca.decode (vgmstream), flac.encode (ffmpeg) |
one FLAC per vgmstream stream (a name repeated within the sheet gets <name>_<stream>; facts: stream, name, sample rate, channels, samples, loop points), cues.json (the first stream of each name), streams.json (every stream in order): the bytes nnnotes export audio writes for the sheet (FLAC) |
cri.movie |
format: mkv (default; with flac.level) or webm |
usm.demux (nnnotes: numpy-<v>/2), movie.mux (ffmpeg), hca.decode (vgmstream) |
per stream as stored and unmasked (a stream stored without its mask is recognised by its structure), named by its kind and, past channel 0, _<channel>: video.<ext> and alpha.<ext> (ivf: VP9 in IVF; m1v: MPEG-1 elementary stream, CRI's Sofdec.Prime; h264: H.264 Annex B; facts: codec, size, display size, frame rate, frames, an alpha stream's alpha type), audio.<ext> (adx; hca, still enciphered as stored; facts: channels, sample rate, samples or blocks, cipher), subtitle.json (the records as stored: language, time unit, start, duration, the text decoded as UTF-8 or textHex) with subtitle.srt and subtitle.vtt; movie.mkv: every video and alpha stream copied (the alpha as a second video track titled alpha), every audio stream as FLAC (HCA decoded by vgmstream with the boot data's key), every subtitle channel as a SubRip track (facts: the tracks); with format: webm movie.webm, the story's form: the first video stream, VP9 copied, or re-encoded as VP9 (libvpx, CRF 20) when it is not VP9 or has an alpha stream, which becomes its alpha channel cropped to the display size; the first audio stream as Opus; no subtitles |
cri.movie in players: a player shows the first video track of movie.mkv and no player composites the alpha
track (its luma is the opacity of the frame of the same number; when it is coded larger than the movie, its
top-left display-size part applies). MPEG-1 streams ask in their user data for an 11-bit intra DC precision, which
FFmpeg's decoder applies and other decoders may not. The game's catalog (version 1.0.1) holds VP9 and MPEG-1 video,
MPEG-1 alpha streams, ADX and HCA audio, at most one audio stream per movie and no subtitles: the H.264, subtitle
and multi-audio paths are tested on synthetic movies only.
The ids of the external tools are <tool>-<version>+sha256.<sha256 of the executable>/<revision> (the version as
vgmstream-cli -V / ffmpeg -version report it): another build of a tool runs the tasks that use it again, and a
task described for another build is refused as incompatible. The tools are [paths] vgmstream and [paths] ffmpeg,
else found on PATH. The boot input is the APK's assets/bin/Data/data.unity3d ([paths] apk), stored once.
Atoms¶
nnnotes.pipeline.atoms.ATOMS[name] is the reference implementation of an atom: its id, its function and its cost estimate.
The id names the libraries whose behaviour the output depends on, with their installed versions, and the revision of
the nnnotes code (pillow-12.1.1/1); nnnotes/<revision> when no library's behaviour is involved. Task keys include
the ids of the atoms a task uses, so upgrading a library or changing an atom re-runs exactly the tasks that use it.
| Atom | Reference implementation | Id | Does |
|---|---|---|---|
reader |
nnnotes.pipeline.atoms.reader.open_bundle (UnityPy) |
unitypy-<v>/1 |
one bundle loaded alone from its bytes: serialized files, objects, raw bytes, typetrees, container, externals, and the inputs of texture.decode and mesh.arrays (resource files are read from inside the bundle only) |
texture.decode |
UnityPy Texture2DConverter.parse_image_data |
unitypy-<v>+texture2ddecoder-<v>+astc-encoder-py-<v>+pillow-<v>/1 |
the first mip level, top row first, as UnityPy's Texture2D.image |
png.encode |
Pillow | pillow-<v>/1 |
PNG bytes; level 6 (the default) gives Pillow's default bytes, the ones the other commands write |
astc.container |
nnnotes | nnnotes/1 |
the standard .astc file (16-byte header, first mip level's blocks) |
sprite.crop |
nnnotes (a port of UnityPy's SpriteHelper) |
pillow-<v>+numpy-<v>/1 |
a sprite's image from its texture's image: texture rect, packing rotation / flip, alpha texture, tight mask or mesh drawing; optionally on the sprite's full rect |
mesh.arrays |
nnnotes | numpy-<v>/1 |
every vertex channel with its stored type and component count, primitives per submesh (base vertex applied), skin |
gltf.write |
nnnotes | numpy-<v>/1 |
binary glTF: one node per mesh, one primitive per non-empty submesh, POINTS for a mesh without an index buffer, z negated, winding reversed, v flipped, custom _... attributes for components glTF has no place for, bind poses in extras |
sniff |
nnnotes | nnnotes/1 |
format, extension and media type by magic number (images, fonts, CRI, Unity, Live2D, Spine, archives, audio / video), then JSON, UTF-8 text |
Reference implementations¶
Each atom has exactly one reference implementation, the one in the table above. Another implementation replaces it
only when all of the following hold, measured on a whole catalog with scripts/atom_eval.py (conformance with m1,
cost with m3, determinism with m1 --checks det and compare):
- Conformance. Its output equals the reference's for every object of the atom's classes, or an independent
reference shows the reference wrong for that object. For
png.encodethe output is equal when the file decodes to the same mode and pixels. No new refusals, and no object handed back to another implementation. - Distribution. A released package with wheels for every platform and Python version the CI tests; no source builds, git installs or vendored copies.
- Cost. At least 20 % less CPU time or peak resident memory for the stage that uses the atom, on the whole-catalog workload, counting every additional load of a bundle.
- Determinism. The same bytes on repeated runs and from any number of threads.
A switch changes one registry entry: the atom's id changes, exactly the tasks that use the atom run again, and their golden records are regenerated with a version bump. A library that meets the criteria only in an unreleased version is not used until it is released.
The current implementations:
| Atom | Implementation |
|---|---|
reader |
UnityPy |
texture.decode |
UnityPy (Texture2DConverter) |
png.encode |
Pillow, zlib level png.level (6) |
astc.container |
nnnotes |
sprite.crop |
nnnotes |
mesh.arrays |
nnnotes |
gltf.write |
nnnotes |
sniff |
nnnotes |
png.level (0-9) is a task parameter: a lower level writes larger files faster, with the same pixels. The default
stays 6, the level whose bytes equal the textures of the other commands.
NNNOTES_ATOM_<NAME> (NNNOTES_ATOM_PNG_ENCODE for png.encode) replaces an atom for evaluation: its value is
module:attribute, naming an Impl (or a callable returning one) whose id differs from the reference's. Keys and
provenance record the replacement.
Reason codes¶
The codes of items that are generic, unsupported or failed, and of limits recorded with an exported artifact.
| Code | Meaning |
|---|---|
empty.texture |
a texture without pixels (0 wide or high) |
empty.mesh |
a mesh without vertices, or whose every submesh is empty |
unsupported.texture.format |
a texture format the decoder does not implement (HDR ASTC is exported as .astc instead) |
unsupported.image.kind |
a texture array, cube map or 3D texture (generic JSON with the raw data) |
unsupported.sprite.downscale |
an atlas-packed sprite with a downscale multiplier other than 1 |
unsupported.sprite.tight_mask |
a tightly packed sprite whose mesh cannot be drawn (not flat, no triangles, texture coordinates not 2D) |
unsupported.sprite.canvas |
a sprite image that does not fit its rect at its offset |
unsupported.mesh.blendshapes |
a mesh's blend shapes (the mesh is exported without them) |
partial.mesh.skin |
a skin not converted in full: more than four weights per vertex, or weight and index channels that do not match |
generic.clip.legacy |
a legacy or compressed AnimationClip |
generic.controller.override |
an AnimatorOverrideController |
no_data.font |
a Font without font data |
unsupported.cri.awb_external |
an ACB whose waveforms are in an AWB that is not available |
unsupported.usm.codec |
a USM video or alpha stream in a codec other than VP9, H.264 or MPEG-1, an audio stream in a codec other than ADX or HCA, or an H.264 stream without a frame rate |
unsupported.usm.stream |
a USM stream of a kind other than video, alpha, audio or subtitles |
unsupported.usm.subtitle |
USM subtitles that are not records, or whose text is not UTF-8 (the movie's other artifacts are made) |
unsupported.class.AudioClip |
an AudioClip |
unsupported.class.VideoClip |
a VideoClip |
unsupported.class.MovieTexture |
a MovieTexture |
script.unresolved |
a MonoBehaviour whose script is not in the task's script table (no script bundle) |
script.missing |
a MonoBehaviour whose script reference is null |
no_data.sprite |
a sprite whose render data names no texture, or whose texture has no image |
unsupported.sprite.external_texture |
a sprite whose render data texture (not an atlas) is in another bundle |
generic.error |
a converter failed on the object (the message says how); its typetree JSON instead |
failed.read |
the object's typetree could not be read |
failed.export |
the object's typetree JSON could not be made |
source.absent |
a catalog entry whose file is not there (an APK entry without a file) |
source.error |
an input that could not be fetched |