From d1f8d3ddddd9ea18256a356a573acc26dd86bf16 Mon Sep 17 00:00:00 2001 From: shen <> Date: Wed, 30 Sep 2026 16:13:31 -0700 Subject: [PATCH] Improve macOS Retina UI clarity and bundle HD assets --- audit/evidence/2026-09-30-mac-ui-clarity.md | 35 +++++ audit/history.jsonl | 2 + audit/manifest.json | 9 +- .../port-map/EterLib/GrpFontTexture.cpp.json | 16 ++- .../EterLib/GrpImageInstance.cpp.json | 2 +- docs/MAC-UI-CLARITY.md | 32 +++++ scripts/build_hd_ui.py | 4 +- scripts/package_mac_app.sh | 120 +++++++++++------- scripts/verify_hd_ui.py | 41 ++++++ src/host/hd_ui_archive.h | 64 ++++++++++ src/host/live_client.cpp | 4 +- src/host/sdl_window.cpp | 11 +- src/host/texture_decode.cpp | 77 ++++------- src/host/texture_decode.h | 4 +- src/host/ui_batches.cpp | 12 +- src/host/ui_quality.h | 28 ++++ src/host/vk_resources.cpp | 3 + src/host/vk_textures.cpp | 19 ++- src/host/vulkan_window.cpp | 1 + src/host/vulkan_window.h | 8 +- src/platform/EterLib/GrpFontTexture.cpp | 59 +++++++-- src/platform/EterLib/UIRenderCommands.h | 4 +- src/port/CMakeLists.txt | 2 + src/port/EterLib/GrpFontTexture.h | 2 + tests/host/ui_quality_test.cpp | 41 ++++++ tests/port/port_text_test.cpp | 39 ++++++ 26 files changed, 505 insertions(+), 134 deletions(-) create mode 100644 audit/evidence/2026-09-30-mac-ui-clarity.md create mode 100644 docs/MAC-UI-CLARITY.md create mode 100644 scripts/verify_hd_ui.py create mode 100644 src/host/hd_ui_archive.h create mode 100644 src/host/ui_quality.h create mode 100644 tests/host/ui_quality_test.cpp diff --git a/audit/evidence/2026-09-30-mac-ui-clarity.md b/audit/evidence/2026-09-30-mac-ui-clarity.md new file mode 100644 index 00000000..a46f6acd --- /dev/null +++ b/audit/evidence/2026-09-30-mac-ui-clarity.md @@ -0,0 +1,35 @@ +# macOS UI clarity evidence + +The 40250 UI layout, item data, UVs and hit testing remain unchanged. Host rendering +snaps axis-aligned image origins to physical pixels and selects nearest sampling only +when sampled texels match physical pixels. Scaled images retain linear sampling. +Font atlases rebuild between script frames on display-density changes, preserving +cached character-info node addresses and logical metrics. HiDPI pages use 8-bit alpha. + +The HD pipeline completed all 2406 images without skips (1898 icon, 384 UI and 124 +Chinese locale images). The resulting 135.1 MiB archive is validated and embedded in +`dist/Metin2.app/Contents/Resources/hd_ui.pak`. Generated game assets remain private +build artifacts. The package includes SDL3, Vulkan loader and MoltenVK, uses bundled +rpaths and passes `codesign --verify --deep --strict` with local ad-hoc signatures. + +Validation on 2026-09-30: + +- Full native CTest: 29 passed, 2 skipped (codepage data absent and no live server). +- `host.ui_quality`: physical snapping, sampling decisions, font-density rounding, + valid archive lookup and malformed/truncated archive rejection. +- `port.text`: transitions 1→2→3→1→2 preserve character pointers, logical text + dimensions and advances; HiDPI page sizes and non-4-bit alpha are checked. + Linking this test with the previous font implementation fails 9 new assertions. +- Desktop and mobile-mode login flow pass. Native release renderer and Android debug + APK builds pass. +- Packaged launcher run from `/` loads 2406 bundled textures and reports 2× glyphs + at density 2.000. A two-frame local-server inventory fixture inserts weapon, + armor, jewelry and consumables, then opens and refreshes the inventory. +- Before and after images are `build/mac-ui-items-before.png` and + `build/mac-ui-items-after.png`, both 2560×1600. Visual inspection confirms sharper + inventory icon contours, slot borders and bottom toolbar detail. + +This is macOS clarity evidence, not Windows pixel parity. Actual movement between +physical monitors of different density remains unverified. Original low-resolution +art still limits recoverable detail; `MT_HD_UI=0` retains an original-art comparison. +The HUD aggregate contract remains PARTIAL. diff --git a/audit/history.jsonl b/audit/history.jsonl index d3b78354..dae8f743 100644 --- a/audit/history.jsonl +++ b/audit/history.jsonl @@ -644,3 +644,5 @@ {"date": "2026-09-30", "unit": "ScriptLib/Resource.cpp", "action": "port", "evidence": "verbatim 40250 (was a rewrite that also registered .png); ctest 29/29, screenshot unchanged"} {"date": "2026-09-30", "unit": "EterGrnLib/ModelInstanceUpdate.cpp", "action": "fix-platform-adapter", "evidence": "2026-09-30: native Granny adapter now selects the model-named track group, preserving all group metadata and limiting bone binding to that group. Real packed bear walk/run (auxiliary Hedra before Bip01) fail with the previous runtime and pass in tests/port/port_granny_root_motion_test.cpp. See audit/evidence/2026-09-30-bear-root-motion.md."} {"date": "2026-09-30", "unit": "EffectLib/ParticleSystemInstance.cpp", "action": "fix-platform-adapter", "evidence": "XRGB destination alpha semantics fixed in Vulkan pipeline; focused mapping + GPU pixel regression; original particle logic unchanged; desktop/mobile login regressions and Android build pass."} +{"date": "2026-09-30", "unit": "EterLib/GrpFontTexture.cpp", "action": "improve-platform-rendering", "evidence": "Retina UI clarity: dynamic font atlas rebuild preserving cached pointers and logical metrics, 8-bit HiDPI glyph alpha, physical pixel alignment and 1:1 sampling; 2406 bundled HD textures. Full native suite 29 passed, 2 skipped, macOS packaged inventory screenshots and strict signature verification. See audit/evidence/2026-09-30-mac-ui-clarity.md."} +{"date": "2026-09-30", "unit": "EterLib/GrpImageInstance.cpp", "action": "improve-platform-rendering", "evidence": "Retina UI clarity: dynamic font atlas rebuild preserving cached pointers and logical metrics, 8-bit HiDPI glyph alpha, physical pixel alignment and 1:1 sampling; 2406 bundled HD textures. Full native suite 29 passed, 2 skipped, macOS packaged inventory screenshots and strict signature verification. See audit/evidence/2026-09-30-mac-ui-clarity.md."} diff --git a/audit/manifest.json b/audit/manifest.json index b89f91bf..eb5b6d3a 100644 --- a/audit/manifest.json +++ b/audit/manifest.json @@ -4261,9 +4261,14 @@ "project/name_show_test.gd", "project/char_status_ui_test.gd", "project/test_taskbar_quickslot_parity.gd", - "project/taskbar_dock_test.gd" + "project/taskbar_dock_test.gd", + "tests/host/ui_quality_test.cpp", + "tests/port/port_text_test.cpp" ], - "last_test_result": "PARTIAL" + "last_test_result": "PARTIAL", + "commands": [ + "2026-09-30: native UI HiDPI font refresh and sampling checks PASS; previous font runtime fails 9 new assertions. Full native CTest: 29 pass, codepage and live login skipped. Actual mac app HD archive/launch/screenshot checks recorded in audit/evidence/2026-09-30-mac-ui-clarity.md. Aggregate HUD parity remains PARTIAL." + ] }, "remaining": [ "在正式 GameScene 创建并注入唯一 TargetUI,移除/隐藏简化目标条并补生产链回归", diff --git a/audit/port-map/EterLib/GrpFontTexture.cpp.json b/audit/port-map/EterLib/GrpFontTexture.cpp.json index a3e421b2..a2db4de3 100644 --- a/audit/port-map/EterLib/GrpFontTexture.cpp.json +++ b/audit/port-map/EterLib/GrpFontTexture.cpp.json @@ -48,7 +48,7 @@ "test": [ "tests/port/port_text_test.cpp" ], - "note": "40250: deletes each HFONT of the font map. Port: also deletes its oversampled twin. Invariant: 40250 teardown; no leaked GDI fonts." + "note": "40250 deletes fonts/pages. PORT also unregisters the live raster atlas and deletes oversampled twins; rebuilding preserves cached character-info node addresses. See docs/MAC-UI-CLARITY.md and audit/evidence/2026-09-30-mac-ui-clarity.md." }, "CGraphicFontTexture::CreateDeviceObjects": { "status": "PORTED", @@ -76,7 +76,7 @@ "test": [ "tests/port/port_text_test.cpp" ], - "note": "40250: the page DIB is width x height. Port: width*s x height*s for oversample s (1 by default = 40250). Invariant: logical page size, metrics and UVs unchanged." + "note": "40250 retains logical font metrics and atlas dimensions. PORT allocates 1–4x raster pages and registers successful atlases for monitor-density refresh; port.text verifies unchanged width/height/advance and stable glyph pointers. See docs/MAC-UI-CLARITY.md and audit/evidence/2026-09-30-mac-ui-clarity.md." }, "CGraphicFontTexture::GetFont": { "status": "ADAPTED", @@ -89,22 +89,24 @@ "note": "40250: CreateFontIndirect per code page. Port: with s > 1 also creates an s-times-height twin for drawing. Invariant: the measuring font is the 40250 one." }, "CGraphicFontTexture::AppendTexture": { - "status": "PORTED", + "status": "ADAPTED", "impl": [ "src/platform/EterLib/GrpFontTexture.cpp:CGraphicFontTexture::AppendTexture" ], "test": [ "tests/port/port_text_test.cpp" - ] + ], + "note": "40250 creates A4R4G4B4 pages. PORT uses A8R8G8B8 for HiDPI pages to retain 256 coverage levels; 1x still uses A4R4G4B4. tests/port/port_text_test.cpp checks alpha finer than 4-bit. See docs/MAC-UI-CLARITY.md and audit/evidence/2026-09-30-mac-ui-clarity.md." }, "CGraphicFontTexture::UpdateTexture": { - "status": "PORTED", + "status": "ADAPTED", "impl": [ "src/platform/EterLib/GrpFontTexture.cpp:CGraphicFontTexture::UpdateTexture" ], "test": [ "tests/port/port_text_test.cpp" - ] + ], + "note": "40250 copies DIB cells into 16-bit pages. PORT copies 32-bit ARGB rows for HiDPI pages; 1x retains the original conversion. Tests check actual published memory textures. See docs/MAC-UI-CLARITY.md and audit/evidence/2026-09-30-mac-ui-clarity.md." }, "CGraphicFontTexture::GetCharacterInfomation": { "status": "PORTED", @@ -123,7 +125,7 @@ "test": [ "tests/port/port_text_test.cpp" ], - "note": "40250: TextOutW with the 1x font, bilevel threshold into A4R4G4B4. Port: with s > 1 draws the glyph with the twin into the s-scaled cell keeping coverage alpha; s == 1 is the 40250 path. Invariant: width/height/advance and packing are the logical 1x values." + "note": "40250 measures the logical font and caches metrics/UVs. PORT draws oversampled white glyphs with 8-bit coverage and preserves logical metrics. Monitor changes rebuild map values in place without invalidating text-instance pointers; port.text tests 1→2→3→1→2 scales. See docs/MAC-UI-CLARITY.md and audit/evidence/2026-09-30-mac-ui-clarity.md." }, "CGraphicFontTexture::CheckTextureIndex": { "status": "PORTED", diff --git a/audit/port-map/EterLib/GrpImageInstance.cpp.json b/audit/port-map/EterLib/GrpImageInstance.cpp.json index 1ed50fe1..32467301 100644 --- a/audit/port-map/EterLib/GrpImageInstance.cpp.json +++ b/audit/port-map/EterLib/GrpImageInstance.cpp.json @@ -57,7 +57,7 @@ "test": [ "tests/port/port_grp_resource_test.cpp" ], - "note": "40250: SetPDTStream + DEFAULT_IB_FILL_RECT + stage-0 texture, DrawIndexedPrimitive. Port: the same four TPDTVertex corners/UVs/diffuse go to UIRenderAddImage. Invariant: quad = image rect at m_v2Position-0.5, UVs = rect/texture size." + "note": "40250: SetPDTStream + DEFAULT_IB_FILL_RECT + stage-0 texture, DrawIndexedPrimitive. Port: the same four TPDTVertex corners/UVs/diffuse go to UIRenderAddImage. Invariant: quad = image rect at m_v2Position-0.5, UVs = rect/texture size. 2026-09-30: native host snaps axis-aligned quad origins by at most half a physical pixel and uses point sampling only at 1:1 texel/pixel density; source layout/UVs and rotated quads unchanged. host.ui_quality + mac UI screenshots. See docs/MAC-UI-CLARITY.md." }, "CGraphicImageInstance::GetTextureReference": { "status": "PORTED", diff --git a/docs/MAC-UI-CLARITY.md b/docs/MAC-UI-CLARITY.md new file mode 100644 index 00000000..6ec33c65 --- /dev/null +++ b/docs/MAC-UI-CLARITY.md @@ -0,0 +1,32 @@ +# macOS UI 清晰度 + +原生客户端按 macOS 窗口的逻辑尺寸布局,在 Retina 的物理像素尺寸上绘制 UI。 +本轮保持 40250 的窗口尺寸、格子、图标位置和点击逻辑,通过更细的纹理和字形改善显示。 + +- `hd_ui.pak` 提供 2× UI 图片;原版 EPK 继续负责逻辑尺寸和 UV。高清纹理不改变道具数据。 +- 加载顺序为显式 `MT_HD_UI_PAK`、应用资源目录、客户端工作目录。缺失或无效时记录一次日志并使用原图。 +- mac 打包必须包含通过索引/PNG 检查的高清包,同时包含 SDL3、Vulkan loader 和 MoltenVK。 +- 轴对齐图片的起点对齐物理像素;宽高、UV 和旋转保持不变。一比一显示使用 point 采样,其余缩放保留 linear。 +- 字体按物理像素/逻辑像素比向上取整生成 1–4× 字形。Retina 字形保留 8 位覆盖率,1× 路径保持原版阈值。 +- 窗口尺寸、显示器和显示倍率变化时,在脚本帧之间重建字体页。字符信息节点地址和原字号/字距保留。 + +构建高清包使用现有 Real-ESRGAN 管线:先放大 4×,再缩到 2×,恢复原图低频残差与纹理, +对透明边缘填色和可平铺背景补边。模型不会恢复原图中不存在的真实细节,因此保留原版素材开关便于对照。 +工具来源:[Real-ESRGAN 官方发布](https://github.com/xinntao/Real-ESRGAN/releases/tag/v0.2.5.0)。 + +```sh +python3 scripts/build_hd_ui.py --esrgan build/tools/realesrgan \ + --client build/zh-locale/Client --locale zh --out build/hd-ui/hd_ui.pak +python3 scripts/verify_hd_ui.py build/hd-ui/hd_ui.pak +cmake --build build/release --target mt_native_render -j8 +scripts/package_mac_app.sh build/release build/zh-locale/Client dist/Metin2.app build/hd-ui/hd_ui.pak +``` + +临时关闭高清图片:`MT_HD_UI=0 dist/Metin2.app/Contents/MacOS/Metin2`。 +字体强制倍率:`MT_FONT_OVERSAMPLE=1`(用于原版对照)或 `2` 等;默认自动适应显示器。 +启动日志应显示 `[hd_ui] loaded ... textures` 和 `UI glyph raster: 2x ...`(普通 Retina 屏)。 + +验证:`host.ui_quality` 检查物理像素对齐、采样选择和无效高清索引回退;`port.text` 在 +1→2→3→1→2× 中检查字符指针、字号、字距、页面尺寸和 8 位透明度。旧字体实现不能通过这些新增检查。 +实际应用用本地测试服务器打开背包、填入武器/防具/消耗品并截取原尺寸画面,检查背包与工具栏; +截图仅用于清晰度验收,不是 Windows 像素完全等价声明。应用为本地 ad-hoc 签名构建。 diff --git a/scripts/build_hd_ui.py b/scripts/build_hd_ui.py index 85b9f248..639d9186 100644 --- a/scripts/build_hd_ui.py +++ b/scripts/build_hd_ui.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Builds hd_ui.pak: the 40250 UI textures at twice their resolution, for src/host/hd_ui.cpp. +"""Builds hd_ui.pak: the 40250 UI textures at twice their resolution, for the native texture loader. 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 @@ -240,6 +240,8 @@ def main() -> int: "-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] + if len(items) != len(metas): + sys.exit(f"incomplete upscale: {len(items)} of {len(metas)} images; archive was not replaced") write_pak(out, items) print(f"wrote {out}: {len(items)} images, {out.stat().st_size / 2**20:.1f} MB") finally: diff --git a/scripts/package_mac_app.sh b/scripts/package_mac_app.sh index 3dd7ea60..d01c4478 100755 --- a/scripts/package_mac_app.sh +++ b/scripts/package_mac_app.sh @@ -1,52 +1,84 @@ #!/bin/zsh -# Wraps a built mt_native_render into a double-clickable Metin2.app with the branding icon: -# scripts/package_mac_app.sh [BUILD_DIR=build/release] [CLIENT_DIR=/Client] [OUT=BUILD_DIR/Metin2.app] -# The bundle is for this machine: the executable still links Homebrew's SDL3 / Vulkan loader / MoltenVK, and -# the launcher runs the live client from CLIENT_DIR (an absolute path baked in; MT_CLIENT_DIR overrides it -# at launch). The shaders and python27.zip go to Contents/Resources, which SDL_GetBasePath returns in a bundle. +# Portable local macOS bundle with private Client assets and the validated 2x UI archive. +# scripts/package_mac_app.sh [BUILD_DIR] [CLIENT_DIR] [OUT] [HD_UI_PAK] set -euo pipefail repo=${0:A:h:h} build=${1:-$repo/build/release} -client=${2:-$repo/Client} -out=${3:-$build/Metin2.app} +client=${2:-$repo/build/zh-locale/Client} +out=${3:-$repo/dist/Metin2.app} +pak=${4:-${MT_HD_UI_PAK:-$repo/build/hd-ui/hd_ui.pak}} bin=$build/src/host/mt_native_render -[[ -x $bin ]] || { echo "missing $bin (build the mt_native_render target first)" >&2; exit 1; } -client=${client:A} -version=$(git -C $repo describe --always --dirty 2>/dev/null || echo dev) - -rm -rf $out -mkdir -p $out/Contents/MacOS $out/Contents/Resources -cp $bin $out/Contents/MacOS/ -cp $build/src/host/native.vert.spv $build/src/host/native.frag.spv $build/src/host/python27.zip $out/Contents/Resources/ -cp $repo/branding/icon/macos/AppIcon.icns $out/Contents/Resources/ - -cat > $out/Contents/MacOS/Metin2 <&2; exit 1; } +[[ -d "$client/pack" ]] || { echo "missing Client/pack: $client" >&2; exit 1; } +[[ -f "$pak" ]] || { echo "missing HD UI archive: $pak (run scripts/build_hd_ui.py first)" >&2; exit 1; } +python3 "$repo/scripts/verify_hd_ui.py" "$pak" +version=$(git -C "$repo" describe --always --dirty 2>/dev/null || echo dev) +mkdir -p "${out:A:h}" +stage=$(mktemp -d "${out:A}.tmp.XXXXXX") +trap 'rm -rf "$stage"' EXIT +mkdir -p "$stage/Contents/MacOS" "$stage/Contents/Resources" "$stage/Contents/Frameworks" +cp "$bin" "$stage/Contents/MacOS/mt_native_render" +cp "$build/src/host/native.vert.spv" "$build/src/host/native.frag.spv" "$build/src/host/python27.zip" "$stage/Contents/Resources/" +cp "$repo/branding/icon/macos/AppIcon.icns" "$stage/Contents/Resources/" +cp "$pak" "$stage/Contents/Resources/hd_ui.pak" +# Clone private assets on APFS; fall back to an ordinary copy on other filesystems. +cp -cR "$client" "$stage/Contents/Resources/Client" 2>/dev/null || ditto "$client" "$stage/Contents/Resources/Client" +for lib in libvulkan.1.dylib libSDL3.0.dylib; do + dependency=$(otool -L "$bin" | awk -v name="$lib" '$1 ~ name "$" {print $1; exit}') + [[ -f "$dependency" ]] || { echo "cannot resolve $lib: $dependency" >&2; exit 1; } + cp "$dependency" "$stage/Contents/Frameworks/$lib" + install_name_tool -change "$dependency" "@rpath/$lib" "$stage/Contents/MacOS/mt_native_render" + install_name_tool -id "@rpath/$lib" "$stage/Contents/Frameworks/$lib" +done +for rpath in ${(f)"$(otool -l "$bin" | awk '/LC_RPATH/ {getline; getline; print $2}')"}; do + install_name_tool -delete_rpath "$rpath" "$stage/Contents/MacOS/mt_native_render" +done +install_name_tool -add_rpath '@executable_path/../Frameworks' "$stage/Contents/MacOS/mt_native_render" +molten=${MT_MOLTENVK:-/opt/homebrew/opt/molten-vk/lib/libMoltenVK.dylib} +[[ -f "$molten" ]] || molten=/usr/local/opt/molten-vk/lib/libMoltenVK.dylib +[[ -f "$molten" ]] || { echo "missing MoltenVK (set MT_MOLTENVK)" >&2; exit 1; } +cp "$molten" "$stage/Contents/Frameworks/libMoltenVK.dylib" +cat > "$stage/Contents/Resources/MoltenVK_icd.json" <<'JSON' +{"file_format_version":"1.0.0","ICD":{"library_path":"../Frameworks/libMoltenVK.dylib","api_version":"1.2.0","is_portability_driver":true}} +JSON +cat > "$stage/Contents/MacOS/Metin2" <<'SH' #!/bin/sh -# Finder starts apps with / as the working directory; the client reads its packs relative to its directory. -cd "\${MT_CLIENT_DIR:-$client}" || exit 1 -exec "\$(dirname "\$0")/mt_native_render" --live-client . "\$@" -EOF -chmod +x $out/Contents/MacOS/Metin2 - -cat > $out/Contents/Info.plist < "$stage/Contents/Info.plist" < - - - CFBundleNameMetin2 - CFBundleDisplayNameMetin2 - CFBundleIdentifierorg.metin2port.client - CFBundleExecutableMetin2 - CFBundleIconFileAppIcon - CFBundlePackageTypeAPPL - CFBundleShortVersionString0.1 - CFBundleVersion$version - LSMinimumSystemVersion12.0 - LSApplicationCategoryTypepublic.app-category.role-playing-games - NSHighResolutionCapable - - -EOF -# Ad-hoc signature so Gatekeeper on Apple silicon runs the copied binary. -codesign --force --deep --sign - $out >/dev/null 2>&1 || true -echo $out + +CFBundleNameMetin2 +CFBundleDisplayNameMetin2 +CFBundleIdentifierorg.metin2port.client +CFBundleExecutableMetin2 +CFBundleIconFileAppIcon +CFBundlePackageTypeAPPL +CFBundleShortVersionString0.1 +CFBundleVersion$version +LSMinimumSystemVersion12.0 +LSApplicationCategoryTypepublic.app-category.role-playing-games +NSHighResolutionCapable + +PLIST +for library in "$stage"/Contents/Frameworks/*.dylib; do codesign --force --sign - "$library"; done +codesign --force --sign - "$stage/Contents/MacOS/mt_native_render" +codesign --force --deep --sign - "$stage" +codesign --verify --deep --strict "$stage" +# Publish only after validating the complete replacement bundle. +rm -rf "$out" +mv "$stage" "$out" +trap - EXIT +echo "$out" diff --git a/scripts/verify_hd_ui.py b/scripts/verify_hd_ui.py new file mode 100644 index 00000000..e8ed0549 --- /dev/null +++ b/scripts/verify_hd_ui.py @@ -0,0 +1,41 @@ +#!/usr/bin/env python3 +"""Validate a complete MTHD archive before including private HD art in a mac bundle.""" +import pathlib +import struct +import sys + + +def verify(path): + data = pathlib.Path(path).read_bytes() + if len(data) < 12 or data[:4] != b'MTHD': + raise ValueError('invalid MTHD header') + version, count = struct.unpack_from(' len(data): raise ValueError('truncated index') + size, = struct.unpack_from(' len(data): raise ValueError('invalid entry') + name = data[index:index+size].decode('latin1') + index += size + offset, length = struct.unpack_from(' len(data): raise ValueError('invalid payload range') + names.add(name) + entries.append((name, offset, length)) + for name, offset, length in entries: + image = data[offset:offset+length] + if offset < index or length < 33 or image[:8] != b'\x89PNG\r\n\x1a\n' or image[12:16] != b'IHDR': + raise ValueError('invalid PNG: ' + name) + w, h = struct.unpack_from('>II', image, 16) + if not 0 < w <= 4096 or not 0 < h <= 4096: raise ValueError('invalid dimensions: ' + name) + return count + + +if __name__ == '__main__': + try: + print(f'HD UI archive valid: {verify(sys.argv[1])} textures') + except (ValueError, OSError, IndexError, struct.error) as error: + sys.exit(str(error)) diff --git a/src/host/hd_ui_archive.h b/src/host/hd_ui_archive.h new file mode 100644 index 00000000..fc392d3e --- /dev/null +++ b/src/host/hd_ui_archive.h @@ -0,0 +1,64 @@ +#pragma once +#include +#include +#include +#include +#include +#include + +namespace mt_host { +// MTHD v1. Reject incomplete indices and out-of-file payloads before replacing +// any original UI texture. Assets are immutable after startup. +class HdUiArchive { +public: + ~HdUiArchive() { if (file_) std::fclose(file_); } + bool open(const std::string& path) { + if (file_) return false; + file_ = std::fopen(path.c_str(), "rb"); + if (!file_) return false; + if (std::fseek(file_, 0, SEEK_END) != 0) return fail(); + const long length = std::ftell(file_); + if (length < 12 || std::fseek(file_, 0, SEEK_SET) != 0) return fail(); + unsigned char header[12]; + if (!read_bytes(header, 12) || std::string(reinterpret_cast(header),4) != "MTHD" || + u32(header+4) != 1) return fail(); + const auto count = u32(header+8); + if (!count || count > 100000 || count > std::uint64_t(length-12)/11) return fail(); + for (std::uint32_t i=0; i 4096) return fail(); + std::string name(n,'\0'); + if (!read_bytes(name.data(),n) || !read_bytes(range,8)) return fail(); + const auto offset=u32(range), bytes=u32(range+4); + if (!bytes || std::uint64_t(offset)+bytes > std::uint64_t(length) || + !entries_.emplace(std::move(name),std::make_pair(offset,bytes)).second) return fail(); + } + const long end = std::ftell(file_); + for (const auto& [name, range] : entries_) + if (range.first < std::uint64_t(end)) return fail(); + return true; + } + std::size_t size() const { return entries_.size(); } + bool read(const std::string& name, std::vector& out) { + const auto it=entries_.find(name); + if (!file_ || it==entries_.end()) return false; + std::lock_guard lock(mutex_); + out.resize(it->second.second); + if (std::fseek(file_,long(it->second.first),SEEK_SET)==0 && read_bytes(out.data(),out.size())) return true; + out.clear(); + return false; + } +private: + static std::uint32_t u32(const unsigned char* 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); + } + bool read_bytes(void* p,std::size_t n) { return std::fread(p,1,n,file_)==n; } + bool fail() { std::fclose(file_); file_=nullptr; entries_.clear(); return false; } + std::FILE* file_=nullptr; + std::mutex mutex_; + std::unordered_map> entries_; +}; +} // namespace mt_host diff --git a/src/host/live_client.cpp b/src/host/live_client.cpp index 40e055be..b2fd9bb6 100644 --- a/src/host/live_client.cpp +++ b/src/host/live_client.cpp @@ -9,6 +9,7 @@ #include "perf_log.h" #include "texture_decode.h" #include "ui_scale.h" +#include "ui_quality.h" #include "vulkan_window.h" #include "platform/PackBackend.h" @@ -159,9 +160,10 @@ int run_live_client( // scales finer glyphs down instead of stretching 1x ones. MT_FONT_OVERSAMPLE=1 keeps the // 40250 bilevel 1x glyphs. const float density = float(renderer.width()) / float(std::max(1u, renderer.logical_width())); - int oversample = std::clamp(int(std::ceil(density - 0.05f)), 1, 4); + int oversample = font_raster_scale(density); if (const char* env = std::getenv("MT_FONT_OVERSAMPLE")) oversample = std::max(1, std::atoi(env)); UISetFontOversample(oversample); + SDL_Log("UI glyph raster: %dx (%.3f physical pixels per logical pixel)", UIFontOversample(), density); } std::string error; diff --git a/src/host/sdl_window.cpp b/src/host/sdl_window.cpp index 7bd7d28a..4ccfefb2 100644 --- a/src/host/sdl_window.cpp +++ b/src/host/sdl_window.cpp @@ -4,6 +4,7 @@ #include "input_keymap.h" #include "texture_decode.h" #include "ui_scale.h" +#include "ui_quality.h" #ifdef MT_NATIVE_HAS_LIVE_CLIENT #include "platform/ScriptLib/PythonBoot.h" @@ -233,7 +234,8 @@ bool VulkanWindow::poll(bool forward_to_live_client) { flush_mouse_move(); #endif if (event.type == SDL_EVENT_QUIT) return false; - if (event.type == SDL_EVENT_WINDOW_RESIZED || event.type == SDL_EVENT_WINDOW_PIXEL_SIZE_CHANGED) { + if (event.type == SDL_EVENT_WINDOW_RESIZED || event.type == SDL_EVENT_WINDOW_PIXEL_SIZE_CHANGED || + event.type == SDL_EVENT_WINDOW_DISPLAY_SCALE_CHANGED || event.type == SDL_EVENT_WINDOW_DISPLAY_CHANGED) { int ew = 0, eh = 0; SDL_GetWindowSize(window_, &ew, &eh); touch_controller.update_screen_size(int(logical_width()), int(logical_height()), ui_safe_inset()); @@ -244,6 +246,13 @@ bool VulkanWindow::poll(bool forward_to_live_client) { } #endif recreate_swapchain(); +#ifdef MT_NATIVE_HAS_LIVE_CLIENT + if (forward_to_live_client) { + int scale = font_raster_scale(float(width()) / float(std::max(1u, logical_width()))); + if (const char* env = std::getenv("MT_FONT_OVERSAMPLE")) scale = std::max(1, std::atoi(env)); + UISetFontOversample(scale); + } +#endif continue; } if (event.type == SDL_EVENT_FINGER_DOWN) { diff --git a/src/host/texture_decode.cpp b/src/host/texture_decode.cpp index 72e53050..d2ab4729 100644 --- a/src/host/texture_decode.cpp +++ b/src/host/texture_decode.cpp @@ -1,4 +1,7 @@ #include "texture_decode.h" +#include "hd_ui_archive.h" +#include +#include #ifdef MT_NATIVE_HAS_LIVE_CLIENT #include "platform/PackBackend.h" @@ -151,47 +154,25 @@ std::vector pack_name_candidates(const std::string& vpath) { 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; +// PORT: application resources take priority over the launch directory. Finder +// changes cwd and the original Client packs can live outside the app bundle. +std::unique_ptr open_hd_ui_pak() { + if (const char* env = std::getenv("MT_HD_UI"); env && std::strcmp(env, "0") == 0) return {}; + std::vector paths; + if (const char* path = std::getenv("MT_HD_UI_PAK"); path && *path) paths.emplace_back(path); + else { + if (const char* base = SDL_GetBasePath()) paths.emplace_back(std::string(base) + "hd_ui.pak"); + paths.emplace_back("hd_ui.pak"); } - 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))); + for (const auto& path : paths) { + auto pak = std::make_unique(); + if (pak->open(path)) { + std::fprintf(stderr, "[hd_ui] loaded %zu textures from %s\n", pak->size(), path.c_str()); + return pak; + } } - std::fprintf(stderr, "[hd_ui] hd_ui.pak: %zu UI textures at 2x\n", pak->entries.size()); - return pak; + std::fprintf(stderr, "[hd_ui] no valid archive found; using original UI textures\n"); + return {}; } } // namespace @@ -207,20 +188,10 @@ bool read_live_pack_texture(const std::string& vpath, std::vector& } 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(); - } + static auto pak = open_hd_ui_pak(); + if (!pak || vpath.empty()) return false; + for (const auto& candidate : pack_name_candidates(vpath)) + if (pak->read(candidate, bytes)) return true; return false; } #endif diff --git a/src/host/texture_decode.h b/src/host/texture_decode.h index 8de871d0..15316e0a 100644 --- a/src/host/texture_decode.h +++ b/src/host/texture_decode.h @@ -13,8 +13,8 @@ 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. +// 2x UI replacement: MT_HD_UI_PAK, then application resources, then the client directory. +// MT_HD_UI=0 disables it. Invalid archives or uncovered images use the original pack texture. bool read_hd_ui_texture(const std::string& vpath, std::vector& bytes); #endif diff --git a/src/host/ui_batches.cpp b/src/host/ui_batches.cpp index af16a538..1258fcbf 100644 --- a/src/host/ui_batches.cpp +++ b/src/host/ui_batches.cpp @@ -1,5 +1,6 @@ // VulkanWindow::build_ui_batches: UIRenderCommand quads into the frame UI buffer, batched by texture and blend. #include "vulkan_window.h" +#include "ui_quality.h" #include #include @@ -138,13 +139,22 @@ void VulkanWindow::build_ui_batches( const std::string& tex_name = cmd.kind == UIRenderCommand::Image ? cmd.text : std::string(); const std::string& mask_name = cmd.kind == UIRenderCommand::Image ? cmd.mask : std::string(); const bool is_masked = !mask_name.empty(); + const float sx = float(extent_.width) / float(ui_width); + const float sy = float(extent_.height) / float(ui_height); + const bool aligned = cmd.kind == UIRenderCommand::Image && align_ui_quad(qx, qy, sx, sy); + bool point = false; + if (aligned && !is_masked && !tex_name.empty()) { + const auto& texture = get_gpu_texture(tex_name, capture_textures); + point = ui_texels_match_pixels(std::fabs(qx[1]-qx[0])*sx, std::fabs(qy[2]-qy[0])*sy, + std::fabs(eu-su)*texture.width, std::fabs(ev-sv)*texture.height); + } // CGraphicExpandedImageInstance: SCREEN/COLOR_DODGE use INVDESTCOLOR (10) + ONE (2); // MODULATE uses ZERO (1) + SRCCOLOR (3). Packed as src | dst << 4. const std::uint8_t blend_mode = cmd.kind == UIRenderCommand::Image ? ((cmd.blend == 1 || cmd.blend == 2) ? 0x2A : (cmd.blend == 3 ? 0x31 : 1)) : 1; const VkPipeline pipeline = get_pipeline(0, 0, blend_mode); - const VkDescriptorSet descriptor = get_ui_texture_descriptor(tex_name, mask_name, capture_textures); + const VkDescriptorSet descriptor = get_ui_texture_descriptor(tex_name, mask_name, capture_textures, point); for (int k = 0; k < 4; ++k) { Vertex& vert = vertices[v_count + k]; diff --git a/src/host/ui_quality.h b/src/host/ui_quality.h new file mode 100644 index 00000000..47442acc --- /dev/null +++ b/src/host/ui_quality.h @@ -0,0 +1,28 @@ +#pragma once +#include +#include + +namespace mt_host { +inline int font_raster_scale(float density) { + return std::clamp(int(std::ceil(density - 0.001f)), 1, 4); +} + +// PORT: move an axis-aligned UI quad by at most half a physical pixel. Preserve +// its dimensions, UVs and rotation; recorded logical layout/hit testing is untouched. +inline bool align_ui_quad(float x[4], float y[4], float sx, float sy) { + if (sx <= 0 || sy <= 0 || std::fabs(x[0]-x[2]) > 0.001f || + std::fabs(x[1]-x[3]) > 0.001f || std::fabs(y[0]-y[1]) > 0.001f || + std::fabs(y[2]-y[3]) > 0.001f) return false; + const float dx = std::round(x[0]*sx)/sx-x[0]; + const float dy = std::round(y[0]*sy)/sy-y[0]; + for (int i=0; i<4; ++i) { x[i]+=dx; y[i]+=dy; } + return true; +} + +// Point sampling avoids a second blur of a 1:1 HD image or glyph atlas. Resized +// images retain linear filtering; integer enlargement of low-res art stays smooth. +inline bool ui_texels_match_pixels(float pixels_w, float pixels_h, float texels_w, float texels_h) { + return texels_w > 0 && texels_h > 0 && + std::fabs(pixels_w-texels_w) < 0.01f && std::fabs(pixels_h-texels_h) < 0.01f; +} +} // namespace mt_host diff --git a/src/host/vk_resources.cpp b/src/host/vk_resources.cpp index 9054dcb6..35b144f7 100644 --- a/src/host/vk_resources.cpp +++ b/src/host/vk_resources.cpp @@ -60,6 +60,9 @@ void VulkanWindow::create_descriptors_and_buffers() { ui_sampler_info.anisotropyEnable = VK_FALSE; ui_sampler_info.maxAnisotropy = 1.0f; check(vkCreateSampler(device_, &ui_sampler_info, nullptr, &ui_sampler_), "vkCreateSampler ui"); + ui_sampler_info.magFilter = VK_FILTER_NEAREST; + ui_sampler_info.minFilter = VK_FILTER_NEAREST; + check(vkCreateSampler(device_, &ui_sampler_info, nullptr, &ui_point_sampler_), "vkCreateSampler ui point"); VkDescriptorSetLayoutBinding tex_bindings[2]{}; tex_bindings[0].binding = 0; diff --git a/src/host/vk_textures.cpp b/src/host/vk_textures.cpp index 755fa6f7..59cec68f 100644 --- a/src/host/vk_textures.cpp +++ b/src/host/vk_textures.cpp @@ -42,7 +42,7 @@ VkDescriptorSet VulkanWindow::allocate_texture_descriptor_set(VkImageView view0, return set; } -VkDescriptorSet VulkanWindow::allocate_ui_texture_descriptor_set(VkImageView view0, VkImageView view1) { +VkDescriptorSet VulkanWindow::allocate_ui_texture_descriptor_set(VkImageView view0, VkImageView view1, bool point) { VkDescriptorSet set = VK_NULL_HANDLE; VkDescriptorSetAllocateInfo desc_alloc{VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO}; desc_alloc.descriptorPool = descriptor_pool_; @@ -51,10 +51,10 @@ VkDescriptorSet VulkanWindow::allocate_ui_texture_descriptor_set(VkImageView vie check(vkAllocateDescriptorSets(device_, &desc_alloc, &set), "vkAllocateDescriptorSets ui"); VkDescriptorImageInfo image_infos[2]{}; - image_infos[0].sampler = ui_sampler_ ? ui_sampler_ : sampler_; + image_infos[0].sampler = point ? ui_point_sampler_ : (ui_sampler_ ? ui_sampler_ : sampler_); image_infos[0].imageView = view0; image_infos[0].imageLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL; - image_infos[1].sampler = ui_sampler_ ? ui_sampler_ : (clamp_sampler_ ? clamp_sampler_ : sampler_); + image_infos[1].sampler = point ? ui_point_sampler_ : (ui_sampler_ ? ui_sampler_ : sampler_); image_infos[1].imageView = view1; image_infos[1].imageLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL; @@ -195,8 +195,13 @@ VkDescriptorSet VulkanWindow::get_texture_descriptor( VkDescriptorSet VulkanWindow::get_ui_texture_descriptor( const std::string& texture0_name, const std::string& mask_name, - const std::unordered_map>& capture_textures) { - const auto& tex0 = get_gpu_texture(texture0_name, capture_textures); + const std::unordered_map>& capture_textures, bool point) { + auto& tex0 = get_gpu_texture(texture0_name, capture_textures); + if (point && mask_name.empty() && tex0.view) { + if (!tex0.ui_point_descriptor) + tex0.ui_point_descriptor = allocate_ui_texture_descriptor_set(tex0.view, fallback_texture_.view, true); + return tex0.ui_point_descriptor; + } if (mask_name.empty()) return tex0.ui_descriptor ? tex0.ui_descriptor : tex0.descriptor; const auto& tex1 = get_gpu_texture(mask_name, capture_textures); if (!tex0.view || !tex1.view) return tex0.ui_descriptor ? tex0.ui_descriptor : tex0.descriptor; @@ -221,6 +226,8 @@ void VulkanWindow::upload_texture( bool generate_mips, const std::vector>* file_mips) { const ScopedMs timer(timings_.texture_upload_ms); + texture.width = width; + texture.height = height; const bool has_file_mips = !generate_mips && file_mips && !file_mips->empty(); VkDeviceSize byte_size = VkDeviceSize(width) * height * 4; if (has_file_mips) @@ -395,7 +402,7 @@ void VulkanWindow::upload_texture( } void VulkanWindow::release_texture(GpuTexture& texture) { - for (VkDescriptorSet set : {texture.descriptor, texture.ui_descriptor}) + for (VkDescriptorSet set : {texture.descriptor, texture.ui_descriptor, texture.ui_point_descriptor}) if (set && set != fallback_texture_.descriptor && set != fallback_texture_.ui_descriptor) vkFreeDescriptorSets(device_, descriptor_pool_, 1, &set); if (texture.view) vkDestroyImageView(device_, texture.view, nullptr); diff --git a/src/host/vulkan_window.cpp b/src/host/vulkan_window.cpp index a07a8069..2a36cde2 100644 --- a/src/host/vulkan_window.cpp +++ b/src/host/vulkan_window.cpp @@ -134,6 +134,7 @@ VulkanWindow::~VulkanWindow() { if (sampler_) vkDestroySampler(device_, sampler_, nullptr); if (clamp_sampler_) vkDestroySampler(device_, clamp_sampler_, nullptr); if (ui_sampler_) vkDestroySampler(device_, ui_sampler_, nullptr); + if (ui_point_sampler_) vkDestroySampler(device_, ui_point_sampler_, nullptr); for (const auto& entry : state_samplers_) vkDestroySampler(device_, entry.second, nullptr); for (auto framebuffer : framebuffers_) vkDestroyFramebuffer(device_, framebuffer, nullptr); if (pass_) vkDestroyRenderPass(device_, pass_, nullptr); diff --git a/src/host/vulkan_window.h b/src/host/vulkan_window.h index 28e835c5..00e6d718 100644 --- a/src/host/vulkan_window.h +++ b/src/host/vulkan_window.h @@ -46,6 +46,8 @@ class VulkanWindow { VkImageView view = VK_NULL_HANDLE; VkDescriptorSet descriptor = VK_NULL_HANDLE; VkDescriptorSet ui_descriptor = VK_NULL_HANDLE; + VkDescriptorSet ui_point_descriptor = VK_NULL_HANDLE; + std::uint32_t width = 0, height = 0; std::uint64_t last_used_frame = 0; std::uint64_t last_decode_attempt_frame = 0; }; @@ -326,7 +328,7 @@ private: VkSampler sampler0 = VK_NULL_HANDLE, VkSampler sampler1 = VK_NULL_HANDLE); - VkDescriptorSet allocate_ui_texture_descriptor_set(VkImageView view0, VkImageView view1); + VkDescriptorSet allocate_ui_texture_descriptor_set(VkImageView view0, VkImageView view1, bool point = false); GpuTexture& get_gpu_texture( const std::string& texture_name, @@ -345,7 +347,8 @@ private: VkDescriptorSet get_ui_texture_descriptor( const std::string& texture0_name, const std::string& mask_name, - const std::unordered_map>& capture_textures); + const std::unordered_map>& capture_textures, + bool point = false); void upload_texture( std::uint32_t width, @@ -419,6 +422,7 @@ private: VkSampler sampler_ = VK_NULL_HANDLE; VkSampler clamp_sampler_ = VK_NULL_HANDLE; VkSampler ui_sampler_ = VK_NULL_HANDLE; + VkSampler ui_point_sampler_ = VK_NULL_HANDLE; std::unordered_map state_samplers_; VkDescriptorSetLayout descriptor_layout_ = VK_NULL_HANDLE; VkDescriptorSetLayout bone_descriptor_layout_ = VK_NULL_HANDLE; diff --git a/src/platform/EterLib/GrpFontTexture.cpp b/src/platform/EterLib/GrpFontTexture.cpp index d298dea1..00b268f1 100644 --- a/src/platform/EterLib/GrpFontTexture.cpp +++ b/src/platform/EterLib/GrpFontTexture.cpp @@ -1,5 +1,5 @@ // 40250 EterLib/GrpFontTexture.cpp, verbatim over the GDI emulation in platform/Win32Gdi.cpp. The -// A4R4G4B4 page textures are CGraphicImageTexture memory textures the Godot canvas samples. +// Page textures are memory textures consumed by the native UI renderer. #include "EterLib/StdAfx.h" #include "EterLib/GrpText.h" #include "EterBase/Stl.h" @@ -8,6 +8,8 @@ #include "UIRenderCommands.h" #include +#include +#include // PORT: HiDPI glyph pages (UISetFontOversample). With a scale s > 1 the page DIB and texture are // s times larger and each glyph is drawn with an s-times font into the s-scaled cell, while the @@ -15,22 +17,25 @@ // 1x values: text layout is unchanged, only the texels get finer. namespace { int g_font_oversample = 1; -bool g_font_oversample_latched = false; +std::unordered_set g_font_textures; // The 1x HFONT a CGraphicFontTexture measures with -> its s-times twin that draws. std::unordered_map g_oversampled_fonts; int FontOversample() { - g_font_oversample_latched = true; return g_font_oversample; } } // namespace void UISetFontOversample(int scale) { - if (g_font_oversample_latched) - return; - g_font_oversample = scale < 1 ? 1 : (scale > 4 ? 4 : scale); + scale = scale < 1 ? 1 : (scale > 4 ? 4 : scale); + if (scale == g_font_oversample) return; + g_font_oversample = scale; + // The host calls this between AppFrame steps, when the script thread is suspended. + const auto textures = g_font_textures; + for (auto* texture : textures) + if (!texture->RefreshRasterScale()) TraceError("HiDPI font page rebuild failed"); } int UIFontOversample() @@ -64,6 +69,7 @@ bool CGraphicFontTexture::IsEmpty() const void CGraphicFontTexture::Destroy() { + g_font_textures.erase(this); HDC hDC = m_dib.GetDCHandle(); if (hDC) SelectObject(hDC, m_hFontOld); @@ -139,10 +145,29 @@ bool CGraphicFontTexture::Create(const char* c_szFontName, int fontSize, bool bI if (!AppendTexture()) return false; - + g_font_textures.insert(this); return true; } +bool CGraphicFontTexture::RefreshRasterScale() +{ + const std::string name(m_fontName); + const int size = m_fontSize; + const bool italic = m_bItalic; + // Text instances cache pointers into this map. Keep its nodes alive throughout + // Create/Destroy and fill the same nodes with the new page indices and UVs. + TCharacterInfomationMap characters; + characters.swap(m_charInfoMap); + if (!Create(name.c_str(), size, italic)) { + m_charInfoMap.swap(characters); + return false; + } + m_charInfoMap.swap(characters); + for (const auto& character : m_charInfoMap) + if (!UpdateCharacterInfomation(character.first)) return false; + return UpdateTexture(); +} + HFONT CGraphicFontTexture::GetFont(WORD codePage) @@ -195,7 +220,9 @@ bool CGraphicFontTexture::AppendTexture() { CGraphicImageTexture * pNewTexture = new CGraphicImageTexture; - if (!pNewTexture->Create(m_dib.GetWidth(), m_dib.GetHeight(), D3DFMT_A4R4G4B4)) + // PORT: retain all 256 coverage levels on Retina pages; 1x keeps the original format. + if (!pNewTexture->Create(m_dib.GetWidth(), m_dib.GetHeight(), + FontOversample() > 1 ? D3DFMT_A8R8G8B8 : D3DFMT_A4R4G4B4)) { delete pNewTexture; return false; @@ -220,6 +247,16 @@ bool CGraphicFontTexture::UpdateTexture() WORD* pwDst; int pitch; + if (FontOversample() > 1) { + DWORD* dst; + if (!pFontTexture->Lock(&pitch, (void**)&dst)) return false; + const DWORD* src = (const DWORD*)m_dib.GetPointer(); + for (int y=0; yUnlock(); + return true; + } + if (!pFontTexture->Lock(&pitch, (void**)&pwDst)) return false; @@ -308,7 +345,7 @@ CGraphicFontTexture::TCharacterInfomation* CGraphicFontTexture::UpdateCharacterI if (hi != g_oversampled_fonts.end()) { // Draw with the s-times font into the s-scaled cell and keep its anti-aliasing as - // A4R4G4B4 white with coverage alpha (1x keeps 40250's bilevel threshold below). + // A8R8G8B8 white with coverage alpha (1x keeps 40250's bilevel threshold below). SelectObject(hDC, hi->second); TextOutW(hDC, m_x * s, m_y * s, &keyValue, 1); SelectObject(hDC, hFont); @@ -318,8 +355,8 @@ CGraphicFontTexture::TCharacterInfomation* CGraphicFontTexture::UpdateCharacterI for (int y = 0; y < size.cy * s; ++y, pdwRow += nDIBWidth) for (int x = 0; x < size.cx * s; ++x) { - const DWORD alpha = ((pdwRow[x] & 0xff) * 15 + 127) / 255; - pdwRow[x] = alpha ? ((alpha << 12) | 0x0fff) : 0; + const DWORD alpha = pdwRow[x] & 0xff; + pdwRow[x] = alpha ? ((alpha << 24) | 0x00ffffff) : 0; } } else diff --git a/src/platform/EterLib/UIRenderCommands.h b/src/platform/EterLib/UIRenderCommands.h index a5d053e7..52a85589 100644 --- a/src/platform/EterLib/UIRenderCommands.h +++ b/src/platform/EterLib/UIRenderCommands.h @@ -83,7 +83,7 @@ void UIRenderRestoreClip(); // HiDPI text: CGraphicFontTexture rasterises its glyph pages at this multiple of the logical font // size (the swapchain's pixels per UI pixel) while keeping the 40250 glyph metrics and page UVs in -// logical pixels. 1 (the default) is the verbatim 40250 path. Latched when the first font page is -// created, so set it before the game creates fonts. +// logical pixels. 1 (the default) is the verbatim 40250 path. Changing the scale between +// AppFrame steps rebuilds existing pages without invalidating cached glyph pointers. void UISetFontOversample(int scale); int UIFontOversample(); diff --git a/src/port/CMakeLists.txt b/src/port/CMakeLists.txt index 00357bd0..a85f285f 100644 --- a/src/port/CMakeLists.txt +++ b/src/port/CMakeLists.txt @@ -194,6 +194,8 @@ if(BUILD_TESTING AND CMAKE_SYSTEM_NAME STREQUAL CMAKE_HOST_SYSTEM_NAME) # 批次 2V0-g: the 40250 font path over the FreeType GDI emulation; needs a system Tahoma. add_executable(port_text_test ${PROJECT_SOURCE_DIR}/tests/port/port_text_test.cpp) + add_executable(host_ui_quality_test ${PROJECT_SOURCE_DIR}/tests/host/ui_quality_test.cpp) + add_test(NAME host.ui_quality COMMAND host_ui_quality_test) target_link_libraries(port_text_test PRIVATE port_platform) add_test(NAME port.text COMMAND $) set_tests_properties(port.text PROPERTIES SKIP_RETURN_CODE 77) diff --git a/src/port/EterLib/GrpFontTexture.h b/src/port/EterLib/GrpFontTexture.h index 56ae068e..c9ef20fb 100644 --- a/src/port/EterLib/GrpFontTexture.h +++ b/src/port/EterLib/GrpFontTexture.h @@ -32,6 +32,8 @@ public: void Destroy(); bool Create(const char* c_szFontName, int fontSize, bool bItalic); + // PORT: rebuild raster pages while preserving character-info addresses and logical metrics. + bool RefreshRasterScale(); bool CreateDeviceObjects(); void DestroyDeviceObjects(); diff --git a/tests/host/ui_quality_test.cpp b/tests/host/ui_quality_test.cpp new file mode 100644 index 00000000..90208a11 --- /dev/null +++ b/tests/host/ui_quality_test.cpp @@ -0,0 +1,41 @@ +#include "../../src/host/ui_quality.h" +#include "../../src/host/hd_ui_archive.h" +#include +#include +#include + +int main() { + int failures=0; + auto check=[&](bool ok,const char* message) { if (!ok) { std::fprintf(stderr,"FAIL %s\n",message); ++failures; } }; + using namespace mt_host; + check(font_raster_scale(1)==1 && font_raster_scale(2)==2 && font_raster_scale(2.01f)==3, + "fractional density uses enough glyph texels"); + float x[]={0.25f,32.25f,0.25f,32.25f}, y[]={0.125f,0.125f,32.125f,32.125f}; + check(align_ui_quad(x,y,2,2),"axis aligned icon snaps"); + check(x[0]*2==std::round(x[0]*2) && y[0]*2==std::round(y[0]*2) && x[1]-x[0]==32, + "physical origin aligns without resizing"); + x[2]+=1; + check(!align_ui_quad(x,y,2,2),"rotated quad remains unchanged"); + check(ui_texels_match_pixels(64,64,64,64),"2x HD icon at 1:1 uses point sampling"); + check(!ui_texels_match_pixels(64,64,32,32) && !ui_texels_match_pixels(48,48,64,64), + "upsized original art and downsized HD retain linear filtering"); + + char path[]="/tmp/metin2-hd-archive-XXXXXX"; + const int fd=mkstemp(path); + check(fd>=0,"archive test fixture"); + if (fd<0) return 1; + close(fd); + std::vector data={'M','T','H','D',1,0,0,0,1,0,0,0,3,0,'f','o','o',25,0,0,0,4,0,0,0,1,2,3,4}; + auto save=[&] { auto* f=std::fopen(path,"wb"); std::fwrite(data.data(),1,data.size(),f); std::fclose(f); }; + save(); + { HdUiArchive archive; std::vector out; + check(archive.open(path) && archive.size()==1 && archive.read("foo",out) && out==std::vector({1,2,3,4}), + "valid resource archive payload"); + check(!archive.read("missing",out),"uncovered asset falls back"); } + data[21]=200; save(); + { HdUiArchive archive; check(!archive.open(path) && archive.size()==0,"invalid payload range rejected"); } + data.resize(20); save(); + { HdUiArchive archive; check(!archive.open(path),"truncated index rejected"); } + unlink(path); + return failures ? 1 : 0; +} diff --git a/tests/port/port_text_test.cpp b/tests/port/port_text_test.cpp index 0088fdae..4f83b9a8 100644 --- a/tests/port/port_text_test.cpp +++ b/tests/port/port_text_test.cpp @@ -132,6 +132,45 @@ int main() } CHECK(inked); + // PORT: changing monitors must rebuild glyph rasters, not text layout. Existing + // CGraphicTextInstance objects retain pointers into the character-info map. + auto* font = text->GetFontTexturePointer(); + auto* glyph = font->GetCharacterInfomation(1252, L'H'); + const auto original_glyph = *glyph; + int page_width = 0, page_height = 0; + for (const auto& command : UIRenderCommands()) { + UIMemoryTexture page; + if (UIRenderMemoryTexture(command.text, &page)) { + page_width = page.width; page_height = page.height; break; + } + } + CHECK(page_width > 0); + for (int scale : {2, 3, 1, 2}) { + UISetFontOversample(scale); + CHECK(UIFontOversample() == scale); + CHECK(font->GetCharacterInfomation(1252, L'H') == glyph); + CHECK(glyph->width == original_glyph.width && glyph->height == original_glyph.height && + glyph->advance == original_glyph.advance); + UIRenderBeginFrame(); + instance.Render(); // use the already cached glyph pointers + CHECK(glyph_quads(&left, &right) == 5); + int new_width = 0, new_height = 0; + instance.GetTextSize(&new_width, &new_height); + CHECK(new_width == width && new_height == height); + bool sized = false, fine_coverage = false; + for (const auto& command : UIRenderCommands()) { + UIMemoryTexture page; + if (!UIRenderMemoryTexture(command.text, &page)) continue; + sized |= page.width == page_width * scale && page.height == page_height * scale; + for (auto argb : page.argb) { + const auto alpha = argb >> 24; + fine_coverage |= alpha > 0 && alpha < 255 && alpha % 17 != 0; + } + } + CHECK(sized); + if (scale > 1) CHECK(fine_coverage); // 8-bit alpha, not the old 4-bit quantisation + } + instance.Destroy(); text->Release();