Skip to content
Draft
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
5 changes: 5 additions & 0 deletions docs/contracts.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
59 changes: 59 additions & 0 deletions docs/schema/sprite-geometries.schema.json
Original file line number Diff line number Diff line change
@@ -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}}}]}
}
}
37 changes: 37 additions & 0 deletions docs/sprite-geometries.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 4 additions & 0 deletions docs/ui.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
13 changes: 13 additions & 0 deletions src/nnnotes/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
nnnotes run-stage <task.json> [...]
nnnotes catalogs list | import <catalog.bin> | fetch | diff <old> <new>
nnnotes store verify
nnnotes sprite-geometries --keys selected-keys.json -o out/sprite-geometries.json
"""
from __future__ import annotations

Expand Down Expand Up @@ -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__}")
Expand Down Expand Up @@ -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


Expand Down
118 changes: 118 additions & 0 deletions src/nnnotes/spritegeometry.py
Original file line number Diff line number Diff line change
@@ -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)}))
102 changes: 102 additions & 0 deletions tests/test_spritegeometry.py
Original file line number Diff line number Diff line change
@@ -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
Loading