Related: [[TODO.md]], [[BENCHMARKS.md]], [[fab.md]], [[sma-migration.md]], [[unity-probes.md]]
Rust rlib consumed by meow-tower via the shared BoxcatBridge cdylib (see Packages/com.boxcat.libs/Native~/bridge/ in meow-tower). Authors Unity Sprite .asset files byte-exactly from a TexturePacker .tpsheet + .tps + atlas .png.
C# (TPSheetPostprocessor) is the AssetPostprocessor entry point β for each imported .tpsheet it reads _prefix from TPSImporter on the sibling .tps, picks up PPU from the .png's TextureImporter, and calls BoxcatBridge.SpriteAuthorGenerate(...), which dispatches into this rlib's pipeline::generate. The pipeline does parse + bytes + filesystem; C# routes the returned written/deleted paths back through AssetDatabase.ImportAsset/DeleteAsset.
The previous C# CreateSprites path was slow (managed), non-deterministic across Unity versions, and re-ran on every reimport. Moving it to native code with a byte-exact contract makes it fast, deterministic, and version-stable.
The bar is byte-exactness: output must equal what Unity's EditorUtility.CopySerialized emits, byte-for-byte. Existing committed .asset files survive the swap with no diff churn β AssetBundle hashes, addressables, .meta GUID refs stay stable.
- Input (path-based): tpsheet path, tps path, atlas
.pngpath, output sprite dir, prefix string, PPU. - Output: byte-exact
.asset+ matching.asset.metaper sprite. Orphan.asset/.asset.metapruned..tpsheet+.tpsheet.metadeleted on success. - Failure: all-or-nothing. Nothing written, nothing deleted, error returned to the caller.
- Other Unity asset types (
.controller,.spriteatlasv2, etc.). Sprite-only by design. - Reimplementing Unity's tight-mesh tracer / alpha outline algorithms. Tpsheet always carries verts + tris.
- Watcher / scratch-path workflow. (A thin
unity-sprite-authorCLI exists β see [[#cli]] β but it's a one-shot orchestrator, not a daemon.) - Cross-Unity-version compat. Target one version at a time; bump explicitly.
TexturePacker β Foo.tps + Foo.tpsheet (in Assets/, alongside Foo.png)
β
Unity import β TPSheetPostprocessor.OnPostprocessAllAssets
β
For each *.tpsheet: prefix from TPSImporter (.tps), PPU from TextureImporter (.png)
β
BoxcatBridge.SpriteAuthorGenerate β bxc_sprite_author_generate (cdylib) β
unity_sprite_author::pipeline::generate (this rlib)
β
For each written/deleted path: AssetDatabase.ImportAsset / DeleteAsset
β
(.tpsheet + .tpsheet.meta gone; .asset + .asset.meta written or pruned)
The only entry point callers use is pipeline::generate. The crate has no FFI of its own β the BoxcatBridge cdylib in meow-tower wraps this fn behind bxc_sprite_author_generate and handles C# marshalling.
use std::path::Path;
use unity_sprite_author::pipeline;
let result = pipeline::generate(&pipeline::GenerateInputs {
tpsheet_path: Path::new("Assets/Atlas.tpsheet"),
tps_path: Path::new("Assets/Atlas.tps"),
atlas_png_path: Path::new("Assets/Atlas.png"), // sibling .png.meta read for atlas GUID + `alphaIsTransparency` sync
sprite_dir: Path::new("Assets/Sprites"), // output dir
prefix: "Atlas", // "" if none
ppu: 100.0, // from TextureImporter.spritePixelsPerUnit
})?;
// result.written_paths β new/updated sprite .asset paths, plus the atlas
// .png when alphaIsTransparency was rewritten
// (caller invokes AssetDatabase.ImportAsset on each)
// result.deleted_paths β pruned .asset paths + the consumed .tpsheet(.meta)For non-pipeline consumers (the GUI editor today), combine::build_combined_with_ranges returns the same CombinedMesh plus per-part [start, end) index ranges into the merged vertex arrays β useful for picking, outlining, and vertex-color overrides without re-running the build per part. build_combined is a one-line wrapper that discards the ranges.
- No panics in normal control flow.
pipeline::generatereturnsResult<GenerateOutput, Error>.unwrap/expectreserved for genuine bugs. The bridge wraps the call incatch_unwindand surfaces panics asrc=2with the panic message. - Two-phase commit (mandatory): Phase 1 collects all
(path, bytes)pairs in memory β any error here = nothing written. Phase 2 writes each.asset+.asset.metato a.tmpsibling, then atomic-renames after all temps succeed. Only after every rename succeeds: prune orphans, delete.tpsheet+.tpsheet.meta. On phase-2 failure: clean up.tmpfiles, leave originals, return error. Pre-existing.tmpfiles from prior crashes are deleted at function entry. - Skip-write-if-equal: before writing, read existing bytes; if identical, skip. Avoids mtime churn that would re-import dependents in Unity.
- No global state: no
OnceCell,lazy_static, thread-locals, or caches outliving ageneratecall. Mono does not unload native plugins on domain reload β global state would leak across script recompiles. .tpsheet+.tpsheet.metadeletion happens inside this crate on success only. Both paths are reported indeleted_pathsso the caller can route them throughAssetDatabase.DeleteAsset.- Atlas
.png.metaalphaIsTransparencyis kept in lockstep with the tpsheet'salphahandlingheader:PremultiplyAlpha/KeepTransparentPixelsβ0(premultiplied); anything else β1(Unity straight-alpha default). Surgical line rewrite β only the value flips. When a rewrite happens, the atlas.pngpath is appended towritten_pathsso the caller'sAssetDatabase.ImportAssetretriggersTextureImporter(a.meta-only touch isn't always enough). Missing field βError::Meta(NoAlphaIsTransparencyField).
unity-sprite-author <atlas.tps> packs the .tps with TexturePackerCLI (shells out to texturepacker), mints any missing .tps.meta / .png.meta via the unity-assetdb register API, then runs pipeline::generate. Intended for one-shot regens outside the Unity Editor β Unity must NOT be running, same caveat as scripts/regen-corpus.sh.
just install # builds release, symlinks to ~/.local/bin/
unity-sprite-author Atlas.tps # pack + author; prefix/ppu read from existing metas
unity-sprite-author Atlas.tps --prefix AC_ --ppu 100 --sprite-dir Atlas
unity-sprite-author Atlas.tps --skip-pack # reuse existing .tpsheet/.png--prefix / --ppu override the meta-derived defaults. PPU is required (CLI flag or spritePixelsToUnits in .png.meta); prefix defaults to "" when neither source supplies one. Default --sprite-dir is <tps-parent>/<tps-stem>/ (matches the meow-tower convention). .tps.fab.json / .tps.mesh.json sidecars are picked up automatically since the bin just forwards tps_path to pipeline::generate β no extra flags.
StartAssetEditing is not wrapped around the call β Rust writes raw bytes that the editing batch wouldn't observe anyway. AssetDatabase.Refresh() is not called β too coarse, retriggers postprocessors. The canonical C# integration is meow-tower's TPSheetPostprocessor.cs calling BoxcatBridge.SpriteAuthorGenerate(...).
- For sprite
<name>in output dir<dir>:- If
<dir>/<prefix><name>.asset.metaexists β read existingguid, preserve. Detect the file's shape (Legacy189 vs Modern186,mainObjectFileIDvalue) and rewrite in the same shape so on-disk bytes don't churn just because we touched the file. Theguidand detected shape are the only preserved values. Hand-edits to other fields are not supported. - Else β mint random 128-bit GUID, write a fresh
.asset.metain the Modern186 shape withmainObjectFileID: 21300000.
- If
m_RenderDataKeyin the.assetbody uses the SAME GUID as the sibling.asset.meta(verified againstCake__DecoLeft.asset.metacorpus, 3645 files:m_RenderDataKeyalways equals own meta GUID).- Renames must be done in tpsheet AND Unity at the same time by the developer. This design does not detect renames automatically.
Two trailing-space variants exist in the corpus and one varying field:
- Legacy189 (older Unity emit): trailing space after
userData:,assetBundleName:,assetBundleVariant:. 189 bytes. - Modern186 (current Unity emit): no trailing spaces. 186 bytes.
mainObjectFileID: usually21300000(Sprite class fileID). Some incompletely-imported sprites carry0instead.
To avoid byte churn on existing metas the pipeline preserves both axes when present (meta::detect_shape). Fresh mints use Modern186 + 21300000. Schema (Legacy189 form shown):
fileFormatVersion: 2
guid: <32-lower-hex>
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 21300000
userData:
assetBundleName:
assetBundleVariant:
Random-mint conflicts with byte-equal goldens. Strategy: tests stage the committed .asset.meta into the temp input dir before running the pipeline, so only the preserve branch is exercised by golden tests. The mint branch is covered by meta::tests::mint_guid_from_seeds_is_deterministic β calls a mint_guid_from(lo, hi) helper directly with fixed entropy words so the rendered .asset.meta bytes are deterministic. (mint_guid() itself uses two RandomState::hash_one calls; no rand crate.)
tpsheet line format (semicolon-separated):
<name>;<x>;<y>;<w>;<h>; <pivotX>;<pivotY>; <bL>;<bR>;<bT>;<bB>;
<vCount>;<v0x>;<v0y>;...;
<triCount>;<t0a>;<t0b>;<t0c>;...
Sprite .asset field |
Source |
|---|---|
m_Rect |
tpsheet rect |
textureRect |
always tpsheet rect. If an existing .asset carries a divergent textureRect.{w,h} (only seen on legacy Tight + spriteMode: Multiple outputs), generate() emits a non-fatal warning via GenerateOutput.warnings (also echoed to stderr) and overwrites with the current tpsheet's rect. Current tpsheet is authoritative. |
m_Pivot |
tpsheet pivot |
m_Border |
tpsheet borders (LRTB) |
m_PixelsToUnits |
ppu / spriteScale (PPU from importer; spriteScale from .tps) |
_typelessdata pos (stream 0) |
(px β wΒ·pivotX)/ppu, (py β hΒ·pivotY)/ppu, 0 β vec3 f32 LE |
_typelessdata uv (stream 1) |
(rect.x + px)/atlasW, (rect.y + py)/atlasH β vec2 f32 LE |
_typelessdata layout |
stream 0 packed, padded up to 16-byte boundary, then stream 1 |
m_DataSize |
align16(vCountΒ·12) + vCountΒ·8 |
m_IndexBuffer |
tpsheet triangles, u16 LE |
uvTransform |
(ppu, rect.x + wΒ·pivotX, ppu, rect.y + hΒ·pivotY) |
settingsRaw |
constant 192 (0xC0). No emit-side guard β divergence surfaces via the e2e byte-mismatch. |
texture GUID |
from atlas .png.meta |
m_RenderDataKey GUID |
own .asset.meta GUID (preserve or mint per policy above) |
Reference fixture for parity testing: tests/golden/orgel/ β a self-contained snapshot of Orgel.{tpsheet, tps, png.meta} + the per-sprite .asset / .asset.meta corpus (Cake__DecoLeft.asset is the canonical example). The matching meow-tower-side files live under meow-tower/Assets/21_Collections/OrgelContents/1204/Orgel/, but the .tpsheet there is ephemeral β pipeline::generate deletes it on success, so it's only present mid-import.
C# integration (matched-pair to this crate, in meow-tower):
Packages/com.boxcat.libs/TexturePacker/TPSheetPostprocessor.csβOnPostprocessAllAssetsentry point; callsBoxcatBridge.SpriteAuthorGenerate(...).Packages/com.boxcat.libs/Native/BoxcatBridge.csβSpriteAuthorGeneratewrapper +SpriteAuthorResultshape; marshals UTF-8 paths and frees the native arena.Packages/com.boxcat.libs/Native~/bridge/src/lib.rsβbxc_sprite_author_generate/bxc_sprite_author_freeexports wrappingunity_sprite_author::pipeline::generate.Packages/com.boxcat.libs/TexturePacker/TPSImporter.csβScriptedImporterholding_prefixon.tps.Packages/com.boxcat.libs/TexturePacker/TexturePackerUtils.csβ.tpsparsing forspriteScale(and_prefixreader for menu items).Packages/com.boxcat.libs/TexturePacker/SheetLoader.csβ historical tpsheet parser. No longer on the import path; useful as a cross-reference for our parser.
TS port for mesh internals (proven byte-exact for m_IndexBuffer, float-tolerant elsewhere):
prefab-saloon/src/lib/sprite/tpsheet-parser.tsprefab-saloon/src/lib/sprite/generator.tsβ portencodeTypelessData,encodeIndexBuffer,pixelToLocal,pixelToUV,alignTo16verbatim.prefab-saloon/src/lib/sprite/generator.test.tsβ 4 verified mesh fixtures; lift into Rust tests.
Do not port: prefab-saloon/src/lib/prefab/{parser,serializer,templates}.ts. We don't read .asset files, and the YAML emitter must be Unity-flavor specific from day one.
m_PackingTag:andm_SpriteID:end with a literal trailing space before LF.- File ends
m_SpriteID: \nwith single LF, no trailing blank line. _typelessdatais one unbroken hex line, never folded.m_RenderDataKeyis the only non-flow nested mapping; everything else is flow{x: ..., y: ...}.atlasRectOffset: {x: -1, y: -1}β Unity's sentinel for non-SpriteAtlas sprites. Constant; applies uniformly to TexturePacker-imported sprites AND toSpriteFactory.CreateFromMeshoutputs (verified against the Silloutte1 golden). The earlier "fabricated ships (0, 0)" claim was wrong β emit doesn't branch onSpriteSourcehere.m_Borderfield order is{x: L, y: B, z: R, w: T}per UnitySprite.cs. Verified empirically: 50/51 non-zero-border sprites in the meow-tower corpus emit byte-exactly under the current formula (the lone outlier is .tps drift β golden has all-zero borders, current tpsheet has non-zero). The hard-fail guard was retired once this was proven.- Float formatting must match C#
ToString("R").yaml::floatuses Rust's defaultDisplay, which matches across the entire golden corpus (93 distinct fractional literals); the round-trip guard lives inyaml::tests::float_corpus_full_roundtripso a future Display divergence surfaces as a unit failure instead of a golden-byte mismatch. m_AtlasRD == m_RDfor non-SpriteAtlas sprites (verified across the corpus); emit always writesm_SpriteAtlas: {fileID: 0}as a constant. The "diverge under SpriteAtlas" hypothesis lives as a deferred probe in [[unity-probes.md#c-m_atlasrd-vs-m_rd-divergence-under-spriteatlas]] β guard wiring waits on a fixture.- LF line endings; pin via
.gitattributes(*.asset binary,*.asset.meta binary). mainObjectFileID: 21300000in every sprite.asset.meta(Unity class ID 213). Constant, not parameterized.
- Rust stable. Primary artifact is the
rlibconsumed by BoxcatBridge (meow-tower/Packages/com.boxcat.libs/Native~/bridge/); a thinunity-sprite-authorbin (see [[#cli]]) shares the same rlib for offline packs. No cdylib lives here β that path was retired when sprite-author was folded into the BoxcatBridge cdylib. [profile.release] panic = "unwind"β required by the bridge's outercatch_unwind. Inner code returnsResult;unwrap/expectreserved for genuine bugs.- Custom Unity-flavor YAML emitter; no
serde_yaml.yaml::floatmatches C#ToString("R")β table-driven tests inyaml::tests::float_corpus_full_roundtripseeded from every distinct float in the golden corpus. - Golden-file
assert_eq!over committed Unity-emitted samples..gitattributespins*.asset binaryand*.asset.meta binaryto prevent CRLF conversion. - Cross-platform concerns (universal macOS dylib, Windows UCRT linkage) now live in the bridge crate, not here.
One-shot rollout shipped (meow-tower commits 0d9143ecβ¦668fd2eb). The .tpsheet.meta _prefix was relocated to .tps.meta (the new TPSImporter ScriptedImporter's home) by scripts/migrate-tpsheet-meta.sh β idempotent, --dry-run flag, reversible via git. Re-run is safe; skips already-migrated .tps.meta.
Cargo workspace. crates/core is the rlib consumed by the BoxcatBridge cdylib
in meow-tower; crates/cli is the offline unity-sprite-author binary that
shells out to TexturePackerCLI and threads the result into core;
crates/editor is a standalone GUI tool (eframe + egui, native macOS
menubar via muda, persisted prefs, command palette) for authoring
.tps.fab.json files. Every user-facing command in the editor flows through
one Action enum (see crates/editor/src/action.rs) β adding a feature
means: extend Action, add a match arm in App::dispatch, optionally
register a CommandEntry for palette discoverability. The bridge crate in
meow-tower points its path = "..." at crates/core/ β editor and CLI
never leak into the rlib.
unity-sprite-author/
βββ Cargo.toml # [workspace] root β members: crates/core, crates/cli
βββ crates/
β βββ core/ # rlib (package: unity-sprite-author, lib: unity_sprite_author)
β β βββ src/
β β β βββ lib.rs # module declarations
β β β βββ pipeline.rs # orchestrate: parse β build β write β prune β delete
β β β βββ tpsheet.rs # parser (mirrors SheetLoader.cs)
β β β βββ tps.rs # minimal parser (spriteScale lookup)
β β β βββ meta.rs # .png.meta GUID read + alphaIsTransparency rewrite; .asset.meta read/write
β β β βββ render_data.rs # _typelessdata, m_IndexBuffer, uvTransform
β β β βββ emit.rs # SpriteAsset β bytes
β β β βββ yaml.rs # Unity-flavor YAML + yaml::float (C# ToString("R"))
β β β βββ triangulator.rs # ear-clipping triangulator for fab polygon parts
β β β βββ combine.rs # fab combined-sprite mesh stitching
β β β βββ manifest.rs # .tps.fab.json unified tree (CSA + SMA) β fab/mesh bridge
β β β βββ fab.rs # typed IR for fabricated combined sprites (consumed by combine.rs)
β β β βββ mesh_emit.rs # Mesh .asset emit (SpriteRenderer half2 UVs + CanvasRenderer f32 UVs)
β β β βββ mesh_manifest.rs # Mesh IR consumed by `mesh_emit`; v3 manifest bridges into it
β β βββ tests/
β β β βββ golden_parity.rs # byte-equality on the Orgel corpus
β β β βββ golden_fab_silloutte.rs # fab manifest β byte-exact Silloutte{1,2}.asset
β β β βββ golden_fab_treasure_trove.rs # fab manifest β ULP-tolerance rect/pivot/offset/vert diff vs pre-c23474b2 TreasureTrove goldens (4 sprites)
β β β βββ golden_sma_mesh.rs # Mesh .asset byte-exact (Box_29_Ghost, 32 meshes)
β β β βββ e2e_meow_tower.rs # opt-in walk of the meow-tower checkout
β β β βββ golden/ # committed .tpsheet + .tps + .png.meta + expected .asset
β β βββ examples/
β β β βββ drift_report.rs # diagnostic β runs across meow-tower, prints first diff per atlas
β β β βββ sma_dumper.cs # Unity scratch β dump SMA SpriteRenderer tree
β β β βββ regen_offline.rs # single-atlas runner over staged tps/tpsheet/png.meta/fab.json (no TexturePackerCLI)
β β β βββ migrate_corpus.rs # offline corpus migration runner (no Unity Editor)
β β βββ benches/
β β βββ pipeline.rs # criterion harness β full pipeline + per-stage hot paths
β βββ cli/ # `unity-sprite-author` binary (package: unity-sprite-author-cli)
β β βββ src/
β β βββ main.rs # offline CLI: pack .tps + author sprites
β βββ editor/ # GUI editor for `.tps.fab.json` (package: unity-sprite-author-editor)
β βββ src/
β β βββ main.rs # eframe entry
β β βββ app.rs # App + tabs + undo/redo + Action dispatch
β β βββ action.rs # Action enum + palette CommandEntry registry
β β βββ ops.rs # TreeOp, NodeEdit, NewGraphic + apply helpers
β β βββ command_palette.rs # Cmd+Shift+P modal
β β βββ menubar.rs # native macOS menubar via `muda` (other OS: stub)
β β βββ preferences.rs # eframe::Storage-backed user prefs
β β βββ theme.rs # central color constants + helpers
β β βββ doc.rs # Doc + NodePath + Color_* sprite normalization
β β βββ selection.rs # multi-select state w/ click-modifier semantics
β β βββ atlas.rs # sibling .tpsheet/.png/.tps load + auto-pack + thumbnail rasterizer
β β βββ tree_panel.rs # left panel: tree edit + drag-reorder + thumbnails
β β βββ inspector.rs # right panel: per-node editor
β β βββ preview.rs # center canvas: composed mesh + rulers + guides + handles
β β βββ picker.rs # sprite / color modals
β β βββ serialize.rs # manifest β fab.json round-trip
β βββ tests/
β βββ app_ops.rs # headless drive of the pending-op pipeline
β βββ round_trip_goldens.rs # parse β serialize β parse against fixtures
βββ docs/
β βββ fab.md # .tps.fab.json schema + per-part transform math
β βββ sma-migration.md # SpriteMeshAuthor β mesh_emit migration map
β βββ unity-probes.md # in-Editor procedures for the four blocked TODOs
βββ scripts/
β βββ migrate-tpsheet-meta.sh # --dry-run; .tpsheet.meta β .tps.meta
β βββ regen-corpus.sh # TexturePackerCLI sweep over $MEOW_CLIENT/Assets
β βββ delete-authoring.sh # Phase 3 atomic deletion of authoring C# + prefabs
βββ justfile # `just install` β ~/.local/bin/unity-sprite-author
βββ CLAUDE.md