Host: 2x UI textures from hd_ui.pak (AI-upscaled 40250 UI art)

The phone magnifies the 600-high 40250 UI about 1.8x, so the 1x window art
and icons look soft next to the oversampled text. scripts/build_hd_ui.py
upscales every UI image the zh client loads (d:/ymir work/ui/, icon/,
locale/zh/) with realesrgan-x4plus, reduces it to 2x and back-projects the
residual so the board grain the network denoises away comes back; the host
uploads a texture found in hd_ui.pak in place of the pack file. Layout,
sizes and UVs stay the engine's 1x ones. MT_HD_UI=0 disables it; the pak
(135 MB, Ymir art) is built into the client dir and synced to the phone,
never committed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
shenlei
2026-09-30 20:04:46 +09:00
co-authored by Claude Opus 5.5
parent c5b709d38d
commit 01cc252a1d
6 changed files with 354 additions and 9 deletions
+3 -1
View File
@@ -88,7 +88,9 @@ def local_files(client, locale):
path = Path(dirpath) / name path = Path(dirpath) / name
files[path.relative_to(client).as_posix()] = path files[path.relative_to(client).as_posix()] = path
for path in client.iterdir(): 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 files[path.name] = path
if locale: if locale:
cfg = client / f"locale_{locale}.cfg" cfg = client / f"locale_{locale}.cfg"
+9 -1
View File
@@ -4130,7 +4130,15 @@
"focus_pause_exit_cleanup": "PARTIAL" "focus_pause_exit_cleanup": "PARTIAL"
}, },
"platform_adaptations": [ "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/<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": { "evidence": {
"contract": "audit/contracts/ui.input-camera-mobile.md", "contract": "audit/contracts/ui.input-camera-mobile.md",
+252
View File
@@ -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/<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("<II", VERSION, len(items))]
entries = []
for name, data in items:
raw = name.encode("latin-1")
entries.append(struct.pack("<H", len(raw)) + raw + struct.pack("<II", offset, len(data)))
offset += len(data)
tmp = out.with_suffix(".tmp")
with open(tmp, "wb") as f:
f.write(b"".join(header) + b"".join(entries))
for _, data in items:
f.write(data)
os.replace(tmp, out)
def main() -> 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: <client>/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())
+80 -6
View File
@@ -7,8 +7,13 @@
#include "stb_image.h" #include "stb_image.h"
#include <algorithm> #include <algorithm>
#include <cstdio>
#include <cstdlib>
#include <cstring> #include <cstring>
#include <mutex>
#include <stdexcept> #include <stdexcept>
#include <unordered_map>
#include <utility>
namespace mt_host { 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 #ifdef MT_NATIVE_HAS_LIVE_CLIENT
bool read_live_pack_texture(const std::string& vpath, std::vector<std::uint8_t>& bytes) { namespace {
if (vpath.empty() || !mtpack40250::ready())
return false; // 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<std::string> pack_name_candidates(const std::string& vpath) {
std::string norm = vpath; std::string norm = vpath;
for (char& ch : norm) for (char& ch : norm)
if (ch == '\\') ch = '/'; if (ch == '\\') ch = '/';
@@ -141,14 +148,81 @@ bool read_live_pack_texture(const std::string& vpath, std::vector<std::uint8_t>&
ch = static_cast<char>(ch - 'A' + 'a'); ch = static_cast<char>(ch - 'A' + 'a');
return s; return s;
}; };
for (const std::string& candidate : { return {norm, stripped, "d:/" + stripped, lower(norm), lower(stripped), lower("d:/" + stripped)};
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<std::string, std::pair<std::uint32_t, std::uint32_t>> 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<std::uint8_t>& bytes) {
if (vpath.empty() || !mtpack40250::ready())
return false;
for (const std::string& candidate : pack_name_candidates(vpath)) {
if (mtpack40250::read(candidate, bytes) && !bytes.empty()) if (mtpack40250::read(candidate, bytes) && !bytes.empty())
return true; return true;
} }
return false; return false;
} }
bool read_hd_ui_texture(const std::string& vpath, std::vector<std::uint8_t>& 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<std::mutex> 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 #endif
} // namespace mt_host } // namespace mt_host
+3
View File
@@ -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); mtimage::Image decode_texture_bytes(const std::uint8_t* data, std::size_t size);
#ifdef MT_NATIVE_HAS_LIVE_CLIENT #ifdef MT_NATIVE_HAS_LIVE_CLIENT
bool read_live_pack_texture(const std::string& vpath, std::vector<std::uint8_t>& bytes); bool read_live_pack_texture(const std::string& vpath, std::vector<std::uint8_t>& 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<std::uint8_t>& bytes);
#endif #endif
} // namespace mt_host } // namespace mt_host
+7 -1
View File
@@ -105,8 +105,14 @@ VulkanWindow::GpuTexture& VulkanWindow::get_gpu_texture(
decoded = decode_texture_bytes(mtra.data(), mtra.size()); decoded = decode_texture_bytes(mtra.data(), mtra.size());
} }
} else { } 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<std::uint8_t> pack_bytes; std::vector<std::uint8_t> 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()); decoded = decode_texture_bytes(pack_bytes.data(), pack_bytes.size());
} }
} }