diff --git a/docs/contracts.md b/docs/contracts.md index 2cf4e7f..8806c9e 100644 --- a/docs/contracts.md +++ b/docs/contracts.md @@ -230,6 +230,11 @@ The music data file of `nnnotes music-data`, `nnnotes.music-data/1` ([schema/music-data.schema.json](schema/music-data.schema.json)), names its format in a `format` field; it is described in [music-data.md](music-data.md). +The metadata-only Sprite observation file, `nnnotes.observed-sprite-geometries/1` +([schema/sprite-geometries.schema.json](schema/sprite-geometries.schema.json)), +retains original geometry and input hashes independently of public artwork +bitmaps; usage and evidence scope are in [sprite-geometries.md](sprite-geometries.md). + ## Store The store is a directory, and the cache of all stage work: diff --git a/docs/schema/sprite-geometries.schema.json b/docs/schema/sprite-geometries.schema.json new file mode 100644 index 0000000..2f10905 --- /dev/null +++ b/docs/schema/sprite-geometries.schema.json @@ -0,0 +1,59 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "urn:nnnotes:schema:sprite-geometries", + "title": "nnnotes.observed-sprite-geometries/1", + "type": "object", + "required": ["schema", "region", "client", "catalogSha256", "source", "sprites"], + "properties": { + "schema": {"const": "nnnotes.observed-sprite-geometries/1"}, + "region": {"type": "string", "minLength": 1}, + "client": {"type": "object", "properties": {"versionName": {"type": "string"}, "versionCode": {"type": "integer"}}}, + "catalogSha256": {"$ref": "#/$defs/sha"}, + "source": { + "type": "object", "required": ["kind", "catalogs", "native", "runtimeVerified"], + "properties": { + "kind": {"const": "observed-nnnotes-sprite-metadata"}, + "catalogs": {"type": "object", "required": ["remote"], "additionalProperties": {"$ref": "#/$defs/fingerprint"}}, + "native": {"type": "object"}, + "runtimeVerified": {"const": false}, + "publicBitmaps": {"$ref": "#/$defs/file"} + } + }, + "sprites": { + "type": "object", "minProperties": 1, + "additionalProperties": { + "type": "object", "required": ["geometry", "source"], + "properties": { + "geometry": { + "type": "object", "required": ["rect", "textureRect", "textureRectOffset", "pivot", "border", "pixelsPerUnit", "settingsRaw", "downscaleMultiplier"], + "properties": { + "rect": {"$ref": "#/$defs/rect"}, "textureRect": {"$ref": "#/$defs/rect"}, + "textureRectOffset": {"$ref": "#/$defs/xy"}, "pivot": {"$ref": "#/$defs/xy"}, + "border": {"type": "object", "required": ["x", "y", "z", "w"], "additionalProperties": {"type": "number"}}, + "pixelsPerUnit": {"type": "number", "exclusiveMinimum": 0}, + "settingsRaw": {"type": "integer", "minimum": 0}, + "downscaleMultiplier": {"type": "number", "exclusiveMinimum": 0} + } + }, + "source": { + "type": "object", "required": ["key", "name", "assetFile", "pathId", "catalogSha256", "bundles"], + "properties": { + "key": {"type": "string", "minLength": 1}, "name": {"type": "string"}, + "assetFile": {"type": "string", "minLength": 1}, + "pathId": {"type": "string", "pattern": "^-?[0-9]+$"}, + "catalogSha256": {"$ref": "#/$defs/sha"}, + "bundles": {"type": "array", "minItems": 1, "items": {"$ref": "#/$defs/file"}} + } + } + } + } + } + }, + "$defs": { + "sha": {"type": "string", "pattern": "^[a-f0-9]{64}$"}, + "fingerprint": {"type": "object", "required": ["sha256", "size"], "properties": {"sha256": {"$ref": "#/$defs/sha"}, "size": {"type": "integer", "minimum": 0}}}, + "file": {"allOf": [{"$ref": "#/$defs/fingerprint"}, {"type": "object", "required": ["file"], "properties": {"file": {"type": "string", "minLength": 1}}}]}, + "xy": {"type": "object", "required": ["x", "y"], "properties": {"x": {"type": "number"}, "y": {"type": "number"}}}, + "rect": {"allOf": [{"$ref": "#/$defs/xy"}, {"type": "object", "required": ["width", "height"], "properties": {"width": {"type": "number", "minimum": 0}, "height": {"type": "number", "minimum": 0}}}]} + } +} diff --git a/docs/sprite-geometries.md b/docs/sprite-geometries.md new file mode 100644 index 0000000..1ce9190 --- /dev/null +++ b/docs/sprite-geometries.md @@ -0,0 +1,37 @@ +# Original Sprite geometry + +`nnnotes sprite-geometries` reads actual Sprite metadata through the same nnnotes +Exporter used for UI packs. It downloads only the explicitly selected bundle +closures and never decodes textures or writes game artwork into the repository. + +```sh +nnnotes --config private.toml --catalog saved-catalog.bin sprite-geometries \ + --keys selected-keys.json -o ../private-ui/sprite-geometries.json +``` + +`selected-keys.json` is a JSON array of exact Addressables keys. `--key` is also +repeatable. The output follows +[sprite-geometries.schema.json](schema/sprite-geometries.schema.json). +Each `sprites["key[actual Sprite name]"]` entry contains the original `rect`, +`textureRect`, `textureRectOffset`, pivot, border, pixels per unit, packing flags +and downscale multiplier. Its source records the actual asset file, decimal +string path ID and SHA-256/size of every decrypted bundle read. Catalog hashes +identify the saved input; resource version/hash is included when known. + +An APK adds its observed manifest fingerprint and client version. Missing client +fields remain absent. `--native-fingerprints` additionally hashes native +libraries from the supplied APK set. These observations do not certify current +runtime patches or pixel parity. No endpoint or session credentials are exported. + +A public bitmap can be a tight integer crop while Unity Image still uses the +original logical Sprite rectangle. For `ournotes-player/ui`, bind that bitmap +with `_previewSource` and its original geometry with `_previewSpriteGeometry`. +The trimmed preview expects `floor(textureRect.min)..ceil(textureRect.max)` and +supports unrotated, unscaled crops; other bitmap contracts require explicit +conversion. Full textures already containing the logical Sprite rectangle must +not be labeled as a trimmed crop. + +`--public-bitmaps observed-bitmaps.json` fingerprints a separate observation +file. A public URL, dimensions or file hash alone does not prove that its pixels +were generated from the native Sprite. Keep the native metadata and public +bitmap evidence distinct, and pin/verify both when publishing a UI library. diff --git a/docs/ui.md b/docs/ui.md index 57c1686..8e7a5fb 100644 --- a/docs/ui.md +++ b/docs/ui.md @@ -1,5 +1,9 @@ # Serialized UI libraries (experimental) +For external artwork binding, use the separate +[original Sprite geometry producer](sprite-geometries.md) to preserve the +logical rectangle and transparent trim offsets without decoding textures. + `nnnotes ui` exports an offline prefab library for the optional `ournotes-player/ui` preview module. It reads the embedded Addressables catalog and bundle closure of the APK set you supply; it does not contact a game API. Keep outputs outside the nnnotes and ournotes-player source repositories. The export contains game data that these diff --git a/src/nnnotes/cli.py b/src/nnnotes/cli.py index 5bc0f2a..89097c3 100644 --- a/src/nnnotes/cli.py +++ b/src/nnnotes/cli.py @@ -43,6 +43,7 @@ nnnotes run-stage [...] nnnotes catalogs list | import | fetch | diff nnnotes store verify + nnnotes sprite-geometries --keys selected-keys.json -o out/sprite-geometries.json """ from __future__ import annotations @@ -764,6 +765,11 @@ def cmd_ui(args, cfg): ui.command(args, cfg) +def cmd_sprite_geometries(args, cfg): + from . import spritegeometry + spritegeometry.command(args, cfg) + + def build_parser() -> argparse.ArgumentParser: p = argparse.ArgumentParser(prog="nnnotes", description="BanG Dream! Our Notes data toolkit") p.add_argument("--version", action="version", version=f"nnnotes {__version__}") @@ -1018,6 +1024,13 @@ def target(m, what): c.add_argument("--no-dependencies", action="store_true", help="omit additional prefab roots and controllers") c.add_argument("--force", action="store_true", help="re-export the selected keys") c.set_defaults(func=cmd_ui, usage=c.error) + c = sub.add_parser("sprite-geometries", help="observe original Sprite geometry without decoding textures") + c.add_argument("--key", action="append", help="exact catalog key (repeatable)") + c.add_argument("--keys", metavar="JSON", help="JSON array of exact catalog key strings") + c.add_argument("--native-fingerprints", action="store_true", help="also hash the configured APK's native libraries") + c.add_argument("--public-bitmaps", metavar="JSON", help="fingerprint a separately observed public bitmap listing") + _out(c, "Sprite metadata JSON outside the source repositories") + c.set_defaults(func=cmd_sprite_geometries, usage=c.error) return p diff --git a/src/nnnotes/spritegeometry.py b/src/nnnotes/spritegeometry.py new file mode 100644 index 0000000..0707f99 --- /dev/null +++ b/src/nnnotes/spritegeometry.py @@ -0,0 +1,118 @@ +"""Original Sprite geometry observations, without decoding or publishing textures. + +The output is game data and belongs outside source repositories. It preserves +the logical Sprite rectangle independently of a published bitmap's trim. +""" +from __future__ import annotations + +import hashlib +import json +from pathlib import Path + +from .cache import write_atomic +from .export import Exporter +from .jsonio import dumps + +SCHEMA = "nnnotes.observed-sprite-geometries/1" + + +def fingerprint(data: bytes) -> dict: + return {"sha256": hashlib.sha256(data).hexdigest(), "size": len(data)} + + +def observe(cat, keys, *, region, client=None, native=None, out=None) -> dict: + """Read every actual Sprite in the explicitly selected bundle closures. + + Catalog and decrypted bundle hashes identify the inputs actually read. + `client`/`native` are observed APK identity, never inferred from a catalog. + Public bitmap evidence is deliberately independent of these native records. + """ + keys = list(keys) + if not keys or any(not isinstance(key, str) or not key for key in keys): + raise ValueError("at least one exact nonempty catalog key is required") + keys = sorted(set(keys)) + unknown = [key for key in keys if not cat.has(key)] + if unknown: + raise ValueError("Sprite key not in catalog: " + ", ".join(unknown)) + sources = {name: fingerprint(data) for name, data in cat.sources().items()} + document = {"schema": SCHEMA, "region": region, "client": client or {}, + "catalogSha256": sources["remote"]["sha256"], + "source": {"kind": "observed-nnnotes-sprite-metadata", "catalogs": sources, + "native": native or {}, "runtimeVerified": False}, "sprites": {}} + source = getattr(cat, "source", None) + if source is not None: + # Do not serialize endpoint/session settings or authentication state. + document["source"]["resource"] = {"provider": source.provider, "version": source.version, + "hash": source.hash, "platform": source.platform} + for key in keys: + exporter = Exporter(cat, Path(out or "."), textures="deferred") + environment, _ = exporter.load(key) + bundles = [{"file": bundle.name, **fingerprint(cat.fetch(bundle).read_bytes())} + for bundle in sorted(cat.resolve(key), key=lambda value: value.name)] + found = False + for obj in environment.objects: + if obj.type.name != "Sprite": + continue + tree = obj.read_typetree() + render, _, _ = exporter.sprite_render_data(obj, tree) + name = tree["m_Name"] + sprite_key = f"{key}[{name}]" + if sprite_key in document["sprites"]: + raise ValueError("Ambiguous Sprite name in one closure: " + sprite_key) + geometry = {"rect": tree["m_Rect"], "pivot": tree["m_Pivot"], "border": tree["m_Border"], + "pixelsPerUnit": tree["m_PixelsToUnits"], "textureRect": render["textureRect"], + "textureRectOffset": render["textureRectOffset"], "settingsRaw": render["settingsRaw"], + "downscaleMultiplier": render.get("downscaleMultiplier", 1)} + document["sprites"][sprite_key] = {"geometry": geometry, "source": { + "key": key, "name": name, "assetFile": obj.assets_file.name, "pathId": str(obj.path_id), + "catalogSha256": sources["remote"]["sha256"], "bundles": bundles}} + found = True + if not found: + raise ValueError("No actual Sprite in requested key: " + key) + return document + + +def apk_identity(apk, *, native_fingerprints=False): + from .apkset import ApkSet + from .deckdata import apk_client + from .player import MANIFEST_IN_APK + client = {key: value for key, value in apk_client(apk).items() if value is not None} + native = {} + with ApkSet(apk) as archive: + if MANIFEST_IN_APK in archive.namelist(): + native["manifest"] = {"file": MANIFEST_IN_APK, **fingerprint(archive.read(MANIFEST_IN_APK))} + if native_fingerprints: + native["libraries"] = [{"file": name, **fingerprint(archive.read(name))} + for name in sorted(archive.namelist()) + if name.startswith("lib/") and name.rsplit("/", 1)[-1] in ("libil2cpp.so", "libanort.so")] + return client, native + + +def command(args, cfg): + from .cli import open_catalog + from .config import ConfigError + keys = list(args.key or []) + try: + if args.keys: + value = json.loads(Path(args.keys).read_text(encoding="utf-8")) + if not isinstance(value, list) or any(not isinstance(key, str) or not key for key in value): + raise ValueError("--keys must contain a JSON array of exact catalog key strings") + keys.extend(value) + client, native = ({}, {}) + apk = cfg.path("paths", "apk") + if apk is not None: + client, native = apk_identity(apk, native_fingerprints=args.native_fingerprints) + elif args.native_fingerprints: + raise ValueError("--native-fingerprints requires a configured APK set") + document = observe(open_catalog(cfg), keys, region=cfg.region(), client=client, native=native, out=args.out) + if args.public_bitmaps: + path = Path(args.public_bitmaps) + # A separately observed public listing is not native Sprite data. + document["source"]["publicBitmaps"] = {"file": path.name, **fingerprint(path.read_bytes())} + encoded = dumps(document, indent=1, ensure_ascii=False).encode("utf-8") + Path(args.out).parent.mkdir(parents=True, exist_ok=True) + write_atomic(Path(args.out), encoded) + except (ValueError, OSError, KeyError) as exc: + raise ConfigError(str(exc)) from None + print(json.dumps({"file": str(Path(args.out).resolve()), "keys": len(set(keys)), + "sprites": len(document["sprites"]), **fingerprint(encoded)})) diff --git a/tests/test_spritegeometry.py b/tests/test_spritegeometry.py new file mode 100644 index 0000000..32e1b93 --- /dev/null +++ b/tests/test_spritegeometry.py @@ -0,0 +1,102 @@ +"""Synthetic metadata only. The producer never decodes or exports artwork.""" +import hashlib +import json +from pathlib import Path +from types import SimpleNamespace as NS + +import jsonschema +import pytest + +from nnnotes import cli, spritegeometry + + +KEY = "Example/Trimmed" +TREE = {"m_Name": "portrait", "m_Rect": {"x": 9, "y": 7, "width": 120.5, "height": 200.25}, + "m_Pivot": {"x": .5, "y": .5}, "m_Border": {"x": 0, "y": 0, "z": 0, "w": 0}, "m_PixelsToUnits": 100} +RENDER = {"textureRect": {"x": 30.25, "y": 40.5, "width": 60.5, "height": 140.25}, + "textureRectOffset": {"x": 20.75, "y": 10.5}, "settingsRaw": 64} + + +@pytest.fixture +def observed(tmp_path, monkeypatch): + bundle_path = tmp_path / "example.bundle" + bundle_path.write_bytes(b"synthetic bundle") + sprite = NS(type=NS(name="Sprite"), assets_file=NS(name="synthetic-CAB"), + path_id=-(1 << 62) + 13, read_typetree=lambda: TREE) + # Accessing texture pixel data is a regression. It must remain unread. + def no_texture_read(): + raise AssertionError("texture decode is forbidden in metadata producer") + texture = NS(type=NS(name="Texture2D"), read=no_texture_read, read_typetree=no_texture_read) + environment = NS(objects=[sprite, texture]) + class Exporter: + def __init__(self, cat, out, **options): + assert options == {"textures": "deferred"} + def load(self, key): + assert key == KEY + return environment, None + def sprite_render_data(self, obj, tree): + assert obj is sprite and tree is TREE + return RENDER, obj, None + class Catalog: + source = None + def has(self, key): + return key == KEY + def sources(self): + return {"remote": b"synthetic catalog"} + def resolve(self, key): + return [NS(name=bundle_path.name)] + def fetch(self, bundle): + return bundle_path + monkeypatch.setattr(spritegeometry, "Exporter", Exporter) + return Catalog(), environment + + +def test_exact_rect_trim_source_and_int64_identity(observed): + cat, _ = observed + document = spritegeometry.observe(cat, [KEY, KEY], region="example") + entry = document["sprites"][KEY + "[portrait]"] + assert entry["geometry"]["rect"] == TREE["m_Rect"] + assert entry["geometry"]["textureRectOffset"] == RENDER["textureRectOffset"] + assert entry["geometry"]["downscaleMultiplier"] == 1 + assert entry["source"]["pathId"] == str(-(1 << 62) + 13) + assert entry["source"]["bundles"][0]["sha256"] == hashlib.sha256(b"synthetic bundle").hexdigest() + assert document["client"] == {} and document["source"]["runtimeVerified"] is False + schema = json.loads((Path(__file__).parents[1] / "docs/schema/sprite-geometries.schema.json").read_text()) + jsonschema.validate(document, schema) + + +def test_unknown_or_empty_keys_never_produce_guessed_records(observed): + cat, _ = observed + for keys in [[], ["Example/Unknown"]]: + with pytest.raises(ValueError): + spritegeometry.observe(cat, keys, region="example") + + +def test_duplicate_sprite_names_are_an_explicit_error(observed): + cat, env = observed + env.objects.append(env.objects[0]) + with pytest.raises(ValueError, match="Ambiguous Sprite"): + spritegeometry.observe(cat, [KEY], region="example") + + +def test_cli_keys_file_and_separate_public_bitmap_fingerprint(observed, tmp_path, monkeypatch, capsys): + cat, _ = observed + monkeypatch.setattr(cli, "open_catalog", lambda cfg: cat) + keys = tmp_path / "keys.json" + keys.write_text(json.dumps([KEY])) + public = tmp_path / "public.json" + public.write_bytes(b'{"observed":"synthetic bitmap listing"}') + out = tmp_path / "private" / "sprite-geometries.json" + cli.main(["--region", "example", "sprite-geometries", "--keys", str(keys), "--public-bitmaps", str(public), "-o", str(out)]) + document = json.loads(out.read_text()) + assert document["region"] == "example" + assert document["source"]["publicBitmaps"]["sha256"] == hashlib.sha256(public.read_bytes()).hexdigest() + assert "publicBitmaps" not in document["sprites"][KEY + "[portrait]"]["source"] + assert json.loads(capsys.readouterr().out)["sprites"] == 1 + + +def test_native_fingerprint_request_without_apk_is_rejected(tmp_path, capsys): + with pytest.raises(SystemExit) as result: + cli.main(["sprite-geometries", "--key", KEY, "--native-fingerprints", "-o", str(tmp_path / "x.json")]) + assert result.value.code == 2 + assert "requires a configured APK" in capsys.readouterr().err