Improve macOS Retina UI clarity and bundle HD assets
This commit is contained in:
@@ -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.
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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 签名构建。
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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))
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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];
|
||||
|
||||
@@ -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
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user