diff --git a/src/nnnotes/export.py b/src/nnnotes/export.py index 10b3b48..90e5baf 100644 --- a/src/nnnotes/export.py +++ b/src/nnnotes/export.py @@ -389,6 +389,7 @@ def __init__(self, cat: Catalog, out_dir: Path, player=None, inline_meshes: bool self._path_sources: list[tuple[str, dict]] = [] self._crcs: dict[str, int] = {} self.unresolved: dict[str, int] = {} # bundle file name -> references exported as null + self._by_pid: dict[int, list] = {} # path id -> loaded objects with it (named-file fallback) self._boot_env = None # the boot scene of the player data (builtin_root) self._boot_graph: SceneGraph | None = None self._boot_files: set[int] = set() @@ -446,6 +447,7 @@ def load(self, key: str): for o in env.objects: self._graph_of_file.setdefault(id(o.assets_file), graph) self._objects[_key(o)] = o + self._by_pid.setdefault(o.path_id, []).append(o) if o.type.name == "Shader": self._shader(o) elif o.type.name == "GameObject": @@ -493,7 +495,12 @@ def deref(self, owner, pptr: dict): name = external_path(owner, pptr) if not name.lower().rsplit("/", 1)[-1].startswith("cab-"): raise - # a bundle file no bundle of the catalog provides: the reference is exported as null + # a bundle file no bundle of the catalog provides: its objects may sit in a loaded bundle under another + # file name (a bundle rebuilt after the one that refers to it); a path id held by one loaded object is it + found = self._by_pid.get(pptr["m_PathID"], ()) + if len(found) == 1: + return found[0] + # no such object: the reference is exported as null if name not in self.unresolved: warnings.warn(f"{name}: no bundle of the catalog holds this file; references to it are null", stacklevel=2) self.unresolved[name] = self.unresolved.get(name, 0) + 1 diff --git a/src/nnnotes/musicdata.py b/src/nnnotes/musicdata.py index e988cb0..e866253 100644 --- a/src/nnnotes/musicdata.py +++ b/src/nnnotes/musicdata.py @@ -1084,7 +1084,7 @@ def record(sid: int) -> dict: "catalog": {"resourceVersion": catalog.get("resourceVersion"), "sha256": catalog.get("sha256"), **({"resourceHash": catalog["resourceHash"]} if catalog.get("resourceHash") else {})}, "master": {"source": master_source, "version": master_version, - "tables": {t: {"sha256": table_sha[t]} for t in read}}, + "tables": {t: {"sha256": table_sha[t]} for t in read if t in table_sha}}, "exporter": exporter, "deck": deck.info() if deck is not None else None, }, diff --git a/tests/test_export_deref.py b/tests/test_export_deref.py new file mode 100644 index 0000000..15d9a08 --- /dev/null +++ b/tests/test_export_deref.py @@ -0,0 +1,41 @@ +"""Exporter.deref: references into a bundle file that no catalog bundle provides.""" +import pytest + +from nnnotes import export + + +class Obj: + def __init__(self, path_id): + self.path_id = path_id + + +def make(tmp_path, monkeypatch, objs): + ex = export.Exporter(None, tmp_path) + for o in objs: + ex._by_pid.setdefault(o.path_id, []).append(o) + + def missing(owner, pptr): + raise FileNotFoundError("CAB-1") + monkeypatch.setattr(export, "deref", missing) + monkeypatch.setattr(export, "external_path", lambda owner, pptr: "archive:/CAB-1/CAB-1") + return ex + + +def test_dangling_reference_resolves_to_the_only_loaded_object(tmp_path, monkeypatch): + target = Obj(77) + ex = make(tmp_path, monkeypatch, [target, Obj(78)]) + assert ex.deref(None, {"m_FileID": 3, "m_PathID": 77}) is target + assert ex.unresolved == {} + + +def test_dangling_reference_without_a_holder_is_null(tmp_path, monkeypatch): + ex = make(tmp_path, monkeypatch, [Obj(78)]) + with pytest.warns(UserWarning, match="no bundle of the catalog"): + assert ex.deref(None, {"m_FileID": 3, "m_PathID": 77}) is None + assert ex.unresolved == {"archive:/CAB-1/CAB-1": 1} + + +def test_dangling_reference_held_twice_is_null(tmp_path, monkeypatch): + ex = make(tmp_path, monkeypatch, [Obj(77), Obj(77)]) + with pytest.warns(UserWarning): + assert ex.deref(None, {"m_FileID": 3, "m_PathID": 77}) is None diff --git a/tests/test_musicdata.py b/tests/test_musicdata.py index 5f80d17..3dba6b7 100644 --- a/tests/test_musicdata.py +++ b/tests/test_musicdata.py @@ -1050,3 +1050,16 @@ def test_aptitude_disabled(tmp_path): assert doc['deck']['gekisouAptitude'] is None assert all(c['deck']['gekisouAptitude'] is None for s in doc['songs'] for c in s['charts']) schema_validator().validate(doc) + + +def test_master_data_without_the_arena_music_table(tmp_path): + d = master_dir(tmp_path) + (d / "MasterArenaMusic.bin").unlink() + manifest = json.loads((d / "MasterManifest.json").read_text(encoding="utf-8")) + manifest["files"] = [f for f in manifest["files"] if f["name"] != "MasterArenaMusic.bin"] + (d / "MasterManifest.json").write_text(json.dumps(manifest), encoding="utf-8") + musicdata.export(tmp_path / "music.json", deckdata.master_files(d), KEY, CHARTS.__getitem__, bgm, **PROV, + deck=musicdata.Deck(module=FakeDeck(), workers=3)) + doc = json.loads((tmp_path / "music.json").read_bytes()) + assert "MasterArenaMusic" not in doc["provenance"]["master"]["tables"] + assert "MasterBand" in doc["provenance"]["master"]["tables"]