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:
co-authored by
Claude Opus 5.5
parent
c5b709d38d
commit
01cc252a1d
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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())
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user