From 1badde75d38d1a1b257a2a95c3172b97772585ea Mon Sep 17 00:00:00 2001 From: nichinichisou Date: Fri, 16 Oct 2026 02:50:03 +0800 Subject: [PATCH 1/3] refactor(unity): share TMP font lookup, font records and texel packing The TextMesh Pro font code now works on any font asset that has the fields it reads, not only on assets read through an Exporter: - tmpfont.FontAsset is the protocol of a font asset; FontLookup holds the character lookup over a fallback chain, and FontSet (Exporter) builds on it. - font_cmap reads a source font's character map and variation sequences; font_record, listed_fonts, load_listed and font_document build the font settings table from any FontSettings that records the path ids it reads (BuiltFontSettings). - text_material_records takes the material lookup as a function; text_materials keeps the Exporter form. export_fonts takes the font lookup alone and reads each font's default material from the asset. - export.py splits TexelPacker.build into texel_rows, pack_group, texture_block and array_block, and adds MemoryPacker: the same packing over textures held in memory, returning the page records and their PNG bytes. The test font builder moves to tests/helpers/fonts.py. Outputs of the existing font settings and font export paths are unchanged. Co-Authored-By: Claude Opus 5.5 --- src/nnnotes/content/advui.py | 1 - src/nnnotes/content/liveui.py | 1 - src/nnnotes/unity/export.py | 235 ++++++++++++++--------- src/nnnotes/unity/tmpfont.py | 304 ++++++++++++++++++++---------- tests/content/test_story_fonts.py | 77 +------- tests/helpers/fonts.py | 79 ++++++++ 6 files changed, 435 insertions(+), 262 deletions(-) create mode 100644 tests/helpers/fonts.py diff --git a/src/nnnotes/content/advui.py b/src/nnnotes/content/advui.py index cca94e5..7bbde5f 100644 --- a/src/nnnotes/content/advui.py +++ b/src/nnnotes/content/advui.py @@ -729,7 +729,6 @@ def records(prefab_nodes: list, keep_text: bool) -> list: # -- glyph blocks ------------------------------------------------------------- if game: font_out = tmpfont.export_fonts( - ex, font_doc["fonts"], font_doc["primaries"], font_doc["needed"], diff --git a/src/nnnotes/content/liveui.py b/src/nnnotes/content/liveui.py index 17e2739..9b35261 100644 --- a/src/nnnotes/content/liveui.py +++ b/src/nnnotes/content/liveui.py @@ -164,7 +164,6 @@ def _game_fonts(ex: Exporter, fontset, texts: dict, chars_by_font: dict, packer: ex, fontset, [t["localized"] for t in texts.values()], primaries, needed, runtime_units ) font_out = tmpfont.export_fonts( - ex, fontset, primaries, needed, diff --git a/src/nnnotes/unity/export.py b/src/nnnotes/unity/export.py index 2f8b1cc..ef961ca 100644 --- a/src/nnnotes/unity/export.py +++ b/src/nnnotes/unity/export.py @@ -129,6 +129,110 @@ def grow_box(x0: float, y0: float, x1: float, y1: float, margin: int) -> tuple[i return (math.floor(x0) - margin, math.floor(y0) - margin, math.ceil(x1) + margin, math.ceil(y1) + margin) +def wrap_mode(mode: int) -> Literal["wrap", "clip"]: + """The np.take mode of a texture's wrap mode (m_WrapU / m_WrapV: 0 repeat, 1 clamp).""" + if mode == 0: + return "wrap" + if mode == 1: + return "clip" + raise NotImplementedError(f"texture wrap mode {mode}") + + +def texel_rows(img, texture_format: int, width: int, height: int, name: str) -> np.ndarray: + """The RGBA texels of a decoded Texture2D image `img` (a PIL image; the texture's format, size and name), bottom + row first. Alpha8 keeps its value in the alpha channel.""" + if texture_format == 1: # Alpha8: value in the alpha channel + if img.mode == "L": + a = np.asarray(img, np.uint8) + elif img.mode == "RGBA": + a = np.asarray(img, np.uint8)[:, :, 3] + else: + raise NotImplementedError(f"Alpha8 decoded as {img.mode}") + arr = np.zeros(a.shape + (4,), np.uint8) + arr[:, :, 3] = a # rgb 0 (GL alpha texture); TMP reads .a only + else: + arr = np.asarray(img.convert("RGBA"), np.uint8) + if arr.shape[:2] != (height, width): + raise RuntimeError(f"{name}: decoded size {arr.shape}") + return arr[::-1].copy() # bottom row first (Unity v = 0) + + +def pack_group(group: str, handles: list[dict], pixels, width_limit: int) -> tuple[dict, np.ndarray]: + """The layout and the texels of one packed texture `group` of the blocks `handles` (TexelPacker.build). Returns its + record without its file (name, width, height, mipCount, settings, sources, blocks) and its RGBA array, bottom row + first. `pixels(tex)` gives the texels of a block's source texture. Every handle gets its texture (`group`) and its + integer `offset`.""" + settings = {json.dumps(h["settings"], sort_keys=True) for h in handles} + if len(settings) != 1: + raise RuntimeError(f"{group}: blocks from textures with different sampler settings") + # dedupe identical blocks of the same texture + uniq: dict[tuple, dict] = {} + for h in handles: + uniq.setdefault((h["key"], h["box"]), h) + blocks = sorted(uniq.values(), key=lambda h: (-(h["box"][3] - h["box"][1]), h["box"])) + x = y = shelf = 0 + width = max(width_limit, max(b["box"][2] - b["box"][0] for b in blocks)) + for b in blocks: + w, hgt = b["box"][2] - b["box"][0], b["box"][3] - b["box"][1] + if x + w > width: + x, y, shelf = 0, y + shelf, 0 + b["dst"] = (x, y) + x += w + shelf = max(shelf, hgt) + height = y + shelf + if len(blocks) == 1: # a single block keeps its own size + width = blocks[0]["box"][2] - blocks[0]["box"][0] + out = np.zeros((height, width, 4), np.uint8) + for b in blocks: + x0, y0, x1, y1 = b["box"] + dx, dy = b["dst"] + if "array" in b: + out[dy : dy + (y1 - y0), dx : dx + (x1 - x0)] = b["array"] + continue + src = pixels(b["tex"]) + st = b["settings"] + rows = np.take(np.arange(src.shape[0]), np.arange(y0, y1), mode=wrap_mode(st["m_WrapV"])) + cols = np.take(np.arange(src.shape[1]), np.arange(x0, x1), mode=wrap_mode(st["m_WrapU"])) + out[dy : dy + (y1 - y0), dx : dx + (x1 - x0)] = src[np.ix_(rows, cols)] + for h in handles: + d = uniq[(h["key"], h["box"])]["dst"] + h["offset"] = (d[0] - h["box"][0], d[1] - h["box"][1]) + h["texture"] = group + s = next(iter(handles))["settings"] + rec = { + "name": group, + "width": width, + "height": height, + "mipCount": 1, + "settings": s, + "sources": sorted({h["srcName"] for h in handles}), + "blocks": len(blocks), + } + return rec, out + + +def texture_block( + key: tuple, settings: dict, name: str, size: tuple[int, int], box: tuple[int, int, int, int], tex +) -> dict: + """A block that copies the texel box `box` of the texture `tex` (its texels come from the packer's `pixels`). + `key` identifies the texture, `settings` is its m_TextureSettings, `name` and `size` (width, height) its name and + size.""" + return {"tex": tex, "key": key, "settings": settings, "box": box, "srcName": name, "srcSize": size} + + +def array_block(name: str, rgba: np.ndarray, origin: tuple[int, int], settings: dict) -> dict: + """A block of generated texels: `rgba` (RGBA, bottom row first) placed at `origin` of its virtual texture.""" + ox, oy = origin + return { + "array": rgba, + "key": ("generated", name), + "settings": settings, + "box": (ox, oy, ox + rgba.shape[1], oy + rgba.shape[0]), + "srcName": name, + "srcSize": (rgba.shape[1], rgba.shape[0]), + } + + class TexelPacker: """Copies texel blocks of source textures into packed textures. @@ -155,119 +259,76 @@ def pixels(self, tex_obj) -> np.ndarray: ck = PIXELS.key(_decode_inputs(tex), tt["m_TextureFormat"] == 1, tt["m_Width"], tt["m_Height"]) arr = PIXELS.get(ck) if arr is None: - img = texture_image(tex) - if tt["m_TextureFormat"] == 1: # Alpha8: value in the alpha channel - if img.mode == "L": - a = np.asarray(img, np.uint8) - elif img.mode == "RGBA": - a = np.asarray(img, np.uint8)[:, :, 3] - else: - raise NotImplementedError(f"Alpha8 decoded as {img.mode}") - arr = np.zeros(a.shape + (4,), np.uint8) - arr[:, :, 3] = a # rgb 0 (GL alpha texture); TMP reads .a only - else: - arr = np.asarray(img.convert("RGBA"), np.uint8) - if arr.shape[:2] != (tt["m_Height"], tt["m_Width"]): - raise RuntimeError(f"{tt['m_Name']}: decoded size {arr.shape}") - arr = arr[::-1].copy() # bottom row first (Unity v = 0) + arr = texel_rows(texture_image(tex), tt["m_TextureFormat"], tt["m_Width"], tt["m_Height"], tt["m_Name"]) arr.setflags(write=False) PIXELS.put(ck, arr, arr.nbytes) self._pixels[k] = arr return self._pixels[k] - @staticmethod - def _wrap(mode: int) -> Literal["wrap", "clip"]: - if mode == 0: - return "wrap" - if mode == 1: - return "clip" - raise NotImplementedError(f"texture wrap mode {mode}") - def add(self, group: str, tex_obj, x0: int, y0: int, x1: int, y1: int) -> dict: """Register a block; returns a handle filled in by build().""" tt = tex_obj.read_typetree() - h = { - "tex": tex_obj, - "key": _key(tex_obj), - "settings": tt["m_TextureSettings"], - "box": (x0, y0, x1, y1), - "srcName": tt["m_Name"], - "srcSize": (tt["m_Width"], tt["m_Height"]), - } + size = (tt["m_Width"], tt["m_Height"]) + h = texture_block(_key(tex_obj), tt["m_TextureSettings"], tt["m_Name"], size, (x0, y0, x1, y1), tex_obj) self.groups.setdefault(group, []).append(h) return h def add_array(self, group: str, name: str, rgba: np.ndarray, origin: tuple[int, int], settings: dict) -> dict: """Register generated texels (RGBA, bottom row first) covering [origin, origin + shape) of a virtual texture as one block; the whole array is copied (no wrap).""" - ox, oy = origin - h = { - "array": rgba, - "key": ("generated", name), - "settings": settings, - "box": (ox, oy, ox + rgba.shape[1], oy + rgba.shape[0]), - "srcName": name, - "srcSize": (rgba.shape[1], rgba.shape[0]), - } + h = array_block(name, rgba, origin, settings) self.groups.setdefault(group, []).append(h) return h def build(self) -> dict: textures = {} for group, handles in self.groups.items(): - settings = {json.dumps(h["settings"], sort_keys=True) for h in handles} - if len(settings) != 1: - raise RuntimeError(f"{group}: blocks from textures with different sampler settings") - # dedupe identical blocks of the same texture - uniq: dict[tuple, dict] = {} - for h in handles: - uniq.setdefault((h["key"], h["box"]), h) - blocks = sorted(uniq.values(), key=lambda h: (-(h["box"][3] - h["box"][1]), h["box"])) - x = y = shelf = 0 - width = max(self.WIDTH, max(b["box"][2] - b["box"][0] for b in blocks)) - for b in blocks: - w, hgt = b["box"][2] - b["box"][0], b["box"][3] - b["box"][1] - if x + w > width: - x, y, shelf = 0, y + shelf, 0 - b["dst"] = (x, y) - x += w - shelf = max(shelf, hgt) - height = y + shelf - if len(blocks) == 1: # a single block keeps its own size - width = blocks[0]["box"][2] - blocks[0]["box"][0] - out = np.zeros((height, width, 4), np.uint8) - for b in blocks: - x0, y0, x1, y1 = b["box"] - dx, dy = b["dst"] - if "array" in b: - out[dy : dy + (y1 - y0), dx : dx + (x1 - x0)] = b["array"] - continue - src = self.pixels(b["tex"]) - st = b["settings"] - rows = np.take(np.arange(src.shape[0]), np.arange(y0, y1), mode=self._wrap(st["m_WrapV"])) - cols = np.take(np.arange(src.shape[1]), np.arange(x0, x1), mode=self._wrap(st["m_WrapU"])) - out[dy : dy + (y1 - y0), dx : dx + (x1 - x0)] = src[np.ix_(rows, cols)] - for h in handles: - d = uniq[(h["key"], h["box"])]["dst"] - h["offset"] = (d[0] - h["box"][0], d[1] - h["box"][1]) - h["texture"] = group + rec, out = pack_group(group, handles, self.pixels, self.WIDTH) fn = f"textures/{_safe(group)}.png" (self.out / "textures").mkdir(parents=True, exist_ok=True) (self.out / fn).write_bytes(packed_png(out)) - s = next(iter(handles))["settings"] - textures[group] = { - "texture": fn, - "name": group, - "width": width, - "height": height, - "mipCount": 1, - "settings": s, - "sources": sorted({h["srcName"] for h in handles}), - "blocks": len(blocks), - } + textures[group] = {"texture": fn, **rec} return textures +class MemoryPacker: + """TexelPacker over texture objects held in memory instead of Unity objects: a block's texture has `key`, `name`, + `settings` (m_TextureSettings), `width`, `height` and `pixels` (its RGBA texels, bottom row first). `build()` gives + the packed textures' records and their PNG bytes by file name, the same records and bytes TexelPacker writes.""" + + WIDTH = TexelPacker.WIDTH + + def __init__(self): + self.groups: dict[str, list] = {} + + @staticmethod + def pixels(tex) -> np.ndarray: + return tex.pixels + + def add(self, group: str, tex, x0: int, y0: int, x1: int, y1: int) -> dict: + """Register a block; returns a handle filled in by build().""" + h = texture_block(tex.key, tex.settings, tex.name, (tex.width, tex.height), (x0, y0, x1, y1), tex) + self.groups.setdefault(group, []).append(h) + return h + + def add_array(self, group: str, name: str, rgba: np.ndarray, origin: tuple[int, int], settings: dict) -> dict: + """Register generated texels as TexelPacker.add_array does.""" + h = array_block(name, rgba, origin, settings) + self.groups.setdefault(group, []).append(h) + return h + + def build(self) -> tuple[dict, dict[str, bytes]]: + """The records of the packed textures by group, and the PNG bytes of their pages by file name.""" + textures: dict[str, dict] = {} + pages: dict[str, bytes] = {} + for group, handles in self.groups.items(): + rec, out = pack_group(group, handles, self.pixels, self.WIDTH) + fn = f"textures/{_safe(group)}.png" + pages[fn] = packed_png(out) + textures[group] = {"texture": fn, **rec} + return textures, pages + + CLIP_FORMATS = ("generic", "livescene", "livenotes") diff --git a/src/nnnotes/unity/tmpfont.py b/src/nnnotes/unity/tmpfont.py index 37ff93b..241cddc 100644 --- a/src/nnnotes/unity/tmpfont.py +++ b/src/nnnotes/unity/tmpfont.py @@ -19,13 +19,12 @@ import io import math from collections.abc import Callable -from typing import cast +from typing import Protocol, cast import numpy as np -from UnityPy.files import ObjectReader from ..source.config import ConfigError -from .export import Exporter, TexelPacker, _key, grow_box +from .export import Exporter, MemoryPacker, TexelPacker, _key, grow_box from .fontsettings import ( END_OF_TEXT, FONT_CLASS, @@ -60,9 +59,49 @@ def require_extra(what: str = "TextMesh Pro font assets") -> None: # -------------------------------------------------------------------------- # fonts # -------------------------------------------------------------------------- +def font_cmap(data: bytes, face_index: int) -> tuple[set[int], dict[tuple[int, int], str]]: + """The code points a font file maps (its best cmap) and its variation sequences that map a glyph other than the + base's (cmap format 14): {(base, selector): the variant's glyph name}.""" + from fontTools.ttLib import TTFont + + ft = TTFont(io.BytesIO(data), fontNumber=face_index, lazy=True) + best = ft.getBestCmap() or {} + variants: dict[tuple[int, int], str] = {} + for t in ft["cmap"].tables: # variation sequences (cmap format 14), non-default ones + if t.format == 14: + for vs, entries in t.uvsDict.items(): + for base, glyph in entries: + if glyph is not None and glyph != best.get(base): + variants[(base, vs)] = glyph + return set(best), variants + + +class FontAsset(Protocol): + """A TextMesh Pro font asset as the lookup, the glyph tables and the runtime glyphs read it: Font (read through an + Exporter) and tmpassets.Font (read from an object table).""" + + name: str + tt: dict + chars: dict + glyphs: dict + dynamic: bool + cmap: set[int] + variants: dict[tuple[int, int], str] + font_data: bytes | None + source_font: dict | None + atlases: list + + def atlas_names(self) -> list[str | None]: ... + + def atlas_settings(self) -> list[dict]: ... + + def default_material(self) -> dict: ... + + class Font: def __init__(self, ex: Exporter, obj): self.obj = obj + self._ex = ex tt = obj.read_typetree() self.tt = tt self.name = tt["m_Name"] @@ -79,18 +118,8 @@ def __init__(self, ex: Exporter, obj): self.font_data: bytes | None = None src = ex.deref(obj, tt["m_SourceFontFile"]) if tt["m_SourceFontFile"]["m_PathID"] else None if src is not None: - from fontTools.ttLib import TTFont - data = bytes(src.read_typetree()["m_FontData"]) - ft = TTFont(io.BytesIO(data), fontNumber=tt["m_FaceInfo"]["m_FaceIndex"], lazy=True) - best = ft.getBestCmap() or {} - self.cmap = set(best) - for t in ft["cmap"].tables: # variation sequences (cmap format 14), non-default ones - if t.format == 14: - for vs, entries in t.uvsDict.items(): - for base, glyph in entries: - if glyph is not None and glyph != best.get(base): - self.variants[(base, vs)] = glyph + self.cmap, self.variants = font_cmap(data, tt["m_FaceInfo"]["m_FaceIndex"]) self.font_data = data self.source_font = { "name": src.read_typetree()["m_Name"], @@ -100,35 +129,33 @@ def __init__(self, ex: Exporter, obj): else: self.source_font = None + def atlas_names(self) -> list[str | None]: + """The names of the atlas textures (None for a missing page).""" + return [a.read_typetree()["m_Name"] if a is not None else None for a in self.atlases] -class FontSet: - """Font assets reachable from the localized fonts, with TMP character lookup.""" + def atlas_settings(self) -> list[dict]: + """The sampler settings of the atlas textures that are there.""" + return [a.read_typetree()["m_TextureSettings"] for a in self.atlases if a is not None] - def __init__(self, ex: Exporter): - self.ex = ex - self.fonts: dict[str, Font] = {} - self._by_obj: dict[tuple, Font] = {} + def default_material(self) -> dict: + """The record of the default material (Exporter.material).""" + return self._ex.material(self.material_obj) - def by_key(self, key: str) -> Font: - return self.of(self.ex.key_object(key)) - def of(self, obj) -> Font: - k = _key(obj) - if k not in self._by_obj: - f = Font(self.ex, obj) - if f.name in self.fonts: - raise RuntimeError(f"two font assets named {f.name}") - self._by_obj[k] = f - self.fonts[f.name] = f - for fb in f.fallback_objs: - if fb is not None: - self.of(fb) - return self._by_obj[k] +class FontLookup: + """TMP character lookup over font assets: a font's own characters, then its fallback chain (`fallbacks(f)`, as + TextMeshPro searches it; see lookup). Results are (kind, font asset) with kind "baked", "runtime" (a dynamic asset + renders it at runtime from its source font) or "synthesized" (TextMeshPro's control characters). A subclass adds + the font assets it reads to `fonts` and the names of their fallbacks to `_fallback_names`.""" + + def __init__(self): + self.fonts: dict[str, FontAsset] = {} + self._fallback_names: dict[str, list[str]] = {} - def fallbacks(self, f: Font) -> list[Font]: - return [self._by_obj[_key(o)] for o in f.fallback_objs if o is not None] + def fallbacks(self, f: FontAsset) -> list[FontAsset]: + return [self.fonts[n] for n in self._fallback_names[f.name]] - def _in_font(self, f: Font, u: int): + def _in_font(self, f: FontAsset, u: int) -> tuple[str, FontAsset] | None: """GetCharacterFromFontAsset_Internal without fallbacks (regular weight, no italic).""" if u in f.chars: return ("baked", f) @@ -138,7 +165,7 @@ def _in_font(self, f: Font, u: int): return ("runtime", f) # TryAddCharacterInternal: SDF rendered at runtime return None - def _search(self, f: Font, u: int, searched: set): + def _search(self, f: FontAsset, u: int, searched: set) -> tuple[str, FontAsset] | None: r = self._in_font(f, u) if r: return r @@ -151,7 +178,7 @@ def _search(self, f: Font, u: int, searched: set): return r return None - def lookup(self, primary: Font, u: int): + def lookup(self, primary: FontAsset, u: int) -> tuple[str, FontAsset] | None: """TMP_Text.GetTextElement for a font asset with no global fallbacks set.""" r = self._in_font(primary, u) if r: @@ -170,6 +197,33 @@ def baked_anywhere(self, u: int) -> list[str]: return sorted(f.name for f in self.fonts.values() if u in f.chars) +class FontSet(FontLookup): + """Font assets reachable from the localized fonts, with TMP character lookup.""" + + def __init__(self, ex: Exporter): + super().__init__() + self.ex = ex + self._by_obj: dict[tuple, Font] = {} + + def by_key(self, key: str) -> Font: + return self.of(self.ex.key_object(key)) + + def loaded(self, name: str) -> Font: + """The font asset `name`, read by `of` or `by_key`.""" + return cast(Font, self.fonts[name]) + + def of(self, obj) -> Font: + k = _key(obj) + if k not in self._by_obj: + f = Font(self.ex, obj) + if f.name in self.fonts: + raise RuntimeError(f"two font assets named {f.name}") + self._by_obj[k] = f + self.fonts[f.name] = f + self._fallback_names[f.name] = [self.of(fb).name for fb in f.fallback_objs if fb is not None] + return self._by_obj[k] + + # -------------------------------------------------------------------------- # missing glyph # -------------------------------------------------------------------------- @@ -186,7 +240,7 @@ def missing_glyph_character(player) -> int: return tt["m_missingGlyphCharacter"] or MISSING_GLYPH_CHARACTER -def missing_glyph(fonts: FontSet, font: Font, chars, character: int = MISSING_GLYPH_CHARACTER) -> dict | None: +def missing_glyph(fonts: FontLookup, font: FontAsset, chars, character: int = MISSING_GLYPH_CHARACTER) -> dict | None: """The missing glyph of a text of `font` (TMP_Text.SetArraySizes, a character GetTextElement finds in no font asset and no sprite asset): {unicode: the substitute, characters: the code points of `chars` that `font` and its fallbacks lack}, or None when they have every one. The substitute is `character` (missing_glyph_character) when @@ -199,14 +253,14 @@ def missing_glyph(fonts: FontSet, font: Font, chars, character: int = MISSING_GL return {"unicode": sub, "characters": miss} -def underline_character(fonts: FontSet, font: Font) -> bool: +def underline_character(fonts: FontLookup, font: FontAsset) -> bool: """Whether `font` itself has U+005F (TMP_Text.GetUnderlineSpecialCharacter: GetCharacterFromFontAsset without fallbacks; a dynamic asset adds it from its source font): the glyph its texts' underline and highlight draw with.""" r = fonts._in_font(font, 0x5F) return r is not None and r[0] in ("baked", "runtime") -def check_glyph_variants(fonts: FontSet, font: Font, texts) -> None: +def check_glyph_variants(fonts: FontLookup, font: FontAsset, texts) -> None: """Raise NotImplementedError when a character of `texts` that `font` (or a fallback) has is followed by a variation selector its source font maps to a variant glyph (SetArraySizes: GetGlyphVariantIndex -> the alternative glyph); any other selector after a font character is dropped (rewritten to U+001A).""" @@ -270,7 +324,7 @@ class RuntimeGlyphs: "the SDF block is the rect grown by padding on each side" ) - def __init__(self, font: Font): + def __init__(self, font: FontAsset): import freetype from scipy.ndimage import distance_transform_edt @@ -406,7 +460,7 @@ def sdf(self, gi: int) -> tuple[dict, tuple, np.ndarray]: a = np.floor(255.0 * np.clip(0.5 + d / (2.0 * self.spread), 0.0, 1.0) + 0.5).astype(np.uint8) return m, (xmin, ymin, w, h), a - def validate(self, packer: TexelPacker, units) -> dict: + def validate(self, packer: TexelPacker | MemoryPacker, units) -> dict: """Generate the baked characters `units` of the same font asset and compare with its atlas.""" f, p = self.font, self.pad errs, per, metric_diff = [], {}, {k: 0.0 for k in GLYPH_METRIC_FIELDS} @@ -462,15 +516,29 @@ def rgba_alpha(a: np.ndarray) -> np.ndarray: return out -def material_by_name(ex: Exporter, font: Font, mat_name: str): +def material_by_name(ex: Exporter, font: FontAsset, mat_name: str): key = font_dir(font.name) + mat_name if not ex.cat.has(key): raise KeyError(f"material key {key}") return ex.material(ex.key_object(key)) +def shared_settings(name: str, settings: list[dict]) -> dict: + """The sampler settings that all the atlas pages of the font asset `name` have (RuntimeError when they differ or + there is no page).""" + if not settings or any(s != settings[0] for s in settings): + raise RuntimeError(f"{name}: atlas pages with different sampler settings") + return settings[0] + + def runtime_glyphs( - f: Font, extra: set, baked: set, margin: int, packer: TexelPacker, chars_out: dict, glyphs_out: dict + f: FontAsset, + extra: set, + baked: set, + margin: int, + packer: TexelPacker | MemoryPacker, + chars_out: dict, + glyphs_out: dict, ) -> dict: """Characters of a dynamic font asset that TMP renders into the atlas at runtime: generate their SDF glyphs + metrics from the source font, add them to the glyph/character tables and the packed @@ -480,9 +548,7 @@ def runtime_glyphs( if margin < p: raise RuntimeError(f"{f.name}: sampling margin {margin} < padding {p}") validation = gen.validate(packer, baked) - settings = [a.read_typetree()["m_TextureSettings"] for a in f.atlases if a is not None] - if not settings or any(s != settings[0] for s in settings): - raise RuntimeError(f"{f.name}: atlas pages with different sampler settings") + settings = shared_settings(f.name, f.atlas_settings()) x = 0 generated = [] for u in sorted(extra): @@ -504,7 +570,7 @@ def runtime_glyphs( f"runtime glyph {gi}", rgba_alpha(cell), (rect["m_X"] - margin, rect["m_Y"] - margin), - settings[0], + settings, ) chars_out[str(u)] = {"glyph": gi, "scale": 1.0, "elementType": 1} glyphs_out[str(gi)] = rec @@ -547,7 +613,7 @@ def runtime_glyphs( # -------------------------------------------------------------------------- # characters -> font assets, materials, glyph tables # -------------------------------------------------------------------------- -def glyph_coverage(fonts: FontSet, primary: Font, chars: list[int]) -> tuple[dict, dict, dict]: +def glyph_coverage(fonts: FontLookup, primary: FontAsset, chars: list[int]) -> tuple[dict, dict, dict]: """TMP character lookup of `chars` (distinct code points) from `primary` through its fallback chain. -> (coverage report, {font: baked units}, {font: runtime units}).""" coverage = { @@ -595,31 +661,44 @@ def glyph_coverage(fonts: FontSet, primary: Font, chars: list[int]) -> tuple[dic return coverage, needed, runtime_units -def text_materials( - ex: Exporter, fonts: FontSet, localized: list[dict], primaries: set, needed: dict, runtime_units: dict +def text_material_records( + fonts: FontLookup, + localized: list[dict], + primaries: set, + needed: dict, + runtime_units: dict, + material_named: Callable[[FontAsset, str], dict], ) -> tuple[dict, dict]: - """The localized text materials (in text order), then the default material of every font asset that - serves a character or is a primary. -> (all materials, text materials).""" + """The localized text materials (in text order, `material_named(font asset, name)`), then the default material of + every font asset that serves a character or is a primary. -> (all materials, text materials).""" materials: dict[str, dict] = {} for loc in localized: if loc["material"] not in materials: - materials[loc["material"]] = material_by_name(ex, fonts.fonts[loc["fontAsset"]], loc["material"]) + materials[loc["material"]] = material_named(fonts.fonts[loc["fontAsset"]], loc["material"]) text_mats = dict(materials) for f in fonts.fonts.values(): if f.name in needed or f.name in runtime_units or f.name in primaries: - m = ex.material(f.material_obj) + m = f.default_material() materials.setdefault(m["material"], m) return materials, text_mats +def text_materials( + ex: Exporter, fonts: FontSet, localized: list[dict], primaries: set, needed: dict, runtime_units: dict +) -> tuple[dict, dict]: + """text_material_records for the font assets of `fonts`, their materials read through `ex`.""" + return text_material_records( + fonts, localized, primaries, needed, runtime_units, lambda f, name: material_by_name(ex, f, name) + ) + + def export_fonts( - ex: Exporter, - fonts: FontSet, + fonts: FontLookup, primaries: set, needed: dict, runtime_units: dict, text_mats: dict, - packer: TexelPacker, + packer: TexelPacker | MemoryPacker, subset: str, ) -> dict: """Font asset records with the character / glyph tables reduced to the `needed` baked characters (plus the @@ -631,7 +710,7 @@ def export_fonts( extra = runtime_units.get(f.name, set()) if f.name not in primaries and not units and not extra: continue - mat = ex.material(f.material_obj) + mat = f.default_material() gs = mat["floats"]["_GradientScale"] aw, ah = f.tt["m_AtlasWidth"], f.tt["m_AtlasHeight"] # the runtime replaces _TextureWidth/_TextureHeight with the packed size @@ -679,7 +758,7 @@ def export_fonts( "atlasPadding": tt["m_AtlasPadding"], "atlasRenderMode": tt["m_AtlasRenderMode"], "atlasPopulationMode": tt["m_AtlasPopulationMode"], - "atlases": [a.read_typetree()["m_Name"] if a is not None else None for a in f.atlases], + "atlases": f.atlas_names(), "normalStyle": tt["normalStyle"], "normalSpacingOffset": tt["normalSpacingOffset"], "boldStyle": tt["boldStyle"], @@ -739,46 +818,62 @@ def resolve_font_blocks(font_out: dict) -> None: # ---------------------------------------------------------------- the font settings of the game's assets -class AssetFontSettings(FontSettings): +def font_record(f: FontAsset, material: str, fallbacks: list[str], bundle: str) -> dict: + """The table's record of the font asset `f` (FontSettings.font): face info, atlas padding and render mode, style + settings, default material name, fallback names, coverage, variants and the bundle that holds it.""" + tt = f.tt + return { + "faceInfo": tt["m_FaceInfo"], + "atlasPadding": tt["m_AtlasPadding"], + "atlasRenderMode": tt["m_AtlasRenderMode"], + **{k: tt[k] for k in STYLE_KEYS}, + "material": material, + "fallbacks": fallbacks, + "coverage": encode_coverage(set(f.chars) | (f.cmap if f.dynamic else set())), + "variants": sorted([b, vs] for b, vs in f.variants), + "bundle": bundle, + } + + +class BuiltFontSettings(FontSettings): + """FontSettings read from font assets, which record the path id of each object they read (font_document).""" + + def __init__(self): + super().__init__({"format": FORMAT, "fonts": {}, "materials": {}}) + self.font_ids: dict[str, int] = {} # font asset name -> its object's path id + self.material_ids: dict[str, int] = {} # material name -> its object's path id + + +class AssetFontSettings(BuiltFontSettings): """FontSettings read from the game's font assets (`ex`: an Exporter of the catalog and player data): a font asset at its address (fontsettings.font_key), its fallbacks through their references (FontSet), its materials at their addresses. Needs the `fonts` extra for the source font files of dynamic assets.""" def __init__(self, ex): - super().__init__({"format": FORMAT, "fonts": {}, "materials": {}}) + super().__init__() self.ex = ex self._set = None - self._mat_objs: dict[str, ObjectReader] = {} # material name -> its object def _fontset(self): if self._set is None: self._set = FontSet(self.ex) return self._set - def _font_obj(self, name: str): + def _font_obj(self, name: str) -> Font: fs = self._fontset() if name not in fs.fonts: fs.by_key(font_key(self.ex.cat, name, name)) - return fs.fonts[name] + return fs.loaded(name) def font(self, name: str) -> dict: if name not in self.fonts: f = self._font_obj(name) - mat = self.ex.material(f.material_obj) + mat = f.default_material() self.materials.setdefault(mat["material"], mat) - self._mat_objs.setdefault(mat["material"], f.material_obj) - tt = f.tt - self.fonts[name] = { - "faceInfo": tt["m_FaceInfo"], - "atlasPadding": tt["m_AtlasPadding"], - "atlasRenderMode": tt["m_AtlasRenderMode"], - **{k: tt[k] for k in STYLE_KEYS}, - "material": mat["material"], - "fallbacks": [fb.name for fb in self._fontset().fallbacks(f)], - "coverage": encode_coverage(set(f.chars) | (f.cmap if f.dynamic else set())), - "variants": sorted([b, vs] for b, vs in f.variants), - "bundle": f.obj.assets_file.parent.name, - } + self.material_ids.setdefault(mat["material"], f.material_obj.path_id) + self.font_ids[name] = f.obj.path_id + fallbacks = [fb.name for fb in self._fontset().fallbacks(f)] + self.fonts[name] = font_record(f, mat["material"], fallbacks, f.obj.assets_file.parent.name) return self.fonts[name] def has_material(self, font: str, name: str) -> bool: @@ -791,22 +886,25 @@ def material(self, font: str | None, name: str) -> dict: key = font_key(self.ex.cat, font, name) if not self.ex.cat.has(key): raise KeyError(f"material key {key}") - self._mat_objs[name] = self.ex.key_object(key) - self.materials[name] = self.ex.material(self._mat_objs[name]) + obj = self.ex.key_object(key) + self.material_ids[name] = obj.path_id + self.materials[name] = self.ex.material(obj) return copy.deepcopy(self.materials[name]) def where(self) -> str: return "the game's font assets" -def build_font_settings(ex, player, game_version: str) -> dict: - """The font settings table (fontsettings.FORMAT) from the game's assets: every font asset LocalizeManager lists - for a language (font names and additional font names) with the assets of their fallback chains, their default - materials and the localized text materials of the listed assets that the catalog has (table_material), and the - objects index of those assets.""" - lang = language_fonts(player) - settings = AssetFontSettings(ex) - listed = sorted({f"{n} SDF" for d in lang["languages"].values() for n in d["fontNames"] + d["additionalFontNames"]}) +def listed_fonts(lang: dict) -> list[str]: + """The font assets LocalizeManager lists for the languages (`lang`: textstyle.language_fonts): " SDF" for + every font name and additional font name, sorted.""" + return sorted({f"{n} SDF" for d in lang["languages"].values() for n in d["fontNames"] + d["additionalFontNames"]}) + + +def load_listed(settings: BuiltFontSettings, lang: dict) -> list[str]: + """Reads the font assets `lang` lists with their fallback chains, and the text materials of the listed assets + (one per material type of `lang`) that the game has. -> the names of the font assets read, sorted.""" + listed = listed_fonts(lang) todo, seen = list(listed), set() while todo: name = todo.pop(0) @@ -818,15 +916,27 @@ def build_font_settings(ex, player, game_version: str) -> dict: for t in lang["materialTypes"]: if settings.has_material(name, f"{base} - {t}"): settings.material(name, f"{base} - {t}") - fs = settings._fontset() - objects = { - str(fs.fonts[n].obj.path_id): {"type": "MonoBehaviour", "class": FONT_CLASS, "name": n} for n in sorted(seen) - } - objects.update({str(o.path_id): {"type": "Material", "name": n} for n, o in sorted(settings._mat_objs.items())}) + return sorted(seen) + + +def font_document(settings: BuiltFontSettings, names: list[str], game_version: str) -> dict: + """The table (fontsettings.FORMAT) of the font assets `names` and of the materials `settings` read (table_material), + with the path id, type and name of each of those objects as the table's `objects` index.""" + objects = {str(settings.font_ids[n]): {"type": "MonoBehaviour", "class": FONT_CLASS, "name": n} for n in names} + objects.update({str(pid): {"type": "Material", "name": n} for n, pid in sorted(settings.material_ids.items())}) return { "format": FORMAT, "gameVersion": game_version, - "fonts": {n: settings.fonts[n] for n in sorted(seen)}, + "fonts": {n: settings.fonts[n] for n in names}, "materials": {n: table_material(settings.materials[n]) for n in sorted(settings.materials)}, "objects": dict(sorted(objects.items(), key=lambda kv: int(kv[0]))), } + + +def build_font_settings(ex, player, game_version: str) -> dict: + """The font settings table (fontsettings.FORMAT) from the game's assets: every font asset LocalizeManager lists + for a language (font names and additional font names) with the assets of their fallback chains, their default + materials and the localized text materials of the listed assets that the catalog has (table_material), and the + objects index of those assets.""" + settings = AssetFontSettings(ex) + return font_document(settings, load_listed(settings, language_fonts(player)), game_version) diff --git a/tests/content/test_story_fonts.py b/tests/content/test_story_fonts.py index f4608c4..010b5ae 100644 --- a/tests/content/test_story_fonts.py +++ b/tests/content/test_story_fonts.py @@ -15,82 +15,7 @@ from nnnotes.content import advui, storyfonts from nnnotes.core import cache from nnnotes.unity import fontsettings, tmpfont # noqa: E402 - -UPEM = 1000 - - -def make_font(path, family="Test Sans", style="Regular", underscore=False, drop=(), extra=None): - """A TrueType font: .notdef, space, A (square), B (triangle), U+53E3 (square with a square hole), H (square) and - x (a box 510 units high); with `underscore` also U+005F (a bar below the baseline); the code points `drop` left - out of the character map, `extra` {code point: glyph name} added to it.""" - from fontTools.fontBuilder import FontBuilder - from fontTools.pens.ttGlyphPen import TTGlyphPen - - def glyph(contours): - pen = TTGlyphPen(None) - for pts in contours: - pen.moveTo(pts[0]) - for p in pts[1:]: - pen.lineTo(p) - pen.closePath() - return pen.glyph() - - square = [(100, 0), (100, 700), (600, 700), (600, 0)] - fb = FontBuilder(UPEM, isTTF=True) - order = [".notdef", "space", "A", "B", "kou", "H", "x"] + (["underscore"] if underscore else []) - fb.setupGlyphOrder(order) - cmap = {0x20: "space", 0x41: "A", 0x42: "B", 0x53E3: "kou", 0x48: "H", 0x78: "x"} - glyphs = { - ".notdef": glyph([square]), - "space": glyph([]), - "A": glyph([square]), - "B": glyph([[(50, 0), (350, 650), (650, 0)]]), - "kou": glyph( - [[(80, -50), (80, 750), (920, 750), (920, -50)], [(250, 150), (750, 150), (750, 550), (250, 550)]] - ), - "H": glyph([square]), - "x": glyph([[(50, 0), (50, 510), (450, 510), (450, 0)]]), - } - metrics = { - ".notdef": (700, 100), - "space": (333, 0), - "A": (700, 100), - "B": (700, 50), - "kou": (1000, 80), - "H": (700, 100), - "x": (500, 50), - } - if underscore: - cmap[0x5F] = "underscore" - glyphs["underscore"] = glyph([[(0, -150), (0, -100), (500, -100), (500, -150)]]) - metrics["underscore"] = (500, 0) - cmap = {u: g for u, g in {**cmap, **(extra or {})}.items() if u not in drop} - fb.setupCharacterMap(cmap) - fb.setupGlyf(glyphs) - fb.setupHorizontalMetrics(metrics) - fb.setupHorizontalHeader(ascent=880, descent=-120, lineGap=0) - fb.setupOS2( - sTypoAscender=880, - sTypoDescender=-120, - usWinAscent=880, - usWinDescent=120, - sCapHeight=700, - sxHeight=500, - version=4, - ) - fb.setupPost(underlinePosition=-100, underlineThickness=50) - fb.setupNameTable( - { - "familyName": family, - "styleName": style, - "version": "Version 1.000", - "licenseInfoURL": "https://example.invalid/license", - } - ) - buf = io.BytesIO() - fb.save(buf) - path.write_bytes(buf.getvalue()) - return path +from tests.helpers.fonts import UPEM, make_font @pytest.fixture diff --git a/tests/helpers/fonts.py b/tests/helpers/fonts.py new file mode 100644 index 0000000..d01b1cb --- /dev/null +++ b/tests/helpers/fonts.py @@ -0,0 +1,79 @@ +"""Synthetic TrueType fonts for the tests (built with fontTools; nothing here comes from game data).""" + +import io + +UPEM = 1000 + + +def make_font(path, family="Test Sans", style="Regular", underscore=False, drop=(), extra=None): + """A TrueType font: .notdef, space, A (square), B (triangle), U+53E3 (square with a square hole), H (square) and + x (a box 510 units high); with `underscore` also U+005F (a bar below the baseline); the code points `drop` left + out of the character map, `extra` {code point: glyph name} added to it.""" + from fontTools.fontBuilder import FontBuilder + from fontTools.pens.ttGlyphPen import TTGlyphPen + + def glyph(contours): + pen = TTGlyphPen(None) + for pts in contours: + pen.moveTo(pts[0]) + for p in pts[1:]: + pen.lineTo(p) + pen.closePath() + return pen.glyph() + + square = [(100, 0), (100, 700), (600, 700), (600, 0)] + fb = FontBuilder(UPEM, isTTF=True) + order = [".notdef", "space", "A", "B", "kou", "H", "x"] + (["underscore"] if underscore else []) + fb.setupGlyphOrder(order) + cmap = {0x20: "space", 0x41: "A", 0x42: "B", 0x53E3: "kou", 0x48: "H", 0x78: "x"} + glyphs = { + ".notdef": glyph([square]), + "space": glyph([]), + "A": glyph([square]), + "B": glyph([[(50, 0), (350, 650), (650, 0)]]), + "kou": glyph( + [[(80, -50), (80, 750), (920, 750), (920, -50)], [(250, 150), (750, 150), (750, 550), (250, 550)]] + ), + "H": glyph([square]), + "x": glyph([[(50, 0), (50, 510), (450, 510), (450, 0)]]), + } + metrics = { + ".notdef": (700, 100), + "space": (333, 0), + "A": (700, 100), + "B": (700, 50), + "kou": (1000, 80), + "H": (700, 100), + "x": (500, 50), + } + if underscore: + cmap[0x5F] = "underscore" + glyphs["underscore"] = glyph([[(0, -150), (0, -100), (500, -100), (500, -150)]]) + metrics["underscore"] = (500, 0) + cmap = {u: g for u, g in {**cmap, **(extra or {})}.items() if u not in drop} + fb.setupCharacterMap(cmap) + fb.setupGlyf(glyphs) + fb.setupHorizontalMetrics(metrics) + fb.setupHorizontalHeader(ascent=880, descent=-120, lineGap=0) + fb.setupOS2( + sTypoAscender=880, + sTypoDescender=-120, + usWinAscent=880, + usWinDescent=120, + sCapHeight=700, + sxHeight=500, + version=4, + ) + fb.setupPost(underlinePosition=-100, underlineThickness=50) + fb.setupNameTable( + { + "familyName": family, + "styleName": style, + "version": "Version 1.000", + "licenseInfoURL": "https://example.invalid/license", + } + ) + buf = io.BytesIO() + fb.save(buf) + path.write_bytes(buf.getvalue()) + return path From 11eafe61260336547918bd3e92cd3fca910bf48f Mon Sep 17 00:00:00 2001 From: nichinichisou Date: Fri, 16 Oct 2026 02:50:03 +0800 Subject: [PATCH 2/3] feat(content): read TextMesh Pro font assets from object tables content/tmpassets.py reads the font data of an object table (nnnotes.objects/1) with the records the Exporter path gives: - Texture: a Texture2D's record and its RGBA texels from its PNG image. - Font and FontSet: a TMP_FontAsset with its atlas pages, source font file, default material and fallback chain, with the TMP character lookup of tmpfont.FontLookup. - TableFontSettings and build_font_settings: the font settings table (fontsettings.FORMAT) of the listed font assets of the table. - missing_glyph_character: the missing glyph character of the table's TMP Settings resource. The glyph tables and runtime glyphs come from tmpfont.export_fonts with an export.MemoryPacker, so a product stage can build glyph pages without a Unity reader. Co-Authored-By: Claude Opus 5.5 --- src/nnnotes/content/tmpassets.py | 299 +++++++++++++++++++++++++++++++ 1 file changed, 299 insertions(+) create mode 100644 src/nnnotes/content/tmpassets.py diff --git a/src/nnnotes/content/tmpassets.py b/src/nnnotes/content/tmpassets.py new file mode 100644 index 0000000..aff833e --- /dev/null +++ b/src/nnnotes/content/tmpassets.py @@ -0,0 +1,299 @@ +"""TextMesh Pro font assets read from an object table (content/prefabs.Objects). + +The records tmpfont.py reads through an Exporter, read from the object layer instead: a font asset's fields from its +`json`, its source font file from its `font` file, its atlas pages from their `image` files (PNG of the decoded image), +its materials from their `json` (the shader by name, the textures as texture records). + + Texture a Texture2D of the table: its record and its RGBA texels + Font a TMP_FontAsset of the table (the fields tmpfont.Font reads) + FontSet the font assets reached from a font asset, with the TMP character lookup of tmpfont.FontLookup + build_font_settings the font settings table (fontsettings.FORMAT) read from the table + missing_glyph_character + the game's TMP Settings resource (tmpfont.missing_glyph_character) + +The glyph tables and runtime glyphs are the tmpfont functions (export_fonts, runtime_glyphs), which take an +export.MemoryPacker to hold the packed pages in memory. Reading a font needs the `fonts` extra (tmpfont.require_extra). +""" + +from __future__ import annotations + +import copy +import hashlib +import io +from collections.abc import Callable +from typing import cast + +import numpy as np +from PIL import Image + +from ..unity.export import texel_rows +from ..unity.fontsettings import FONT_CLASS, font_dir, font_key +from ..unity.ids import parse_object_id +from ..unity.tmpfont import ( + MISSING_GLYPH_CHARACTER, + BuiltFontSettings, + FontAsset, + FontLookup, + font_cmap, + font_document, + font_record, + load_listed, +) +from .prefabs import Objects, key_object, ref_id + + +def _ref(v) -> str | None: + """The object id a reference value of the table names (None for null); a reference the table does not resolve + raises.""" + if v is None: + return None + oid = ref_id(v) + if oid is None: + raise ValueError(f"reference {v!r} does not resolve in the object table") + return oid + + +class _Keys: + """The catalog's `has` (fontsettings.font_key) over the keys of an object table.""" + + def __init__(self, objects: Objects): + self.objects = objects + + def has(self, key: str) -> bool: + try: + self.objects.key(key) + except KeyError: + return False + return True + + +class Texture: + """A Texture2D of an object table: its record (name, size, format, mip count and sampler settings, from its + `json`) and its RGBA texels, bottom row first, decoded from its `image` file.""" + + def __init__(self, objects: Objects, oid: str): + self.oid = oid + self._objects = objects + tt = objects.json(oid) + self.key: tuple[str, int] = parse_object_id(oid) + self.name: str = tt["m_Name"] + self.width: int = tt["m_Width"] + self.height: int = tt["m_Height"] + self.format: int = tt["m_TextureFormat"] + self.mip_count: int = tt.get("m_MipCount", 1) + self.settings: dict = tt["m_TextureSettings"] + self._pixels: np.ndarray | None = None + + @property + def pixels(self) -> np.ndarray: + """The texels as RGBA, bottom row first (read-only), decoded once.""" + if self._pixels is None: + ext = self._objects.info(self.oid)["files"]["image"]["ext"] + if ext != "png": + raise NotImplementedError(f"texture {self.name}: image file {ext}") + img = Image.open(io.BytesIO(self._objects.bytes(self.oid, "image"))) + arr = texel_rows(img, self.format, self.width, self.height, self.name) + arr.setflags(write=False) + self._pixels = arr + return self._pixels + + def record(self) -> dict: + """The record of a material's texture reference (export.Exporter.texture in deferred mode).""" + return { + "textureRef": self.oid, + "name": self.name, + "width": self.width, + "height": self.height, + "format": self.format, + "mipCount": self.mip_count, + "settings": self.settings, + } + + +class Font: + """A TMP_FontAsset of an object table: the fields tmpfont.Font reads, its atlas pages (Texture, None for a missing + page), the bytes and character map of its source font file, and its default material.""" + + def __init__(self, fonts: FontSet, oid: str): + objects = fonts.objects + tt = objects.json(oid) + self.oid = oid + self._fonts = fonts + self.tt = tt + self.name: str = tt["m_Name"] + self.chars = {c["m_Unicode"]: c for c in tt["m_CharacterTable"]} + self.glyphs = {g["m_Index"]: g for g in tt["m_GlyphTable"]} + self.dynamic = tt["m_AtlasPopulationMode"] == 1 + self.atlases: list[Texture | None] = [] + for p in tt["m_AtlasTextures"]: + page = _ref(p) + self.atlases.append(None if page is None else fonts.texture(page)) + material = _ref(tt["m_Material"]) + if material is None: + raise KeyError(f"{self.name}: a required reference is null (m_Material)") + self.material_oid: str = material + self.fallback_oids: list[str | None] = [_ref(p) for p in tt["m_FallbackFontAssetTable"]] + if any(_ref(p["regularTypeface"]) or _ref(p["italicTypeface"]) for p in tt["m_FontWeightTable"]): + raise NotImplementedError(f"{self.name}: font weight table typefaces") + self.cmap: set[int] = set() + self.variants: dict[tuple[int, int], str] = {} + self.font_data: bytes | None = None + self.source_font: dict | None = None + src = _ref(tt["m_SourceFontFile"]) + if src is not None: + data = objects.bytes(src, "font") + self.cmap, self.variants = font_cmap(data, tt["m_FaceInfo"]["m_FaceIndex"]) + self.font_data = data + self.source_font = { + "name": objects.json(src)["m_Name"], + "bytes": len(data), + "sha256": hashlib.sha256(data).hexdigest(), + } + + def atlas_names(self) -> list[str | None]: + return [None if a is None else a.name for a in self.atlases] + + def atlas_settings(self) -> list[dict]: + return [a.settings for a in self.atlases if a is not None] + + def default_material(self) -> dict: + """The record of the default material (export.Exporter.material).""" + return self._fonts.material(self.material_oid) + + +class FontSet(FontLookup): + """The font assets of an object table reached from a font asset (through their fallback chains), with the TMP + character lookup of FontLookup.""" + + def __init__(self, objects: Objects): + super().__init__() + self.objects = objects + self._by_oid: dict[str, Font] = {} + self._textures: dict[str, Texture] = {} + + def texture(self, oid: str) -> Texture: + if oid not in self._textures: + cls = self.objects.info(oid).get("class") + if cls != "Texture2D": + raise NotImplementedError(f"{oid}: a texture reference to a {cls}") + self._textures[oid] = Texture(self.objects, oid) + return self._textures[oid] + + def by_key(self, key: str) -> Font: + """The font asset of the catalog key `key` (its font folder's TMP_FontAsset).""" + return self.of(key_object(self.objects, key, FONT_CLASS)) + + def loaded(self, name: str) -> Font: + """The font asset `name`, read by `of` or `by_key`.""" + return cast(Font, self.fonts[name]) + + def of(self, oid: str) -> Font: + if oid not in self._by_oid: + f = Font(self, oid) + if f.name in self.fonts: + raise RuntimeError(f"two font assets named {f.name}") + self._by_oid[oid] = f + self.fonts[f.name] = f + self._fallback_names[f.name] = [self.of(fb).name for fb in f.fallback_oids if fb is not None] + return self._by_oid[oid] + + def material(self, oid: str) -> dict: + """The record of the Material `oid` (export.Exporter.material): its shader by name, its textures as texture + records, its other fields as in its `json`.""" + doc = self.objects.json(oid) + shader = _ref(doc["shader"]) + return { + "material": doc["material"], + "shader": None if shader is None else {"shader": self.objects.json(shader)["name"]}, + "keywords": doc["keywords"], + "invalidKeywords": doc["invalidKeywords"], + "renderQueue": doc["renderQueue"], + "tags": doc["tags"], + "disabledPasses": doc["disabledPasses"], + "textures": {k: self._texture_entry(v) for k, v in doc["textures"].items()}, + "ints": doc["ints"], + "floats": doc["floats"], + "colors": doc["colors"], + } + + def _texture_entry(self, v: dict) -> dict: + tex = _ref(v["texture"]) + return { + "texture": None if tex is None else self.texture(tex).record(), + "scale": v["scale"], + "offset": v["offset"], + } + + def named_material(self, font: FontAsset, name: str) -> dict: + """The material `name` of the font asset `font`, at its key in the font's folder (tmpfont.material_by_name).""" + key = font_dir(font.name) + name + if not _Keys(self.objects).has(key): + raise KeyError(f"material key {key}") + return self.material(key_object(self.objects, key, "Material")) + + +class TableFontSettings(BuiltFontSettings): + """FontSettings read from the font assets of an object table (FontSet): each font asset's bundle from + `bundle_of(name)`, the records as tmpfont.AssetFontSettings reads them from the game's assets.""" + + def __init__(self, fonts: FontSet, bundle_of: Callable[[str], str]): + super().__init__() + self._set = fonts + self._keys = _Keys(fonts.objects) + self._bundle_of = bundle_of + + def font(self, name: str) -> dict: + if name not in self.fonts: + if name not in self._set.fonts: + self._set.by_key(font_key(self._keys, name, name)) + f = self._set.loaded(name) + mat = f.default_material() + self.materials.setdefault(mat["material"], mat) + self.material_ids.setdefault(mat["material"], parse_object_id(f.material_oid)[1]) + self.font_ids[name] = parse_object_id(f.oid)[1] + fallbacks = [fb.name for fb in self._set.fallbacks(f)] + self.fonts[name] = font_record(f, mat["material"], fallbacks, self._bundle_of(name)) + return self.fonts[name] + + def has_material(self, font: str, name: str) -> bool: + return name in self.materials or self._keys.has(font_key(self._keys, font, name)) + + def material(self, font: str | None, name: str) -> dict: + if name not in self.materials: + if font is None: + raise KeyError(f"material {name}: not read yet, and no font asset to find it by") + key = font_key(self._keys, font, name) + if not self._keys.has(key): + raise KeyError(f"material key {key}") + oid = key_object(self._set.objects, key, "Material") + self.material_ids[name] = parse_object_id(oid)[1] + self.materials[name] = self._set.material(oid) + return copy.deepcopy(self.materials[name]) + + def where(self) -> str: + return "the font assets of the object table" + + +def build_font_settings(objects: Objects, lang: dict, game_version: str, bundle_of: Callable[[str], str]) -> dict: + """The font settings table (fontsettings.FORMAT) read from the font assets of an object table: the document + tmpfont.build_font_settings gives for the game's assets. `lang` is LocalizeManager's font data + (textstyle.language_fonts), `bundle_of(name)` the bundle that holds the font asset `name`.""" + settings = TableFontSettings(FontSet(objects), bundle_of) + return font_document(settings, load_listed(settings, lang), game_version) + + +def missing_glyph_character(objects: Objects) -> int: + """TMP_Settings.missingGlyphCharacter of the table's TMP Settings resource (tmpfont.missing_glyph_character).""" + hits = [ + o + for o in objects.roots + if o in objects + and objects.info(o).get("name") == "TMP Settings" + and objects.info(o).get("class") == "MonoBehaviour" + ] + if len(hits) != 1: + raise ValueError(f"TMP Settings: {len(hits)} resources in the table, expected one") + tt = objects.json(hits[0]) + if tt["m_fallbackFontAssets"] or _ref(tt["m_defaultFontAsset"]) is not None: + raise NotImplementedError("TMP Settings fallback font assets / default font asset") + return tt["m_missingGlyphCharacter"] or MISSING_GLYPH_CHARACTER From d1fe55f7f241a70725ae1bc088fe638acf85a11d Mon Sep 17 00:00:00 2001 From: nichinichisou Date: Fri, 16 Oct 2026 02:50:03 +0800 Subject: [PATCH 3/3] test(content): cover font assets read from object tables Synthetic object tables check the texture records and texels, the font asset fields, fallback chains and lookup, the material records, the font settings table, the missing glyph character and unresolved references, and the block layout and page bytes of MemoryPacker. Co-Authored-By: Claude Opus 5.5 --- tests/content/test_tmpassets.py | 409 ++++++++++++++++++++++++++++++++ 1 file changed, 409 insertions(+) create mode 100644 tests/content/test_tmpassets.py diff --git a/tests/content/test_tmpassets.py b/tests/content/test_tmpassets.py new file mode 100644 index 0000000..52227f9 --- /dev/null +++ b/tests/content/test_tmpassets.py @@ -0,0 +1,409 @@ +"""TextMesh Pro font assets read from an object table (content/tmpassets.py), their lookup and settings, and the +in-memory texture packer (export.MemoryPacker), on synthetic objects; nothing here comes from game data.""" + +import hashlib +import io +import json + +import numpy as np +import pytest +from PIL import Image + +from nnnotes.content import tmpassets +from nnnotes.unity import fontsettings +from nnnotes.unity.export import MemoryPacker, packed_png +from tests.helpers.fonts import make_font + +FILE = "CAB-tmpassets" +SETTINGS = {"m_FilterMode": 1, "m_Aniso": 1, "m_MipBias": 0.0, "m_WrapU": 1, "m_WrapV": 1, "m_WrapW": 1} +SCRIPT = "Unity.TextMeshPro|TMPro|TMP_FontAsset" + + +def oid(pid: int) -> str: + return f"{FILE}:{pid}" + + +def ref(o: str | None): + """A reference value as the object layer writes it.""" + if o is None: + return None + file, pid = o.rsplit(":", 1) + return {"$ref": {"file": file, "pathId": pid}} + + +class Table: + """Synthetic objects with the protocol of prefabs.Objects. `entries` maps an object id to (class, name, json, + files), files being {role: (bytes, extension)}; a json file is written from the json document when it is not None. + `keys` maps catalog keys to object ids and `roots` lists the root object ids.""" + + def __init__(self, entries, keys=None, roots=()): + self.roots = list(roots) + self._keys = {k: list(v) for k, v in (keys or {}).items()} + self._info: dict[str, dict] = {} + self._data: dict[tuple[str, str], bytes] = {} + for o, (cls, name, doc, files) in entries.items(): + files = dict(files) + if doc is not None: + files["json"] = (json.dumps(doc).encode(), "json") + self._info[o] = { + "class": cls, + "name": name, + "files": { + role: {"sha256": hashlib.sha256(data).hexdigest(), "size": len(data), "ext": ext} + for role, (data, ext) in files.items() + }, + } + for role, (data, _) in files.items(): + self._data[(o, role)] = data + + def __contains__(self, o: str) -> bool: + return o in self._info + + def ids(self, cls=None) -> list[str]: + return sorted(o for o, i in self._info.items() if cls is None or i["class"] == cls) + + def key(self, key: str) -> list[str]: + if key not in self._keys: + raise KeyError(f"{key} is not a key of the product") + return self._keys[key] + + def info(self, o: str) -> dict: + return self._info[o] + + def bytes(self, o: str, role: str) -> bytes: + return self._data[(o, role)] + + def json(self, o: str, role: str = "json"): + return json.loads(self.bytes(o, role)) + + +def png(arr: np.ndarray) -> bytes: + buf = io.BytesIO() + Image.fromarray(arr).save(buf, format="PNG") + return buf.getvalue() + + +def texture_doc(name: str, w: int, h: int, fmt: int) -> dict: + return { + "m_Name": name, + "m_Width": w, + "m_Height": h, + "m_TextureFormat": fmt, + "m_MipCount": 1, + "m_TextureSettings": SETTINGS, + } + + +def tmp_font(name, *, chars=(), fallbacks=(), atlases=(), material=None, source=None, dynamic=False) -> dict: + """The json of a TMP_FontAsset: the fields tmpassets.Font reads, with `chars` in its character table.""" + return { + "$script": SCRIPT, + "m_Name": name, + "m_CharacterTable": [ + {"m_Unicode": u, "m_GlyphIndex": i + 1, "m_Scale": 1.0, "m_ElementType": 1} for i, u in enumerate(chars) + ], + "m_GlyphTable": [], + "m_AtlasPopulationMode": 1 if dynamic else 0, + "m_AtlasTextures": [ref(a) for a in atlases], + "m_Material": ref(material), + "m_FallbackFontAssetTable": [ref(f) for f in fallbacks], + "m_FontWeightTable": [], + "m_SourceFontFile": ref(source), + "m_FaceInfo": {"m_FaceIndex": 0, "m_PointSize": 40.0, "m_UnitsPerEM": 1000}, + "m_AtlasWidth": 256, + "m_AtlasHeight": 256, + "m_AtlasPadding": 4, + "m_AtlasRenderMode": 4134, + "normalStyle": 0.5, + "normalSpacingOffset": 0.0, + "boldStyle": 0.75, + "boldSpacing": 7.0, + "italicStyle": 0.35, + "tabSize": 10, + "m_FontFeatureTable": {"m_GlyphPairAdjustmentRecords": []}, + } + + +def material_doc(name, shader=None, texture=None) -> dict: + """The json of a Material (export.convert material_doc, references as {"$ref"}).""" + return { + "material": name, + "shader": ref(shader), + "keywords": ["OUTLINE_ON"], + "invalidKeywords": [], + "renderQueue": 3000, + "tags": {"RenderType": "Transparent"}, + "disabledPasses": [], + "textures": {"_MainTex": {"texture": ref(texture), "scale": [1.0, 1.0], "offset": [0.0, 0.0]}}, + "ints": {}, + "floats": {"_GradientScale": 4.0}, + "colors": {}, + } + + +def atlas_entries(pid: int, name: str, w: int = 4, h: int = 3) -> dict: + """A Texture2D entry of the table: an RGBA atlas (its PNG holds the texels).""" + return { + oid(pid): ( + "Texture2D", + name, + texture_doc(name, w, h, 4), + {"image": (png(np.zeros((h, w, 4), np.uint8)), "png")}, + ) + } + + +def test_texture_pixels_are_the_decoded_image_bottom_row_first(): + rng = np.random.default_rng(7) + rgba = rng.integers(0, 256, (3, 4, 4), dtype=np.uint8) + entries = {oid(1): ("Texture2D", "Atlas", texture_doc("Atlas", 4, 3, 4), {"image": (png(rgba), "png")})} + tex = tmpassets.Texture(Table(entries), oid(1)) + assert tex.key == (FILE, 1) + assert tex.record() == { + "textureRef": oid(1), + "name": "Atlas", + "width": 4, + "height": 3, + "format": 4, + "mipCount": 1, + "settings": SETTINGS, + } + assert np.array_equal(tex.pixels, rgba[::-1]) + assert not tex.pixels.flags.writeable + + +def test_alpha8_texels_keep_their_value_in_alpha(): + a = np.arange(12, dtype=np.uint8).reshape(3, 4) + entries = {oid(2): ("Texture2D", "Glyphs", texture_doc("Glyphs", 4, 3, 1), {"image": (png(a), "png")})} + px = tmpassets.Texture(Table(entries), oid(2)).pixels + assert np.array_equal(px[:, :, 3], a[::-1]) + assert not px[:, :, :3].any() + + +def test_texture_image_must_be_png(): + entries = {oid(3): ("Texture2D", "Hdr", texture_doc("Hdr", 4, 4, 1), {"image": (b"astc", "astc")})} + with pytest.raises(NotImplementedError, match="image file astc"): + _ = tmpassets.Texture(Table(entries), oid(3)).pixels + + +def test_font_asset_fields_atlases_and_default_material(): + entries = { + oid(10): ( + "MonoBehaviour", + "X SDF", + tmp_font("X SDF", chars=[0x41, 0x42], atlases=[oid(11), None], material=oid(12)), + {}, + ), + **atlas_entries(11, "X Atlas"), + oid(12): ("Material", "X SDF Material", material_doc("X SDF Material", shader=oid(13), texture=oid(11)), {}), + oid(13): ("Shader", "TextMeshPro/Distance Field", {"name": "TextMeshPro/Distance Field"}, {}), + } + fs = tmpassets.FontSet(Table(entries, keys={"EmbFont/X/X SDF": [oid(10)]})) + f = fs.by_key("EmbFont/X/X SDF") + assert f.name == "X SDF" + assert sorted(f.chars) == [0x41, 0x42] + assert not f.dynamic and f.source_font is None and f.font_data is None and f.cmap == set() + assert f.atlas_names() == ["X Atlas", None] + assert f.atlas_settings() == [SETTINGS] + assert f.default_material() == { + "material": "X SDF Material", + "shader": {"shader": "TextMeshPro/Distance Field"}, + "keywords": ["OUTLINE_ON"], + "invalidKeywords": [], + "renderQueue": 3000, + "tags": {"RenderType": "Transparent"}, + "disabledPasses": [], + "textures": { + "_MainTex": { + "texture": { + "textureRef": oid(11), + "name": "X Atlas", + "width": 4, + "height": 3, + "format": 4, + "mipCount": 1, + "settings": SETTINGS, + }, + "scale": [1.0, 1.0], + "offset": [0.0, 0.0], + } + }, + "ints": {}, + "floats": {"_GradientScale": 4.0}, + "colors": {}, + } + + +def test_material_without_shader_or_texture_keeps_them_null(): + entries = {oid(12): ("Material", "M", material_doc("M"), {})} + m = tmpassets.FontSet(Table(entries)).material(oid(12)) + assert m["shader"] is None + assert m["textures"]["_MainTex"]["texture"] is None + + +def test_material_texture_must_be_a_texture2d(): + entries = { + oid(12): ("Material", "M", material_doc("M", texture=oid(11)), {}), + oid(11): ("RenderTexture", "RT", {"m_Name": "RT"}, {}), + } + with pytest.raises(NotImplementedError, match="RenderTexture"): + tmpassets.FontSet(Table(entries)).material(oid(12)) + + +def test_lookup_follows_the_fallback_chain(): + entries = { + oid(1): ("MonoBehaviour", "P SDF", tmp_font("P SDF", chars=[0x41], fallbacks=[oid(2)], material=oid(9)), {}), + oid(2): ("MonoBehaviour", "F SDF", tmp_font("F SDF", chars=[0x42], fallbacks=[oid(3)], material=oid(9)), {}), + oid(3): ("MonoBehaviour", "G SDF", tmp_font("G SDF", chars=[0x43], material=oid(9)), {}), + oid(9): ("Material", "M", material_doc("M"), {}), + } + fs = tmpassets.FontSet(Table(entries, keys={"EmbFont/P/P SDF": [oid(1)]})) + p = fs.by_key("EmbFont/P/P SDF") + assert sorted(fs.fonts) == ["F SDF", "G SDF", "P SDF"] + assert [fb.name for fb in fs.fallbacks(p)] == ["F SDF"] + assert fs.lookup(p, 0x41) == ("baked", p) + assert fs.lookup(p, 0x42)[1].name == "F SDF" + assert fs.lookup(p, 0x43)[1].name == "G SDF" + assert fs.lookup(p, 0x44) is None + assert fs.baked_anywhere(0x43) == ["G SDF"] + + +def test_dynamic_font_reads_its_source_font(tmp_path): + pytest.importorskip("fontTools") + data = make_font(tmp_path / "Test.ttf").read_bytes() + entries = { + oid(1): ( + "MonoBehaviour", + "T SDF", + tmp_font("T SDF", chars=[0x41], dynamic=True, source=oid(4), material=oid(9)), + {}, + ), + oid(4): ("Font", "Test Sans", {"m_Name": "Test Sans"}, {"font": (data, "ttf")}), + oid(9): ("Material", "M", material_doc("M"), {}), + } + f = tmpassets.FontSet(Table(entries)).of(oid(1)) + assert f.dynamic + assert {0x20, 0x41, 0x42, 0x48, 0x78, 0x53E3} <= f.cmap + assert f.variants == {} + assert f.font_data == data + assert f.source_font == {"name": "Test Sans", "bytes": len(data), "sha256": hashlib.sha256(data).hexdigest()} + + +def test_build_font_settings_reads_the_listed_fonts_and_their_materials(): + lang = {"languages": {"ja": {"fontNames": ["X"], "additionalFontNames": []}}, "materialTypes": ["Outline"]} + entries = { + oid(1): ( + "MonoBehaviour", + "X SDF", + tmp_font("X SDF", chars=[0x41], fallbacks=[oid(2)], atlases=[oid(4)], material=oid(3)), + {}, + ), + oid(2): ("MonoBehaviour", "Y SDF", tmp_font("Y SDF", chars=[0x42], material=oid(5)), {}), + oid(3): ("Material", "X SDF Material", material_doc("X SDF Material", texture=oid(4)), {}), + oid(5): ("Material", "Y SDF Material", material_doc("Y SDF Material"), {}), + oid(6): ("Material", "X - Outline", material_doc("X - Outline"), {}), + **atlas_entries(4, "X Atlas"), + } + keys = {"EmbFont/X/X SDF": [oid(1)], "EmbFont/X/X - Outline": [oid(6)]} + doc = tmpassets.build_font_settings( + Table(entries, keys), lang, "9.9.9", lambda name: f"embfont-{name[:-4].lower()}.bundle" + ) + assert doc["format"] == fontsettings.FORMAT and doc["gameVersion"] == "9.9.9" + assert sorted(doc["fonts"]) == ["X SDF", "Y SDF"] + x = doc["fonts"]["X SDF"] + assert x["material"] == "X SDF Material" + assert x["fallbacks"] == ["Y SDF"] + assert x["bundle"] == "embfont-x.bundle" + assert x["atlasPadding"] == 4 and x["faceInfo"] == {"m_FaceIndex": 0, "m_PointSize": 40.0, "m_UnitsPerEM": 1000} + assert fontsettings.decode_coverage(x["coverage"]) == bytes(8) + bytes([0b10]) + assert sorted(doc["materials"]) == ["X - Outline", "X SDF Material", "Y SDF Material"] + assert doc["materials"]["X SDF Material"]["textures"]["_MainTex"]["texture"] == { + "name": "X Atlas", + "width": 4, + "height": 3, + "format": 4, + } + assert doc["objects"] == { + "1": {"type": "MonoBehaviour", "class": "TMP_FontAsset", "name": "X SDF"}, + "2": {"type": "MonoBehaviour", "class": "TMP_FontAsset", "name": "Y SDF"}, + "3": {"type": "Material", "name": "X SDF Material"}, + "5": {"type": "Material", "name": "Y SDF Material"}, + "6": {"type": "Material", "name": "X - Outline"}, + } + table = fontsettings.FontSettings(doc) + assert table.lookup("X SDF", 0x42) == "Y SDF" + + +def test_missing_glyph_character_of_the_table_resource(): + def table(**fields) -> Table: + doc = { + "$script": "Unity.TextMeshPro|TMPro|TMP_Settings", + "m_fallbackFontAssets": [], + "m_defaultFontAsset": None, + "m_missingGlyphCharacter": 0, + } + doc.update(fields) + return Table({oid(9): ("MonoBehaviour", "TMP Settings", doc, {})}, roots=[oid(9)]) + + assert tmpassets.missing_glyph_character(table()) == 0x25A1 + assert tmpassets.missing_glyph_character(table(m_missingGlyphCharacter=0x3F)) == 0x3F + with pytest.raises(NotImplementedError): + tmpassets.missing_glyph_character(table(m_fallbackFontAssets=[ref(oid(4))])) + with pytest.raises(ValueError, match="expected one"): + tmpassets.missing_glyph_character(Table({})) + + +def test_unresolved_reference_raises(): + assert tmpassets._ref(None) is None + with pytest.raises(ValueError, match="does not resolve"): + tmpassets._ref({"$ref": {"fileId": 1, "pathId": "5"}}) + + +class Block: + """A source texture as MemoryPacker reads it: its key, name, sampler settings, size and RGBA texels.""" + + def __init__(self, key, name, pixels): + self.key, self.name, self.settings = key, name, SETTINGS + self.height, self.width = pixels.shape[:2] + self.pixels = pixels + + +def test_memory_packer_lays_out_blocks_and_returns_the_pages(): + a = np.arange(2 * 4 * 4, dtype=np.uint8).reshape(2, 4, 4) + b = (np.arange(2 * 3 * 4, dtype=np.uint8) + 100).reshape(2, 3, 4) + ta, tb = Block((FILE, 1), "A", a), Block((FILE, 2), "B", b) + packer = MemoryPacker() + packer.WIDTH = 4 # the blocks fill one row + ha = packer.add("font_X", ta, 0, 0, 2, 2) + hb = packer.add("font_X", tb, 1, 0, 3, 2) + glyph = np.full((1, 1, 4), 9, np.uint8) + hg = packer.add_array("other", "runtime glyph 7", glyph, (5, 6), SETTINGS) + textures, pages = packer.build() + + out = np.zeros((2, 4, 4), np.uint8) + out[:, 0:2] = a[:, 0:2] + out[:, 2:4] = b[:, 1:3] + assert textures["font_X"] == { + "texture": "textures/font_X.png", + "name": "font_X", + "width": 4, + "height": 2, + "mipCount": 1, + "settings": SETTINGS, + "sources": ["A", "B"], + "blocks": 2, + } + assert textures["other"] == { + "texture": "textures/other.png", + "name": "other", + "width": 1, + "height": 1, + "mipCount": 1, + "settings": SETTINGS, + "sources": ["runtime glyph 7"], + "blocks": 1, + } + assert pages == {"textures/font_X.png": packed_png(out), "textures/other.png": packed_png(glyph)} + assert (ha["texture"], ha["offset"]) == ("font_X", (0, 0)) + assert (hb["texture"], hb["offset"]) == ("font_X", (1, 0)) + assert (hg["texture"], hg["offset"]) == ("other", (-5, -6))