diff --git a/android/sync_client.py b/android/sync_client.py index 709cdc13..f403ea7e 100644 --- a/android/sync_client.py +++ b/android/sync_client.py @@ -88,7 +88,9 @@ def local_files(client, locale): path = Path(dirpath) / name files[path.relative_to(client).as_posix()] = path for path in client.iterdir(): - if path.is_file() and (path.name == "channel.inf" or (path.name.startswith("locale") and path.suffix == ".cfg")): + # hd_ui.pak: the 2x UI textures of scripts/build_hd_ui.py, when built (src/host/texture_decode.cpp). + if path.is_file() and (path.name in ("channel.inf", "hd_ui.pak") + or (path.name.startswith("locale") and path.suffix == ".cfg")): files[path.name] = path if locale: cfg = client / f"locale_{locale}.cfg" diff --git a/audit/manifest.json b/audit/manifest.json index ed2d15d6..80d896e2 100644 --- a/audit/manifest.json +++ b/audit/manifest.json @@ -4130,7 +4130,15 @@ "focus_pause_exit_cleanup": "PARTIAL" }, "platform_adaptations": [ - "Godot mobile touch/joystick/pinch/landscape UI is a platform adaptation and has no direct Windows 40250 branch to compare 1:1." + "Godot mobile touch/joystick/pinch/landscape UI is a platform adaptation and has no direct Windows 40250 branch to compare 1:1.", + { + "reference": "CGraphicImageTexture loads the UI images (d:/ymir work/ui/, icon/, locale//ui) from the packs at their 1x size; 40250 draws them 1:1 on an 800x600+ desktop", + "implementation": "src/host/vk_textures.cpp get_gpu_texture -> src/host/texture_decode.cpp read_hd_ui_texture: a UI texture present in hd_ui.pak (scripts/build_hd_ui.py, realesrgan-x4plus + back-projection, 2x) is uploaded in place of the pack file; MT_HD_UI=0 disables", + "invariant": "only the GPU texel density changes: the engine's own decode, the image sizes, layout, quads and normalized UVs stay the 40250 1x ones; a name absent from the pak falls back to the pack", + "tests": [ + "real server 192.168.21.203 shop + character window A/B 2026-09-30 (MT_HD_UI=0 vs 1, 600-high UI at ~1.7x): identical layout, icons and portrait sharper, board grain kept; 2406 textures, 135 MB pak" + ] + } ], "evidence": { "contract": "audit/contracts/ui.input-camera-mobile.md", diff --git a/scripts/build_hd_ui.py b/scripts/build_hd_ui.py new file mode 100644 index 00000000..85b9f248 --- /dev/null +++ b/scripts/build_hd_ui.py @@ -0,0 +1,252 @@ +#!/usr/bin/env python3 +"""Builds hd_ui.pak: the 40250 UI textures at twice their resolution, for src/host/hd_ui.cpp. + +The phone lays the 40250 UI out 600 logical pixels high and magnifies it about 1.8x onto the screen, so +the 1x art (32x32 icons, board frames) looks soft. This reads every UI image the client would load +(d:/ymir work/ui/, icon/, locale//) from the registered packs (first registered pack wins, as +CEterPackManager), upscales it 4x with Real-ESRGAN (realesrgan-x4plus, ncnn-vulkan build) and +Lanczos-reduces it to exactly 2x. The engine keeps the 1x size for layout and UVs; the host only +samples finer texels, so nothing about the 40250 UI changes but the texel density. + + scripts/build_hd_ui.py --esrgan DIR [--client build/zh-locale/Client] [--locale zh] [--out FILE] + +DIR holds the realesrgan-ncnn-vulkan binary and its models/ (github.com/xinntao/Real-ESRGAN releases, +v0.2.5.0). Neither the tool nor the pak enters git: the output is Ymir art (see .gitignore). + +Grain: realesrgan-x4plus smooths the stone and paper fills of the boards; two rounds of back-projection +(the source minus the result box-reduced to 1x, enlarged and added) put that texture back. + +Edges: each image is padded before upscaling so the network sees context at the border. An image whose +opposite edges continue each other (a tiling board fill) is padded by wrapping, so its repeats meet +without a seam; others by reflection. Colours under fully transparent pixels are bled in from their +opaque neighbours first, so the upscaled alpha edges carry no dark fringe. + +Pak format (little endian): "MTHD", u32 version (1), u32 count, then count x {u16 name length, name +bytes (the pack path, lowercase), u32 offset, u32 size}, then the PNG payloads at their offsets. +""" + +from __future__ import annotations + +import argparse +import io +import json +import os +import shutil +import struct +import subprocess +import sys +import tempfile +from concurrent.futures import ProcessPoolExecutor +from pathlib import Path + +import numpy as np +from PIL import Image, ImageFilter + +REPO = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(REPO / "tools/epk_scan")) +sys.path.insert(0, str(REPO / ".agents/skills/metin2-40250-parity-audit/scripts")) +import epk_scan # noqa: E402 +import refroot # noqa: E402 + +IMAGE_EXTS = (".tga", ".dds", ".png", ".jpg", ".bmp") +MAX_SIDE = 1024 # larger images (none in the UI dirs today) are left at 1x +PAD = 8 # source pixels of context around each image +MODEL = "realesrgan-x4plus" +BACK_PROJECT_ROUNDS = 2 +VERSION = 1 + + +def pack_entries(client: Path): + """{pack path: reader} for the first registered pack holding each name.""" + _, keys, order, packs, _ = epk_scan.load(client, refroot.reference_root()) + seen = {} + for name, _folder in order: + p = packs[name] + if p["missing"]: + continue + for e in p["entries"]: + seen.setdefault(e["name"], (p["epk"], e)) + return keys, seen + + +def read_entry(keys, epk: Path, e: dict) -> bytes: + with open(epk, "rb") as f: + f.seek(e["pos"]) + stored = f.read(e["size"]) + if e["type"] == 0: + return stored + f.seek(e["pos"]) + obj = f.read(epk_scan.lz_object_len(stored[:16])) + return epk_scan.lz_object(obj, keys["s_adwEterPackSecurityKey"] if e["type"] == 2 else None) + + +def wanted(name: str, locale: str) -> bool: + if not name.endswith(IMAGE_EXTS): + return False + return (name.startswith("d:/ymir work/ui/") or name.startswith("icon/") + or name.startswith(f"locale/{locale}/")) + + +def tiles(a: np.ndarray, axis: int) -> bool: + """Whether the image continues across the edges of `axis` (1: left/right, 0: top/bottom).""" + if a.shape[axis] < 4 or a[..., 3].min() < 255: + return False + rgb = a[..., :3].astype(np.int32) + first, last = np.take(rgb, 0, axis=axis), np.take(rgb, -1, axis=axis) + wrap = np.abs(first - last).mean() + inner = np.abs(np.diff(rgb, axis=axis)).mean() + return wrap <= max(2.0, 1.5 * inner) + + +def bleed(im: Image.Image) -> Image.Image: + """Fills the colour of fully transparent pixels from nearby opaque ones (premultiplied blur).""" + a = np.asarray(im).astype(np.float32) + alpha = a[..., 3:4] / 255.0 + if alpha.min() > 0: + return im + fill = a[..., :3].copy() + for radius in (2, 6, 16, 48): + pre = Image.fromarray(np.clip(a[..., :3] * alpha, 0, 255).astype(np.uint8)) + w = Image.fromarray((alpha[..., 0] * 255).astype(np.uint8)) + pre_b = np.asarray(pre.filter(ImageFilter.GaussianBlur(radius))).astype(np.float32) + w_b = np.asarray(w.filter(ImageFilter.GaussianBlur(radius))).astype(np.float32)[..., None] + ok = (w_b > 0.5) & (alpha == 0) + est = pre_b / np.maximum(w_b / 255.0, 1e-6) + fill = np.where(ok, est, fill) + alpha_known = (alpha > 0) | ok + if alpha_known.all(): + break + out = a.copy() + out[..., :3] = np.where(alpha == 0, fill, a[..., :3]) + return Image.fromarray(np.clip(out, 0, 255).astype(np.uint8), "RGBA") + + +def prepare(job): + """Decodes one pack image into a padded RGBA PNG for the upscaler; returns its metadata or None.""" + idx, name, data, work = job + try: + im = Image.open(io.BytesIO(data)) + im.load() + except Exception: + return None + im = im.convert("RGBA") + w, h = im.size + if max(w, h) > MAX_SIDE or min(w, h) < 2: + return None + a = np.asarray(im) + tile_x, tile_y = tiles(a, 1), tiles(a, 0) + src = np.asarray(bleed(im)) + px, py = min(PAD, w), min(PAD, h) + src = np.pad(src, ((0, 0), (px, px), (0, 0)), mode="wrap" if tile_x else "reflect") + src = np.pad(src, ((py, py), (0, 0), (0, 0)), mode="wrap" if tile_y else "reflect") + Image.fromarray(src, "RGBA").save(work / "in" / f"{idx}.png", compress_level=1) + return {"idx": idx, "name": name, "w": w, "h": h, "px": px, "py": py, + "alpha": bool(a[..., 3].min() < 255)} + + +def original(meta, work: Path) -> Image.Image: + src = Image.open(work / "in" / f"{meta['idx']}.png").convert("RGBA") + return src.crop((meta["px"], meta["py"], meta["px"] + meta["w"], meta["py"] + meta["h"])) + + +def back_project(hd: Image.Image, src: Image.Image, rounds: int = BACK_PROJECT_ROUNDS) -> Image.Image: + """Adds back what the upscale lost at the source scale, so the result box-reduces to the source. + + realesrgan-x4plus denoises, and the fine stone and paper grain of the 40250 boards is what it takes + for noise: the frames come out sharp but the fills flat. The residual (source minus the result + reduced to 1x), bicubic-enlarged and added, restores that grain while the network's edges stay.""" + a = np.asarray(hd).astype(np.float32) + ref = np.asarray(src).astype(np.float32) + for _ in range(rounds): + down = np.asarray(Image.fromarray(np.clip(a + 0.5, 0, 255).astype(np.uint8), "RGBA").resize(src.size, Image.BOX)) + residual = ref - down.astype(np.float32) + a += np.stack([np.asarray(Image.fromarray(residual[..., c]).resize(hd.size, Image.BICUBIC)) for c in range(4)], -1) + return Image.fromarray(np.clip(a + 0.5, 0, 255).astype(np.uint8), "RGBA") + + +def finish(job): + """Crops the padding off the 4x result and reduces it to 2x; returns (name, png bytes) or None.""" + meta, work = job + path = work / "out" / f"{meta['idx']}.png" + if not path.exists(): + return None + up = Image.open(path).convert("RGBA") + s = 4 + up = up.crop((meta["px"] * s, meta["py"] * s, (meta["px"] + meta["w"]) * s, (meta["py"] + meta["h"]) * s)) + hd = back_project(up.resize((meta["w"] * 2, meta["h"] * 2), Image.LANCZOS), original(meta, work)) + if not meta["alpha"]: + hd.putalpha(255) + buf = io.BytesIO() + hd.save(buf, "PNG", compress_level=6) + return meta["name"], buf.getvalue() + + +def write_pak(out: Path, items: list[tuple[str, bytes]]) -> None: + items = sorted(items) + offset = 12 + sum(2 + len(n.encode("latin-1")) + 8 for n, _ in items) + header = [b"MTHD", struct.pack(" int: + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--esrgan", type=Path, default=os.environ.get("MT_ESRGAN"), required="MT_ESRGAN" not in os.environ, + help="directory with realesrgan-ncnn-vulkan and models/ (or MT_ESRGAN)") + ap.add_argument("--client", type=Path, default=REPO / "build/zh-locale/Client") + ap.add_argument("--locale", default="zh") + ap.add_argument("--out", type=Path, help="default: /hd_ui.pak") + ap.add_argument("--work", type=Path, help="keep intermediates here (default: a temporary directory)") + ap.add_argument("--reuse", action="store_true", + help="keep the upscaled images already in --work and only redo the 2x reduction and the pak") + ap.add_argument("--limit", type=int, default=0, help="only the first N images (a quick look)") + args = ap.parse_args() + out = args.out or args.client / "hd_ui.pak" + binary = args.esrgan / "realesrgan-ncnn-vulkan" + if not binary.exists(): + sys.exit(f"{binary} not found") + + keys, seen = pack_entries(args.client) + names = sorted(n for n in seen if wanted(n, args.locale)) + if args.limit: + names = names[:args.limit] + print(f"{len(names)} UI images in {args.client}/pack") + + if args.reuse and not (args.work and (args.work / "manifest.json").exists()): + sys.exit("--reuse needs a --work directory from an earlier run") + work = args.work or Path(tempfile.mkdtemp(prefix="hd_ui_")) + try: + if args.reuse: + metas = json.loads((work / "manifest.json").read_text()) + else: + for sub in ("in", "out"): + shutil.rmtree(work / sub, ignore_errors=True) + (work / sub).mkdir(parents=True) + jobs = [(i, n, read_entry(keys, *seen[n]), work) for i, n in enumerate(names)] + with ProcessPoolExecutor() as pool: + metas = [m for m in pool.map(prepare, jobs, chunksize=16) if m] + print(f"prepared {len(metas)} (skipped {len(names) - len(metas)} undecodable or oversized)") + (work / "manifest.json").write_text(json.dumps(metas)) + subprocess.run([str(binary), "-i", str(work / "in"), "-o", str(work / "out"), "-n", MODEL, "-s", "4", + "-f", "png"], cwd=args.esrgan, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + with ProcessPoolExecutor() as pool: + items = [r for r in pool.map(finish, [(m, work) for m in metas], chunksize=16) if r] + write_pak(out, items) + print(f"wrote {out}: {len(items)} images, {out.stat().st_size / 2**20:.1f} MB") + finally: + if not args.work: + shutil.rmtree(work, ignore_errors=True) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/host/texture_decode.cpp b/src/host/texture_decode.cpp index f509d077..72e53050 100644 --- a/src/host/texture_decode.cpp +++ b/src/host/texture_decode.cpp @@ -7,8 +7,13 @@ #include "stb_image.h" #include +#include +#include #include +#include #include +#include +#include namespace mt_host { @@ -124,9 +129,11 @@ mtimage::Image decode_texture_bytes(const std::uint8_t* data, std::size_t size) } #ifdef MT_NATIVE_HAS_LIVE_CLIENT -bool read_live_pack_texture(const std::string& vpath, std::vector& bytes) { - if (vpath.empty() || !mtpack40250::ready()) - return false; +namespace { + +// The spellings a 40250 texture name may be registered under in the packs (CEterPackManager::Get +// accepts "d:/ymir work/..." and "ymir work/..." alike, in any case). +std::vector pack_name_candidates(const std::string& vpath) { std::string norm = vpath; for (char& ch : norm) if (ch == '\\') ch = '/'; @@ -141,14 +148,81 @@ bool read_live_pack_texture(const std::string& vpath, std::vector& ch = static_cast(ch - 'A' + 'a'); return s; }; - for (const std::string& candidate : { - norm, stripped, "d:/" + stripped, - lower(norm), lower(stripped), lower("d:/" + stripped)}) { + return {norm, stripped, "d:/" + stripped, lower(norm), lower(stripped), lower("d:/" + stripped)}; +} + +// hd_ui.pak (scripts/build_hd_ui.py): "MTHD", u32 version 1, u32 count, count x {u16 name length, +// name, u32 offset, u32 size}, then PNG payloads. Names are lowercase pack paths. +struct HdUiPak { + std::FILE* file = nullptr; + std::unordered_map> entries; +}; + +std::uint32_t read_u32(const std::uint8_t* p) { + return std::uint32_t(p[0]) | (std::uint32_t(p[1]) << 8) | (std::uint32_t(p[2]) << 16) | (std::uint32_t(p[3]) << 24); +} + +HdUiPak* open_hd_ui_pak() { + if (const char* env = std::getenv("MT_HD_UI"); env && std::strcmp(env, "0") == 0) + return nullptr; + const char* env_path = std::getenv("MT_HD_UI_PAK"); + std::FILE* f = std::fopen(env_path && *env_path ? env_path : "hd_ui.pak", "rb"); + if (!f) + return nullptr; + auto* pak = new HdUiPak; + pak->file = f; + std::uint8_t header[12]; + if (std::fread(header, 1, 12, f) != 12 || std::memcmp(header, "MTHD", 4) != 0 || read_u32(header + 4) != 1) { + std::fprintf(stderr, "[hd_ui] hd_ui.pak: bad header, ignored\n"); + std::fclose(f); + delete pak; + return nullptr; + } + const std::uint32_t count = read_u32(header + 8); + pak->entries.reserve(count); + for (std::uint32_t i = 0; i < count; ++i) { + std::uint8_t len[2]; + if (std::fread(len, 1, 2, f) != 2) + break; + std::string name(std::size_t(len[0] | (len[1] << 8)), '\0'); + std::uint8_t pos[8]; + if (std::fread(name.data(), 1, name.size(), f) != name.size() || std::fread(pos, 1, 8, f) != 8) + break; + pak->entries.emplace(std::move(name), std::make_pair(read_u32(pos), read_u32(pos + 4))); + } + std::fprintf(stderr, "[hd_ui] hd_ui.pak: %zu UI textures at 2x\n", pak->entries.size()); + return pak; +} + +} // namespace + +bool read_live_pack_texture(const std::string& vpath, std::vector& bytes) { + if (vpath.empty() || !mtpack40250::ready()) + return false; + for (const std::string& candidate : pack_name_candidates(vpath)) { if (mtpack40250::read(candidate, bytes) && !bytes.empty()) return true; } return false; } + +bool read_hd_ui_texture(const std::string& vpath, std::vector& bytes) { + // Opened on first use, after live_client has made the client directory the working directory. + static HdUiPak* pak = open_hd_ui_pak(); + static std::mutex mutex; + if (!pak || vpath.empty()) + return false; + for (const std::string& candidate : pack_name_candidates(vpath)) { + auto it = pak->entries.find(candidate); + if (it == pak->entries.end()) + continue; + std::lock_guard lock(mutex); + bytes.resize(it->second.second); + return std::fseek(pak->file, long(it->second.first), SEEK_SET) == 0 && + std::fread(bytes.data(), 1, bytes.size(), pak->file) == bytes.size(); + } + return false; +} #endif } // namespace mt_host diff --git a/src/host/texture_decode.h b/src/host/texture_decode.h index 1f5cc984..8de871d0 100644 --- a/src/host/texture_decode.h +++ b/src/host/texture_decode.h @@ -13,6 +13,9 @@ mtimage::Image decode_tga(const std::uint8_t* data, std::size_t size); mtimage::Image decode_texture_bytes(const std::uint8_t* data, std::size_t size); #ifdef MT_NATIVE_HAS_LIVE_CLIENT bool read_live_pack_texture(const std::string& vpath, std::vector& bytes); +// The 2x replacement of a UI texture from hd_ui.pak in the client directory (MT_HD_UI_PAK overrides +// the path, MT_HD_UI=0 disables it); false when there is none. +bool read_hd_ui_texture(const std::string& vpath, std::vector& bytes); #endif } // namespace mt_host diff --git a/src/host/vk_textures.cpp b/src/host/vk_textures.cpp index aad9b124..755fa6f7 100644 --- a/src/host/vk_textures.cpp +++ b/src/host/vk_textures.cpp @@ -105,8 +105,14 @@ VulkanWindow::GpuTexture& VulkanWindow::get_gpu_texture( decoded = decode_texture_bytes(mtra.data(), mtra.size()); } } else { + // PORT: the phone magnifies the 600-high 40250 UI about 1.8x, so the 1x window art and icons + // look soft. A UI texture found in hd_ui.pak (scripts/build_hd_ui.py) is uploaded at 2x in + // place of the pack file. The engine keeps the 1x size from its own decode for layout and + // quads, and the UVs are normalized, so only the texel density changes. std::vector pack_bytes; - if (read_live_pack_texture(texture_name, pack_bytes)) + if (read_hd_ui_texture(texture_name, pack_bytes)) + decoded = decode_texture_bytes(pack_bytes.data(), pack_bytes.size()); + if (!decoded.ok() && read_live_pack_texture(texture_name, pack_bytes)) decoded = decode_texture_bytes(pack_bytes.data(), pack_bytes.size()); } }