Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion docs/assets.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,8 @@ no longer wanted, never touches other files, and continues an interrupted write
- other objects under the same container path: `stem[<name>].<ext>`, the Addressables sub-object form
(`…/member_full[square].png`);
- objects without a container path: `_bundles/<stable bundle name>/<Class>/<name>~<pathId>.<ext>`;
- secondary artifacts beside the primary one: `<path>.<role>.<ext>` (`….meta.json`, `….blob.m_Data.moc3`);
- secondary artifacts beside the primary one: `<path>.<role>.<ext>` (`….blob.m_Data.moc3`), `<path>.<ext>` for a
role that is its extension (`….png.json`, the `json` of a texture or sprite);
- a container path present in two bundles: `stem@<stable bundle name>.ext` for each;
- names Windows refuses, reserved device names and trailing dots are escaped as `%XX`; paths that coincide ignoring
case get `~<8 hex>` and are listed in the layout report;
Expand Down
5 changes: 3 additions & 2 deletions docs/contracts.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,8 @@ One output file ([artifact.schema.json](https://github.com/MetaSekaiLab/nnnotes/
`ext` is lower-case letters and digits. The bytes are stored once, by content id, whatever their names.

Roles: an object's primary artifact is the one whose role comes first in `image`, `mesh`, `audio`, `video`, `font`,
`data`, `prefab`, `json` (then by role name); its other artifacts (`meta`, `blob:<field path>`, …) are secondary.
`data`, `json` (then by role name); its other artifacts (`json` beside an image, `blob:<field path>`, …) are
secondary.

## Result: nnnotes.result/1

Expand All @@ -120,7 +121,7 @@ The result of a task, written after the bytes of all its artifacts are stored: i
{"schema": "nnnotes.result/1", "key": "…", "keyParts": {…}, "status": "ok",
"artifacts": [<artifact records, sorted by id>],
"items": [{"object": "CAB-…:1", "class": "Texture2D", "status": "exported", "artifacts": ["CAB-…:1#image"]},
{"object": "CAB-…:2", "class": "Transform", "status": "contained", "in": "CAB-…:9#prefab"},
{"object": "CAB-…:2", "class": "Transform", "status": "contained", "in": "CAB-…:9#json"},
{"object": "CAB-…:3", "class": "Texture2D", "status": "unsupported",
"reason": {"code": "empty.texture", "message": "…"}}]}
```
Expand Down
26 changes: 19 additions & 7 deletions docs/stages.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,21 +48,33 @@ with its reason code; any other error of a converter gives it with `generic.erro

| 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 |
| Texture2D | `tex.png/2` | `image`: PNG of the first mip level (HDR ASTC: `.astc`), `json`: the typetree without `image data` (settings, colour space, mip count, stream data) | `texture.decode`, `png.encode`, `astc.container` | further mip levels as facts; a texture without pixels: `empty.texture` |
| Sprite | `sprite.png/2` | `image`: PNG on the sprite's rect (packing undone, tight mask applied), `json`: the typetree, geometry, render data and image (see below) | `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 `json` here (with its geometry and object facts) and its `image` from `sprite.crop` |
| Mesh | `mesh.glb/2` | `mesh`: binary glTF in the mesh's local space, `json`: the typetree without the vertex, index and compressed mesh data | `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/2` | `json`: 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/2` | `json`: the Mecanim clip data and its bindings as stored (see below) | | a legacy or compressed clip: `generic.clip.legacy` |
| AnimatorController | `controller.json/2` | `json`: name, clips (`$ref`), layers, state machines, parameters, default values | | 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` | |
| TextAsset | `data/2` | `data`: the stored bytes, `json`: the typetree without `m_Script` | `sniff` | extension from the container path, else from the content |
| Font | `font/2` | `font`: the font file, `json`: the typetree without `m_FontData` | `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` | |

Every object a converter takes has a `json` artifact or is contained in another object's. Each item lists in
`refs` the objects its documents reference (by object id, other bundles' objects included, the objects of its own
prefab left out): the objects reachable from an object follow from the items alone.

A sprite's `json` holds `sprite` (its typetree), `mesh` (its geometry decoded: `positions` in the sprite's units,
`submeshes` of triangles, `uv0` when stored), `renderData` (`source`: `renderData` for the sprite's own render
data, `atlas` for its SpriteAtlas' entry; `atlas`; and that render data's `texture`, `alphaTexture`,
`secondaryTextures`, `textureRect`, `textureRectOffset`, `atlasRectOffset`, `uvTransform`, `downscaleMultiplier`,
`settingsRaw`) and `image` (`packed`, `packingMode`, `packingRotation`, `width`, `height`). The texture coordinate
of a position `p` is `((p.x * uvTransform.x + uvTransform.y) / width, (p.y * uvTransform.z + uvTransform.w) /
height)` of the render data's texture.

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
Expand Down Expand Up @@ -110,7 +122,7 @@ the same way: its `data.data` bytes, whatever their size, are the artifact `<obj
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
subject>:<atlas path id>`). Its inputs are the atlas' JSON, the `json` 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.

Expand Down
15 changes: 12 additions & 3 deletions src/nnnotes/pipeline/contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@
# locator kinds of a task input: where the bytes can be read (never part of the key)
LOCATOR_KINDS = ("depot", "download", "file", "catalog")
# an object's primary artifact is the one whose role comes first here; its other artifacts are secondary
PRIMARY_ROLES = ("image", "mesh", "audio", "video", "font", "data", "prefab", "json")
PRIMARY_ROLES = ("image", "mesh", "audio", "video", "font", "data", "json")
_EXT = re.compile(r"[0-9a-z]+")
_BUNDLE_SUFFIX = re.compile(r"(_[0-9a-f]{32})?\.bundle$")

Expand Down Expand Up @@ -324,11 +324,18 @@ def reason(code: str, message: str = "") -> dict:


def item(
obj: str, status: str, *, artifacts=(), within: str | None = None, why: dict | None = None, cls: str | None = None
obj: str,
status: str,
*,
artifacts=(),
within: str | None = None,
why: dict | None = None,
cls: str | None = None,
refs=(),
) -> dict:
"""The outcome for one object of a task. exported: its artifacts; contained: `within` (the artifact it is part
of); generic: its artifacts and why the specialized form was not made; unsupported: why (and artifacts, if
any); failed: why."""
any); failed: why. `refs`: the ids of the other objects its documents reference."""
if status not in ITEM_STATUSES:
raise ValueError(f"item {obj}: unknown status {status!r}")
need_art = status in ("exported", "generic")
Expand All @@ -348,6 +355,8 @@ def item(
d["artifacts"] = sorted(artifacts)
if within is not None:
d["in"] = within
if refs:
d["refs"] = sorted(set(refs))
if why is not None:
d["reason"] = why
return d
Expand Down
6 changes: 4 additions & 2 deletions src/nnnotes/pipeline/layout.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@
`~<pathId>` (stem[name]~123.png)
- an object's primary artifact (contract.PRIMARY_ROLES) is written at that path, its extension appended unless
the path already ends with it (x.prefab -> x.prefab.json, y.png -> y.png); its other artifacts at
`<path>.<role>.<ext>` (':' and '/' of the role as '.')
`<path>.<role>.<ext>` (':' and '/' of the role as '.'), `<path>.<ext>` for a role that is its extension
(y.png -> y.png.json)
- objects without a container: `<root>/<Class>/<name>~<pathId>.<ext>`, root = the root of the object's
serialized file when one is given (`object_roots`: the export command roots every object of a bundle at
`_bundles/<stable bundle name>`, whichever task made the artifact), else the source's root; under a root whose
Expand Down Expand Up @@ -301,7 +302,8 @@ def _object_files(base: list[str], records: list[dict], append_ext: bool) -> lis
segs = base[:-1] + [f"{base[-1]}.{ext}"] if append_ext else _with_ext(base, ext)
else:
role = contract.parse_artifact_id(r["id"])[1]
segs = base[:-1] + [f"{base[-1]}.{_role_segment(role)}.{ext}"]
name = f"{base[-1]}.{ext}" if role == ext else f"{base[-1]}.{_role_segment(role)}.{ext}"
segs = base[:-1] + [name]
out.append((segs, _entry("", r)))
return out

Expand Down
19 changes: 13 additions & 6 deletions src/nnnotes/pipeline/objexport.py
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@ def _emit(

def _begin(self, conv: str) -> None:
self.staged, self.conv = [], conv
self.ctx.seen.clear()

def _publish(self) -> list[str]:
"""Keep the staged artifacts; returns their ids."""
Expand All @@ -139,10 +140,15 @@ def _publish(self) -> list[str]:
self.staged = []
return ids

def _refs(self, info) -> list[str]:
"""The objects the documents of `info` made since the last _begin reference."""
return sorted(self.ctx.seen - {info.id})

def _commit(self, info, status: str, why: dict | None = None) -> None:
"""The item of `info`: the artifacts its conversion staged."""
"""The item of `info`: the artifacts its conversion staged and the objects they reference."""
refs = self._refs(info)
ids = self._publish()
self.items[info.id] = contract.item(info.id, status, artifacts=ids, why=why, cls=info.class_name)
self.items[info.id] = contract.item(info.id, status, artifacts=ids, why=why, cls=info.class_name, refs=refs)

def _failed(self, info, e: Exception, prefix: str = "") -> None:
self._begin("generic.json")
Expand Down Expand Up @@ -270,7 +276,7 @@ def _prefabs(self) -> None:
self._fallback(info_of[k], "generic.error", f"hierarchy at transform {root.transform}: {why}")
continue
if self._convert(info, "prefab.json", prefab.prefab, h, root):
within = contract.artifact_id(info.id, "prefab")
within = contract.artifact_id(info.id, "json")
for k in root.members:
i = info_of[k]
if i.id != info.id:
Expand All @@ -293,18 +299,19 @@ def _loose(self, cls: str, conv: str, kind: str) -> None:
for i in infos:
mark = len(self.staged)
key = (i.file, i.path_id)
self.ctx.seen.clear()
try:
entries[i.id] = self.ctx.tree(self.ctx.readers[key], self.ctx.tt(key))
done.append(i)
done.append((i, self._refs(i)))
except Exception as e:
del self.staged[mark:]
failed.append((i, e))
if done:
doc = encode_object(generic.loose_doc(self.ctx, conv, entries))
aid = self._emit(None, conv, doc, "json", kind, "json", {"objects": len(done)}, owner=self.task.id)[0]
self._publish()
for i in done:
self.items[i.id] = contract.item(i.id, "contained", within=aid, cls=i.class_name)
for i, refs in done:
self.items[i.id] = contract.item(i.id, "contained", within=aid, cls=i.class_name, refs=refs)
for i, e in failed:
if isinstance(e, ReadError):
self._failed(i, e)
Expand Down
9 changes: 9 additions & 0 deletions src/nnnotes/schemas/result.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,15 @@
"in": {
"$ref": "urn:nnnotes:schema:common#/$defs/artifactId"
},
"refs": {
"description": "The ids of the other objects the object's documents reference, other bundles' objects included.",
"type": "array",
"items": {
"type": "string",
"minLength": 1
},
"minItems": 1
},
"reason": {
"$ref": "urn:nnnotes:schema:common#/$defs/reason"
}
Expand Down
19 changes: 11 additions & 8 deletions src/nnnotes/unity/convert/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@
bundle holds, with the atoms they use (CONVERTER_ATOMS).

Texture2D tex.png `image`: PNG of the first mip level (HDR ASTC: the .astc file)
Sprite sprite.png `image`: PNG on the sprite's rect; `meta`: JSON
Sprite sprite.png `image`: PNG on the sprite's rect; `json`: typetree, geometry, render data
Mesh mesh.glb `mesh`: binary glTF in the mesh's local space
GameObject (a root) prefab.json `prefab`: the hierarchy below it with its components
GameObject (a root) prefab.json `json`: the hierarchy below it with its components
Material material.json `json`
Shader shader.json `json`: the parsed form as `nnnotes export shader` writes it;
`program:<platform>/s<S>p<P>_<stage>_<N>`: each compiled sub-program as
Expand All @@ -25,6 +25,9 @@
AssetBundle bundle.json "<task id>#bundle.json": serialized files, externals, AssetBundle objects
any other class generic.json `json`: the object's typetree

Every object a converter takes has a `json` artifact (or is contained in one): the converter's document, or the
typetree JSON (a Texture2D, Mesh, TextAsset or Font without the bytes its other artifact holds).

Object JSON (canon/1 in the object's own field order):
- a reference is {"$ref": {"file", "pathId"}}, with "class" and "name" when the target is in the bundle; a null
reference is null. Inside a prefab, a GameObject, Transform or component of the same prefab is named by its path:
Expand Down Expand Up @@ -60,16 +63,16 @@

# converter -> version
CONVERTERS = {
"tex.png": 1,
"sprite.png": 1,
"mesh.glb": 1,
"prefab.json": 1,
"tex.png": 2,
"sprite.png": 2,
"mesh.glb": 2,
"prefab.json": 2,
"material.json": 1,
"clip.json": 2,
"controller.json": 2,
"mono.json": 2,
"data": 1,
"font": 1,
"data": 2,
"font": 2,
"scripts.json": 1,
"bundle.json": 1,
"shader.json": 1,
Expand Down
31 changes: 23 additions & 8 deletions src/nnnotes/unity/convert/files.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,26 @@
"""Mesh (mesh.glb), TextAsset (data) and Font (font)."""
"""Mesh (mesh.glb), TextAsset (data) and Font (font). Each also has `json`: its typetree JSON without the bytes its
other artifact holds."""

from __future__ import annotations

from ..atoms import Unsupported
from .objects import BundleObjects, container_ext
from .objects import BundleObjects, container_ext, encode_object, omit

__all__ = ["FONT_FORMATS", "font", "mesh", "text"]
__all__ = ["FONT_FORMATS", "MESH_DATA", "font", "mesh", "text"]

FONT_FORMATS = ("ttf", "otf", "ttc", "woff", "woff2")
# the fields of a Mesh whose data its glTF holds decoded: vertices, indices, the compressed form
MESH_DATA = ("m_VertexData/m_DataSize", "m_IndexBuffer", "m_CompressedMesh")


def _json(ctx: BundleObjects, key: tuple, tt: dict, *data: str) -> None:
doc = ctx.tree(ctx.readers[key], omit(tt, *data))
ctx.emit(key, "json", encode_object(doc), "json", "unity.object", "json", {})


def mesh(ctx: BundleObjects, info) -> None:
"""`mesh`: binary glTF in the mesh's local space; the limits of its conversion as issues."""
"""`mesh`: binary glTF in the mesh's local space, the limits of its conversion as issues; `json` without the
vertex, index and compressed mesh data (MESH_DATA)."""
key = (info.file, info.path_id)
m = ctx.fns["mesh.arrays"](ctx.view.mesh_input(info))
glb = ctx.fns["gltf.write"]([m])
Expand All @@ -22,25 +31,31 @@ def mesh(ctx: BundleObjects, info) -> None:
"skinned": m.skin is not None,
}
ctx.emit(key, "mesh", glb, "glb", "mesh.gltf", "glb", facts, issues=m.issues)
_json(ctx, key, ctx.tt(key), *MESH_DATA)


def text(ctx: BundleObjects, info) -> None:
"""`data`: the stored bytes, the extension from the container path, else from the content."""
"""`data`: the stored bytes, the extension from the container path, else from the content; `json` without
them (`m_Script`)."""
key = (info.file, info.path_id)
s = ctx.tt(key).get("m_Script", "")
tt = ctx.tt(key)
s = tt.get("m_Script", "")
data = s.encode("utf-8", "surrogateescape") if isinstance(s, str) else bytes(s or b"")
container = ctx.containers.get(key)
sn = ctx.fns["sniff"](data, container)
ext = container_ext(container) or sn.ext
ctx.emit(key, "data", data, ext, "text", sn.format, {"size": len(data)})
_json(ctx, key, tt, "m_Script")


def font(ctx: BundleObjects, info) -> None:
"""`font`: the font file."""
"""`font`: the font file; `json` without it (`m_FontData`)."""
key = (info.file, info.path_id)
data = bytes(ctx.tt(key).get("m_FontData") or b"")
tt = ctx.tt(key)
data = bytes(tt.get("m_FontData") or b"")
if not data:
raise Unsupported("no_data.font", "the font has no font data")
sn = ctx.fns["sniff"](data)
ext = sn.ext if sn.format in FONT_FORMATS else (container_ext(ctx.containers.get(key)) or sn.ext)
ctx.emit(key, "font", data, ext, "font", sn.format, {"size": len(data)})
_json(ctx, key, tt, "m_FontData")
Loading
Loading