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
5 changes: 4 additions & 1 deletion docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -616,7 +616,10 @@ root), and `stories.json` counts their files in `size.models`. The format is des
glyph (U+25A1 where the game's font has it), not from the font file (`coverage.missingGlyph`). The simple talk
window of an Overlay story is handled the same way. Each generated asset takes the
point size, padding, style settings and render mode of the game font asset the texts use in that language, so the
game's text materials apply unchanged; its face info and glyph metrics come from the font file (FreeType, no
game's text materials apply unchanged; those values, the game's fallback chains, the characters the game's assets
have and the text materials come from the table of them shipped with nnnotes (`src/nnnotes/fontsettings.json`,
`fontsettings.FORMAT`), so an open-font export reads no game font asset, material or font bundle;
its face info and glyph metrics come from the font file (FreeType, no
hinting), its distance field from the generator `--fonts game` uses for runtime glyphs (supersampled render modes
at up to 8x). The asset records the font's family, version, license and SHA-256. `--fonts game` uses the game's
font assets instead (the `fonts` extra); the format is the same. No font file is stored in the site other than as
Expand Down
52 changes: 52 additions & 0 deletions scripts/gen_font_settings.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
"""Regenerate the font settings table the open-font exports read (maintainer tool; not installed, not needed to use
nnnotes).

With `--fonts open` (the default) the story, start canvas and talk window exports give each localized game font
asset an open font asset with that asset's point size, padding, render mode, style settings, line metrics, fallback
chain and text materials. nnnotes ships these values in `src/nnnotes/fontsettings.json` (fontsettings.FORMAT), so
those exports read no game font asset; this script reads them from the game's font assets and writes the file.

Supporting a new game version
-----------------------------
When the game changes its font assets or their materials (an open-font export stops with "... not in the font
settings", or the values differ from the game's), regenerate the table with a configured catalog and APK of that
version (`nnnotes config check`):

python scripts/gen_font_settings.py [--region REGION] [--game-version VERSION] [-o PATH]

It reads LocalizeManager's font names of every language and their material types from the player data, each of
those font assets with the assets of its fallback chain, their default materials and every localized text material
("<font> - <type>") the catalog has, and writes the table (`--game-version` default: the APK's versionName). The
`fonts` extra must be installed.
"""
from __future__ import annotations

import argparse
import sys
import tempfile
from pathlib import Path

from nnnotes import fontsettings
from nnnotes.config import Config, use
from nnnotes.export import Exporter
from nnnotes.web import open_data


def main(argv=None) -> None:
p = argparse.ArgumentParser(description="regenerate src/nnnotes/fontsettings.json from the game's font assets")
p.add_argument("--region", help="region of the catalog (default: [catalog] region)")
p.add_argument("--game-version", help="game version to record (default: the APK manifest's versionName)")
p.add_argument("-o", "--out", type=Path, default=fontsettings.FILE, help="output file")
args = p.parse_args(argv)
cfg = use(Config.load())
cat, _, player = open_data(cfg, args.region)
with tempfile.TemporaryDirectory(prefix="fontsettings-") as work:
ex = Exporter(cat, Path(work), player=player, textures="deferred")
doc = fontsettings.build(ex, player, args.game_version or player.game_version)
fontsettings.write(doc, args.out)
print(f"{args.out}: {len(doc['fonts'])} font assets, {len(doc['materials'])} materials, "
f"{len(doc['objects'])} objects", file=sys.stderr)


if __name__ == "__main__":
main()
15 changes: 13 additions & 2 deletions src/nnnotes/advmedia.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@
from .export import HEADER, Exporter
from .jsonio import write_json
from .player import PlayerData
from . import adv, unity
from . import adv, fontsettings, unity
from . import master as master_mod

TMP_SHADER_PREFIX = "TextMeshPro/"
Expand Down Expand Up @@ -70,7 +70,8 @@ def __init__(self, cat: Catalog, out_dir: Path, player: PlayerData, fonts: str =
if fonts not in FONT_CHOICES:
raise ValueError(f"fonts={fonts!r}")
super().__init__(cat, out_dir, player=player, stub_assets=TMP_ASSETS if fonts == "open" else (),
follow=("AnimatorController",))
follow=("AnimatorController",),
font_stubs=fontsettings.builtin() if fonts == "open" else None)
self.fonts = fonts

def scriptable(self, o) -> dict:
Expand All @@ -89,6 +90,16 @@ def material(self, o) -> dict:
return super().material(o)
return {"material": tt["m_Name"], "shader": {"shader": name}, "note": FONT_NOTE}

def font_stub(self, o) -> dict:
"""As material and scriptable: a TMP material or asset of a font bundle is only named, with FONT_NOTE."""
out = super().font_stub(o)
if o.rec["type"] == "Material":
name = out["shader"]["shader"]
if not name.startswith(TMP_SHADER_PREFIX):
return out
return {"material": out["material"], "shader": {"shader": name}, "note": FONT_NOTE}
return {**out, "note": FONT_NOTE} if out["asset"] in TMP_ASSETS else out

def component(self, o) -> dict:
if o.type.name == "MonoBehaviour":
tt = o.read_typetree()
Expand Down
22 changes: 12 additions & 10 deletions src/nnnotes/advui.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@
from .export import Exporter, TexelPacker, _safe
from .jsonio import write_json
from .player import PlayerData
from . import adv, languages, textstyle
from . import adv, fontsettings, languages, textstyle
from .textstyle import LANGUAGE_LINE_SPACING

WIDGET_KEY = "EmbUI/Prefab/UIAdvWidget"
Expand Down Expand Up @@ -393,7 +393,8 @@ def extract(cat: Catalog, player: PlayerData, episode: dict, out_dir: Path, font
ui_dir = Path(out_dir) / "ui"
ui_dir.mkdir(parents=True, exist_ok=True)
ex = Exporter(cat, ui_dir, player=player, textures="deferred",
stub_assets=("TMP_FontAsset", "TMP_SpriteAsset", "TMP_StyleSheet"))
stub_assets=("TMP_FontAsset", "TMP_SpriteAsset", "TMP_StyleSheet"),
font_stubs=None if game else fontsettings.builtin())
packer = TexelPacker(ui_dir)

# -- prefabs -------------------------------------------------------------
Expand All @@ -419,7 +420,7 @@ def kept(p: str) -> bool:
for d in drawn:
if d not in {n["path"] for n in nodes}:
raise RuntimeError(f"{d} missing from the prefabs")
styles = textstyle.TextStyles(ex, player, mode)
styles = textstyle.TextStyles(ex, player, mode, None if game else fontsettings.builtin())
dialog_nodes = {name: ex.prefab(key)["nodes"] for name, key in DIALOG_KEYS.items()}
catalogs = pictogram_catalogs(widget + window + [n for dn in dialog_nodes.values() for n in dn])

Expand Down Expand Up @@ -453,7 +454,7 @@ def records(prefab_nodes: list, keep_text: bool) -> list:
if missing:
raise RuntimeError(f"{missing} missing from {CHAT_WIDGET_KEY}")
chat_widget = {"nodes": [_node_record(chat_nodes[p]) for p in CHAT_WIDGET_NODES]}
chat, status_ids = chat_texts(ex, styles.lang, mode, episode)
chat, status_ids = chat_texts(ex, styles.fonts, styles.lang, mode, episode)
status_texts = master_texts(master, status_ids, field) if master is not None and status_ids else None

materials: dict = {}
Expand Down Expand Up @@ -993,10 +994,11 @@ def chat_text_nodes(ex: Exporter, episode: dict):
return out, sorted(status)


def chat_texts(ex: Exporter, lang: dict, mode: int, episode: dict) -> tuple[dict, list[str]]:
def chat_texts(ex: Exporter, fonts, lang: dict, mode: int, episode: dict) -> tuple[dict, list[str]]:
"""chatTexts {window: {node path: {textStyle, localizeText}}} of the episode's chat windows in the language `mode`
(the text record of textstyle.text_style with the font and material chat_localize gives; font roles are the
slots of the lookup table) and the status text ids of the windows."""
(the text record of textstyle.text_style with the font and material chat_localize gives, their settings from
`fonts` (fontsettings.FontSettings); font roles are the slots of the lookup table) and the status text ids of the
windows."""
table_lang, styles, out = _lookup_lang(lang), {}, {}
nodes, status = chat_text_nodes(ex, episode)
for name, n, comp, loc in nodes:
Expand All @@ -1005,9 +1007,9 @@ def chat_texts(ex: Exporter, lang: dict, mode: int, episode: dict) -> tuple[dict
loc_font = chat_localize(fa, mat, lang, mode)
fa, mat = loc_font["fontAsset"], loc_font["material"]
if (fa, mat) not in styles:
face = ex.key_object(font_key(ex.cat, fa, fa)).read_typetree()["m_FaceInfo"]
styles[(fa, mat)] = textstyle.material_style(ex.material(ex.key_object(font_key(ex.cat, fa, mat))),
face["m_PointSize"], face["m_Scale"])
face = fonts.font(fa)["faceInfo"]
styles[(fa, mat)] = textstyle.material_style(fonts.material(fa, mat), face["m_PointSize"],
face["m_Scale"])
out.setdefault(name, {})[n["path"]] = {
"textStyle": textstyle.text_style(comp, loc, table_lang, styles[(fa, mat)], mode),
"localizeText": {"enabled": loc["m_Enabled"], "localizeEnabled": loc["_localizeEnabled"]} if loc else None}
Expand Down
20 changes: 17 additions & 3 deletions src/nnnotes/catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -326,9 +326,23 @@ def _download(self, url):
return self.session.get(url, source=self.source)
return download(url)

def fetch_key(self, key: str) -> list[Path]:
"""Every bundle of the key's closure; APK-local ones only when an APK is set."""
return [self.fetch(b) for b in self.resolve(key) if b.remote or self.apk]
def fetch_key(self, key: str, skip=frozenset()) -> list[Path]:
"""Every bundle of the key's closure but those named in `skip`; APK-local ones only when an APK is set."""
return [self.fetch(b) for b in self.resolve(key) if (b.remote or self.apk) and b.name not in skip]

def bundle_of(self, key: str) -> Bundle:
"""The bundle that holds the asset `key`: the first bundle among its location's dependencies (a bundle key:
that bundle)."""
e = self._entry(key)
own = self._as_bundle(e)
if own is not None:
return own
for d in e["dependencies"]:
de = self._by_off.get(d)
b = self._as_bundle(de) if de is not None else None
if b is not None:
return b
raise KeyError(f"{key}: no bundle among its dependencies")

def fetch_raw(self, e: dict) -> Path:
"""A non-bundle CDN asset (e.g. CRI ACB/AWB data) as stored -- no decryption."""
Expand Down
6 changes: 3 additions & 3 deletions src/nnnotes/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -744,8 +744,8 @@ def _band_args(c) -> None:

def _fonts_arg(c) -> None:
c.add_argument("--fonts", default="open", choices=("open", "game"),
help="start canvas text: open: layout and style only, no font data (default); game: also the "
"game's TMP fonts (needs the 'fonts' extra)")
help="start canvas text: open (default): layout and style only, no font data (your fonts are not "
"used); game: also the game's TMP fonts (needs the 'fonts' extra)")


def _audio_args(c) -> None:
Expand Down Expand Up @@ -870,7 +870,7 @@ def target(m, what):
_audio_args(c)
c.add_argument("--no-audio", action="store_true", help="do not decode the cue sheets")
c.add_argument("--fonts", default="open", choices=("open", "game"),
help="open: text layout and style only, no font data (default); game: also the game's TMP fonts "
help="open (default): text layout and style only, no font data; game: also the game's TMP fonts "
"(needs the 'fonts' extra)")
c.set_defaults(func=cmd_story, usage=c.error)

Expand Down
49 changes: 45 additions & 4 deletions src/nnnotes/export.py
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,14 @@ def build(self) -> dict:
CLIP_FORMATS = ("generic", "livescene", "livenotes")


class FontStub:
"""A reference into a font bundle left out of a closure (Exporter `font_stubs`): the record of its object in
the font settings' `objects` ({type, class, name})."""

def __init__(self, rec: dict, path_id: int):
self.rec, self.path_id = rec, path_id


class Exporter:
"""`player` resolves references into `unity default resources` (PlayerData);
`inline_meshes=False` emits meshes as name + vertex count; MonoBehaviour
Expand All @@ -331,11 +339,13 @@ class Exporter:
`defer_writes` (textures "write"): texture records are made as usual but their PNG files are written only by
`write_textures`, which can leave out the files a caller would delete.
`class_of(o)`: the class name of a MonoBehaviour (default: unity.script_class, through its m_Script with the
script bundle loaded)."""
script bundle loaded).
`font_stubs` (fontsettings.FontSettings): the bundles of its font assets are left out of every closure; a
reference into them resolves through its `objects` (a font asset to {asset, name}, a material to its record)."""

def __init__(self, cat: Catalog, out_dir: Path, player=None, inline_meshes: bool = True,
stub_assets: tuple[str, ...] = (), textures: str = "write", clip_format: str = "generic",
follow: tuple[str, ...] = (), defer_writes: bool = False, class_of=None):
follow: tuple[str, ...] = (), defer_writes: bool = False, class_of=None, font_stubs=None):
if textures not in ("write", "deferred"):
raise ValueError(f"textures={textures!r}")
if clip_format not in CLIP_FORMATS:
Expand All @@ -350,6 +360,8 @@ def __init__(self, cat: Catalog, out_dir: Path, player=None, inline_meshes: bool
self.follow = tuple(follow)
self.defer_writes = defer_writes
self.script_class = class_of or script_class
self.font_stubs = font_stubs
self._font_bundles: frozenset | None = None
self._unwritten: dict[str, object] = {} # defer_writes: file -> Texture2D
self.textures: dict[tuple, dict] = {}
self.shaders: dict[str, object] = {}
Expand Down Expand Up @@ -379,7 +391,10 @@ def load(self, key: str):
"""(env, graph) of a key's bundle closure, loaded once."""
if key in self._loaded:
return self._loaded[key]
env = load_closure(self.cat.fetch_key(key))
if self.font_stubs is None:
env = load_closure(self.cat.fetch_key(key))
else:
env = load_closure(self.cat.fetch_key(key, self.font_bundles()))
graph = SceneGraph(env)
go_file = self._go_file
for o in env.objects:
Expand All @@ -398,14 +413,38 @@ def load(self, key: str):
self._loaded[key] = (env, graph)
return env, graph

def font_bundles(self) -> frozenset:
"""With `font_stubs`: the names of the bundles that hold its font assets."""
if self._font_bundles is None:
from .advui import font_key
names = set()
for n, f in sorted(self.font_stubs.fonts.items()):
k = font_key(self.cat, n, n) # an addressable asset: its bundle now; else the one recorded
names.add(self.cat.bundle_of(k).name if self.cat.has(k) else f["bundle"])
self._font_bundles = frozenset(names)
return self._font_bundles

def font_stub(self, o: FontStub) -> dict:
"""The value of a reference into a font bundle left out (`font_stubs`): a material's record, a font asset's
{asset, name}."""
if o.rec["type"] == "Material":
return self.font_stubs.material(None, o.rec["name"])
return {"asset": o.rec["class"], "name": o.rec["name"]}

def deref(self, owner, pptr: dict):
if not pptr["m_PathID"]:
return None
if external_path(owner, pptr) == DEFAULT_RESOURCES:
if self.player is None:
raise RuntimeError("reference into unity default resources needs player data")
return self.player.deref(owner, pptr)
return deref(owner, pptr)
try:
return deref(owner, pptr)
except FileNotFoundError:
rec = self.font_stubs.objects.get(pptr["m_PathID"]) if self.font_stubs is not None else None
if rec is None:
raise
return FontStub(rec, pptr["m_PathID"])

def graph(self, o) -> SceneGraph:
return self._graph_of_file[id(o.assets_file)]
Expand All @@ -428,6 +467,8 @@ def ref(self, owner, pptr: dict):
o = self.deref(owner, pptr)
if o is None:
return None
if isinstance(o, FontStub):
return self.font_stub(o)
t = o.type.name
if t in ("AnimatorController", "AnimatorOverrideController") and "AnimatorController" in self.follow:
return self.controller(o)
Expand Down
Loading
Loading