Improve macOS Retina UI clarity and bundle HD assets

This commit is contained in:
shen
2026-09-30 16:13:31 -07:00
parent 905f23f135
commit d1f8d3dddd
26 changed files with 505 additions and 134 deletions
@@ -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.
+2
View File
@@ -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."}
+7 -2
View File
@@ -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,移除/隐藏简化目标条并补生产链回归",
@@ -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",
@@ -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",
+32
View File
@@ -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 签名构建。
+3 -1
View File
@@ -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:
+76 -44
View File
@@ -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=<repo>/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 <<EOF
[[ -x "$bin" ]] || { echo "missing native executable: $bin" >&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 <<EOF
set -eu
CONTENTS_DIR="$(cd "$(dirname "$0")/.." && pwd -P)"
RESOURCES_DIR="$CONTENTS_DIR/Resources"
export VK_ICD_FILENAMES="$RESOURCES_DIR/MoltenVK_icd.json"
# Resource lookup also works when Finder starts us with cwd=/ or Client is overridden.
: "${MT_HD_UI_PAK:=$RESOURCES_DIR/hd_ui.pak}"
export MT_HD_UI_PAK
cd "$RESOURCES_DIR"
exec "$CONTENTS_DIR/MacOS/mt_native_render" --interactive \
--live-client "${MT_CLIENT_DIR:-$RESOURCES_DIR/Client}" \
--fake-mobs 24 --width 1280 --height 800 "$@"
SH
chmod +x "$stage/Contents/MacOS/Metin2"
cat > "$stage/Contents/Info.plist" <<PLIST
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleName</key><string>Metin2</string>
<key>CFBundleDisplayName</key><string>Metin2</string>
<key>CFBundleIdentifier</key><string>org.metin2port.client</string>
<key>CFBundleExecutable</key><string>Metin2</string>
<key>CFBundleIconFile</key><string>AppIcon</string>
<key>CFBundlePackageType</key><string>APPL</string>
<key>CFBundleShortVersionString</key><string>0.1</string>
<key>CFBundleVersion</key><string>$version</string>
<key>LSMinimumSystemVersion</key><string>12.0</string>
<key>LSApplicationCategoryType</key><string>public.app-category.role-playing-games</string>
<key>NSHighResolutionCapable</key><true/>
</dict>
</plist>
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
<plist version="1.0"><dict>
<key>CFBundleName</key><string>Metin2</string>
<key>CFBundleDisplayName</key><string>Metin2</string>
<key>CFBundleIdentifier</key><string>org.metin2port.client</string>
<key>CFBundleExecutable</key><string>Metin2</string>
<key>CFBundleIconFile</key><string>AppIcon</string>
<key>CFBundlePackageType</key><string>APPL</string>
<key>CFBundleShortVersionString</key><string>0.1</string>
<key>CFBundleVersion</key><string>$version</string>
<key>LSMinimumSystemVersion</key><string>12.0</string>
<key>LSApplicationCategoryType</key><string>public.app-category.role-playing-games</string>
<key>NSHighResolutionCapable</key><true/>
</dict></plist>
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"
+41
View File
@@ -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('<II', data, 4)
if version != 1 or not 0 < count <= 100000:
raise ValueError('invalid archive version/count')
index, entries, names = 12, [], set()
for _ in range(count):
if index + 2 > len(data): raise ValueError('truncated index')
size, = struct.unpack_from('<H', data, index)
index += 2
if not 0 < size <= 4096 or index + size + 8 > len(data): raise ValueError('invalid entry')
name = data[index:index+size].decode('latin1')
index += size
offset, length = struct.unpack_from('<II', data, index)
index += 8
if name in names or not length or offset + length > 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))
+64
View File
@@ -0,0 +1,64 @@
#pragma once
#include <cstdio>
#include <cstdint>
#include <mutex>
#include <string>
#include <unordered_map>
#include <vector>
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<char*>(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<count; ++i) {
unsigned char size[2], range[8];
if (!read_bytes(size,2)) return fail();
const unsigned n = size[0] | (unsigned(size[1])<<8);
if (!n || n > 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<std::uint8_t>& out) {
const auto it=entries_.find(name);
if (!file_ || it==entries_.end()) return false;
std::lock_guard<std::mutex> 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<std::string,std::pair<std::uint32_t,std::uint32_t>> entries_;
};
} // namespace mt_host
+3 -1
View File
@@ -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;
+10 -1
View File
@@ -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) {
+24 -53
View File
@@ -1,4 +1,7 @@
#include "texture_decode.h"
#include "hd_ui_archive.h"
#include <SDL3/SDL.h>
#include <memory>
#ifdef MT_NATIVE_HAS_LIVE_CLIENT
#include "platform/PackBackend.h"
@@ -151,47 +154,25 @@ std::vector<std::string> 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<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;
// 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<HdUiArchive> open_hd_ui_pak() {
if (const char* env = std::getenv("MT_HD_UI"); env && std::strcmp(env, "0") == 0) return {};
std::vector<std::string> 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<HdUiArchive>();
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<std::uint8_t>&
}
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();
}
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
+2 -2
View File
@@ -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<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.
// 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<std::uint8_t>& bytes);
#endif
+11 -1
View File
@@ -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 <algorithm>
#include <cmath>
@@ -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];
+28
View File
@@ -0,0 +1,28 @@
#pragma once
#include <algorithm>
#include <cmath>
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
+3
View File
@@ -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;
+13 -6
View File
@@ -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<std::string, std::vector<std::uint8_t>>& capture_textures) {
const auto& tex0 = get_gpu_texture(texture0_name, capture_textures);
const std::unordered_map<std::string, std::vector<std::uint8_t>>& 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<std::vector<std::uint8_t>>* 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);
+1
View File
@@ -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);
+6 -2
View File
@@ -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<std::string, std::vector<std::uint8_t>>& capture_textures);
const std::unordered_map<std::string, std::vector<std::uint8_t>>& 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<std::uint64_t, VkSampler> state_samplers_;
VkDescriptorSetLayout descriptor_layout_ = VK_NULL_HANDLE;
VkDescriptorSetLayout bone_descriptor_layout_ = VK_NULL_HANDLE;
+48 -11
View File
@@ -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 <unordered_map>
#include <unordered_set>
#include <string>
// 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<CGraphicFontTexture*> g_font_textures;
// The 1x HFONT a CGraphicFontTexture measures with -> its s-times twin that draws.
std::unordered_map<HFONT, HFONT> 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; y<m_dib.GetHeight(); ++y)
std::memcpy((char*)dst + y*pitch, src + y*m_dib.GetWidth(), m_dib.GetWidth()*sizeof(DWORD));
pFontTexture->Unlock();
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
+2 -2
View File
@@ -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();
+2
View File
@@ -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 $<TARGET_FILE:port_text_test>)
set_tests_properties(port.text PROPERTIES SKIP_RETURN_CODE 77)
+2
View File
@@ -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();
+41
View File
@@ -0,0 +1,41 @@
#include "../../src/host/ui_quality.h"
#include "../../src/host/hd_ui_archive.h"
#include <unistd.h>
#include <cstdio>
#include <vector>
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<unsigned char> 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<std::uint8_t> out;
check(archive.open(path) && archive.size()==1 && archive.read("foo",out) && out==std::vector<std::uint8_t>({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;
}
+39
View File
@@ -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();