Build self-contained macOS client and verify native beta path

This commit is contained in:
shen
2026-09-24 00:59:08 -07:00
parent 400973da11
commit a57c83dd71
12 changed files with 331 additions and 12 deletions
+23 -6
View File
@@ -3,18 +3,23 @@
#
# ./build-macos-client.sh [debug|release]
#
# 产物:build/export/mtgodot-poc.app(资源和 BGM 自包含在 Contents/Resources/)。
# 产物:build/export/mtgodot-poc.app(40250 Client/pack 和 BGM 自包含)。
# 依赖:Godot 4.7.1 的 macOS 导出模板(editor 里 “管理导出模板” 装,或放
# ~/Library/Application Support/Godot/export_templates/4.7.1.stable/macos.zip)。
set -euo pipefail
cd "$(dirname "$0")"
REPO="$(pwd)"
SOURCE_CLIENT="${MT_40250_CLIENT:-$REPO/../40250/Server Client TMP4/Client}"
CFG="${1:-debug}"
OUT="${MT_MAC_OUTPUT_DIR:-$REPO/build/export}"
APP="$OUT/mtgodot-poc.app"
GODOT="${GODOT:-godot}"
MAC_ARCHES="${MT_MAC_ARCHES:-universal}"
ENGINE_OVERRIDE="${MT_MAC_ENGINE:-}"
test -f "$SOURCE_CLIENT/pack/Index" || {
echo "错误:缺少 40250 Client/pack/Index:$SOURCE_CLIENT" >&2
exit 1
}
if [ -n "$ENGINE_OVERRIDE" ]; then
test -x "$ENGINE_OVERRIDE"
ENGINE_INFO="$(lipo -info "$ENGINE_OVERRIDE")"
@@ -59,11 +64,23 @@ else
"$GODOT" --headless --path project --export-debug macOS "$APP"
fi
echo "== 3. 资源(自包含)=="
mkdir -p "$APP/Contents/Resources"
ditto "$REPO/assets" "$APP/Contents/Resources/assets"
ditto "$REPO/bgm" "$APP/Contents/Resources/bgm"
test -f "$APP/Contents/Resources/assets/asset_index.txt"
echo "== 3. 40250 资源(自包含)=="
BUNDLE_CLIENT="$APP/Contents/Resources/40250/Client"
mkdir -p "$BUNDLE_CLIENT"
ditto "$SOURCE_CLIENT/pack" "$BUNDLE_CLIENT/pack"
if [ -d "$SOURCE_CLIENT/BGM" ]; then ditto "$SOURCE_CLIENT/BGM" "$BUNDLE_CLIENT/BGM"; fi
if [ -f "$SOURCE_CLIENT/locale.cfg" ]; then cp "$SOURCE_CLIENT/locale.cfg" "$BUNDLE_CLIENT/locale.cfg"; fi
test -s "$BUNDLE_CLIENT/pack/Index"
# Record the exact source revision inside the signed bundle. A Beta release is built only
# after its changes are committed, so the identity remains useful when the checkout moves on.
python3 - "$REPO" "$APP/Contents/Resources/beta-build.json" <<'PY'
import json, pathlib, subprocess, sys
repo, output = sys.argv[1:]
commit = subprocess.check_output(['git', '-C', repo, 'rev-parse', 'HEAD'], text=True).strip()
dirty = bool(subprocess.check_output(['git', '-C', repo, 'status', '--porcelain', '--untracked-files=normal'], text=True).strip())
pathlib.Path(output).write_text(json.dumps({'source_commit': commit, 'source_dirty': dirty}, indent=2) + '\n')
PY
echo "== 4. 签名(默认 ad-hoc;CODESIGN_IDENTITY 可切正式证书)=="
if [ -n "$ENGINE_OVERRIDE" ]; then
@@ -49,6 +49,7 @@
#include <condition_variable>
#include <cstdlib>
#include <filesystem>
#include <memory>
#include <mutex>
#include <pthread.h>
@@ -346,8 +347,18 @@ bool Start(const char* stdlib_path, std::string* error)
return true;
// 40250 Main (UserInterface.cpp:410): if (LocaleService_LoadGlobal(hInstance)) SetDefaultCodePage(...).
LocaleService_LoadConfig("locale.cfg");
std::string locale_config = "locale.cfg";
if (const char* client = std::getenv("MT_40250_CLIENT"))
locale_config = (std::filesystem::path(client) / "locale.cfg").string();
LocaleService_LoadConfig(locale_config.c_str());
SetDefaultCodePage(LocaleService_GetCodePage());
if (const char* data_dir = std::getenv("MT_40250_USER_DATA_DIR"))
{
std::error_code ec;
std::filesystem::current_path(data_dir, ec);
if (ec)
return fail(error, "cannot use player settings directory: " + ec.message());
}
const std::string stdlib = (stdlib_path && *stdlib_path) ? stdlib_path : PythonHost::DefaultStdLibPath();
if (stdlib.empty())
@@ -252,7 +252,12 @@ void CPythonBackground::__CreateProperty()
}
else
{
m_PropertyManager.Initialize("pack/property");
// The exported client runs with player settings as CWD; its read-only property pack
// lives beside the other bundled 40250 packs. Use the same root registered by PackBackend.
std::string property_pack = "pack/property";
if (const char* client = getenv("MT_40250_CLIENT"))
property_pack = std::string(client) + "/pack/Property";
m_PropertyManager.Initialize(property_pack.c_str());
}
}
+8 -2
View File
@@ -7,6 +7,7 @@
#include <godot_cpp/variant/utility_functions.hpp>
#include <string>
#include <cstdio>
#ifdef MTGODOT_HAVE_PYTHON
#include "platform/ScriptLib/PythonHost.h"
@@ -60,8 +61,13 @@ String stage_python_stdlib(String *error, bool force_stage) {
// nothing is copied. In an exported game globalize_path() returns a path next to the executable
// that does not exist, which is exactly the case the staging below is for.
const String in_place = settings->globalize_path(ZIP_RES);
if (!force_stage && !in_place.begins_with("res://") && FileAccess::file_exists(in_place)) {
return in_place;
if (!force_stage && !in_place.begins_with("res://")) {
// FileAccess may resolve an exported PCK entry through a relative globalized path. CPython
// uses fopen(), so only return the path when libc can open a real on-disk zip there.
if (FILE *file = std::fopen(in_place.utf8().get_data(), "rb")) {
std::fclose(file);
return in_place;
}
}
const String want = expected_sha();
+6 -1
View File
@@ -47,11 +47,16 @@ static func available() -> bool:
return DirAccess.dir_exists_absolute(path())
# --- 40250 client (pack/Index + .eix/.epk), docs/PORT-PLAN.md 2R item 6 / 2D ---
# 解析顺序:环境变量 MT_40250_CLIENT;开发树 res://../../40250/Server Client TMP4/Client。
# 解析顺序:显式覆盖、macOS .app 内的自包含资源、开发树。
static func client_path() -> String:
var env := OS.get_environment("MT_40250_CLIENT")
if env != "":
return env
var exe := OS.get_executable_path()
if exe != "":
var bundled := exe.get_base_dir().path_join("../Resources/40250/Client").simplify_path()
if FileAccess.file_exists(bundled.path_join("pack/Index")):
return bundled
return ProjectSettings.globalize_path("res://../../40250/Server Client TMP4/Client").simplify_path()
# 按 40250 PackInitialize("pack") 注册 <client>/pack;只注册一次,之后返回首次结果。
+1
View File
@@ -0,0 +1 @@
uid://vqw8iy17y4m0
+1
View File
@@ -0,0 +1 @@
uid://dfbxpg1q8a0q8
+157
View File
@@ -0,0 +1,157 @@
# Bounded, read-only release probe on the same native 40250 path as normal play.
# Credentials stay in the process environment and are never written to the report.
extends Node
var _cases: Array[Dictionary] = []
var _failed := false
func _ready() -> void:
call_deferred("_run")
func _check(ok: bool, name: String) -> bool:
_cases.append({"id": name, "status": "PASS" if ok else "FAIL"})
if not ok:
_failed = true
printerr("BETA_PROBE: FAIL " + name)
return ok
func _py(source: String) -> bool:
return Metin2PythonHost.run_line(source) == ""
func _until(seconds: float, condition: Callable) -> bool:
var deadline := Time.get_ticks_msec() + int(seconds * 1000.0)
while Time.get_ticks_msec() < deadline:
if condition.call():
return true
await get_tree().process_frame
return condition.call()
func _key_state(keycode: Key, unicode: int, pressed: bool) -> void:
var event := InputEventKey.new()
event.keycode = keycode
event.physical_keycode = keycode
event.unicode = unicode
event.pressed = pressed
get_tree().root.push_input(event)
func _key(keycode: Key, unicode: int) -> void:
_key_state(keycode, unicode, true)
_key_state(keycode, unicode, false)
func _type(value: String) -> void:
for i in value.length():
var codepoint := value.unicode_at(i)
_key(OS.find_keycode_from_string(value[i].to_upper()), codepoint)
func _finish(output: String, report: Dictionary) -> void:
report["cases"] = _cases
report["status"] = "FAIL" if _failed else "PASS"
var file := FileAccess.open(output.path_join("client-report.json"), FileAccess.WRITE)
if file == null:
printerr("BETA_PROBE: cannot write client report")
get_tree().quit(1)
return
file.store_string(JSON.stringify(report, " "))
file.close()
print("BETA_PROBE: " + report["status"])
if not _py("import app\napp.Exit()"):
Metin2PythonHost.stop()
get_tree().quit(1)
return
# PythonUISurface observes app.Exit, stops the interpreter and ends this process.
func _run() -> void:
var output := OS.get_environment("MT_BETA_OUTPUT")
if output.is_empty() or not output.is_absolute_path():
printerr("BETA_PROBE: MT_BETA_OUTPUT must be an absolute directory")
get_tree().quit(2)
return
DirAccess.make_dir_recursive_absolute(output)
var report := {"schema_version": 1, "route": "native_40250", "map": "", "draw_count": 0}
var host := OS.get_environment("MT_LIVE_HOST")
var login := OS.get_environment("MT_LIVE_LOGIN")
var password := OS.get_environment("MT_LIVE_PASSWORD")
if not _check(not host.is_empty() and not login.is_empty() and not password.is_empty(), "credentials_present"):
_finish(output, report)
return
if OS.get_environment("MT_BETA_REQUIRE_BUNDLE") == "1":
var bundle := OS.get_executable_path().get_base_dir().path_join("../Resources/40250/Client").simplify_path()
if not _check(AssetRoot.client_path() == bundle, "bundled_client_selected"):
_finish(output, report)
return
if not _check(AssetRoot.pack_ready() and Metin2PythonHost.is_app_looping(), "bundled_pack_and_python_loop"):
_finish(output, report)
return
if not _check(await _until(20, func(): return _py(
"import __main__, networkModule, introLogin, introSelect, game\n"
+ "__main__._stream = [o for o in __import__('gc').get_objects() if isinstance(o, networkModule.MainStream)][0]\n"
+ "assert isinstance(_stream.curPhaseWindow, introLogin.LoginWindow)")), "login_window"):
_finish(output, report)
return
if not _check(_py("import os\n"
+ "_env = os.environ\n"
+ "_slot = int(_env.get('MT_LIVE_SLOT', '0'))\n"
+ "_stream.SetConnectInfo(_env['MT_LIVE_HOST'], int(_env.get('MT_LIVE_GAME_PORT', '13000')), "
+ "_env['MT_LIVE_HOST'], int(_env.get('MT_LIVE_AUTH_PORT', '11000')))\n"
+ "_w = _stream.curPhaseWindow\n"
+ "_w._LoginWindow__OpenLoginBoard()\n"
+ "_w.idEditLine.SetText('')\n"
+ "_w.pwdEditLine.SetText('')\n"
+ "_w.idEditLine.SetFocus()"), "login_board"):
_finish(output, report)
return
_type(login)
_key(KEY_TAB, 0)
_type(password)
if not _check(_py("assert _w.idEditLine.GetText() == _env['MT_LIVE_LOGIN']\n"
+ "assert _w.pwdEditLine.GetText() == _env['MT_LIVE_PASSWORD']\n"
+ "del _env, _w"), "keyboard_input"):
_finish(output, report)
return
_key(KEY_ENTER, 0)
var selecting := await _until(30, func(): return _py(
"assert isinstance(_stream.curPhaseWindow, introSelect.SelectCharacterWindow)"))
if not _check(selecting, "character_select"):
_finish(output, report)
return
if not _check(_py("import net\n"
+ "assert net.GetAccountCharacterSlotDataString(_slot, net.ACCOUNT_CHARACTER_SLOT_NAME)\n"
+ "_stream.curPhaseWindow.SelectSlot(_slot)\n"
+ "_stream.curPhaseWindow.StartGame()"), "selected_character"):
_finish(output, report)
return
var in_game := await _until(70, func(): return _py(
"assert isinstance(_stream.curPhaseWindow, game.GameWindow) and _stream.curPhaseWindow.IsShow()"))
if not _check(in_game, "game_window"):
_finish(output, report)
return
var world: Node = get_parent().get_node("Python3DSurface")
_check(await _until(10, func(): return world.draw_count > 0), "character_draws")
_check(not Metin2PythonHost.current_map_name().is_empty(), "map_selected")
_check(int(world.terrain_report.get("chunks_splatted", 0)) > 0, "terrain_textured")
_check(_py("assert net.IsConnect()"), "network_connected")
if _check(_py("import player\n_beta_start_pos = player.GetMainCharacterPosition()"), "movement_start_position"):
_key_state(KEY_UP, 0, true)
await get_tree().create_timer(1.5).timeout
_key_state(KEY_UP, 0, false)
await get_tree().create_timer(0.5).timeout
_check(_py("_beta_end_pos = player.GetMainCharacterPosition()\n"
+ "assert abs(_beta_end_pos[0] - _beta_start_pos[0])"
+ " + abs(_beta_end_pos[1] - _beta_start_pos[1]) > 20"), "movement_position_changed")
# Let the loading fade finish before assessing what a player actually sees.
await get_tree().create_timer(2.0).timeout
report["draw_count"] = world.draw_count
report["map"] = Metin2PythonHost.current_map_name()
report["terrain_chunks"] = int(world.terrain_report.get("chunks_splatted", 0))
if DisplayServer.get_name() != "headless":
await RenderingServer.frame_post_draw
var screenshot := get_viewport().get_texture().get_image()
_check(screenshot.save_png(output.path_join("game.png")) == OK, "screenshot")
var lit := 0
for y in range(screenshot.get_height() / 4, screenshot.get_height() * 3 / 4, 12):
for x in range(screenshot.get_width() / 4, screenshot.get_width() * 3 / 4, 12):
if screenshot.get_pixel(x, y).get_luminance() > 0.05:
lit += 1
report["centre_lit_samples"] = lit
_check(lit > 30, "scene_visible_after_loading")
_finish(output, report)
+1
View File
@@ -0,0 +1 @@
uid://b0y8dh6cv657q
+5 -1
View File
@@ -32,7 +32,6 @@ func _ready() -> void:
# 启动 40250 原生 port 路径
func _start_port_route() -> void:
AssetPack.ensure()
if not (ClassDB.class_exists("Metin2PythonHost") and AssetRoot.pack_ready()):
printerr("[client] port 路由缺少 Metin2PythonHost 或 40250 Client/pack(%s)" % AssetRoot.client_path())
get_tree().quit(1)
@@ -52,3 +51,8 @@ func _start_port_route() -> void:
if err != "":
printerr("port: " + err)
get_tree().quit(1)
return
if OS.get_environment("MT_TEST_MODE") == "beta_probe":
var probe: Node = preload("res://beta_live_probe.gd").new()
probe.name = "BetaLiveProbe"
add_child(probe)
+7
View File
@@ -45,6 +45,13 @@ var _owns_app := false
func run_app() -> String:
if not AssetRoot.pack_ready():
return "no 40250 Client/pack at %s" % AssetRoot.client_path()
# The exported Godot executable starts in Contents/Resources. 40250 writes mouse.cfg,
# metin2.cfg and other player settings relative to CWD, so move those writes to user://.
var data_dir := OS.get_user_data_dir()
if DirAccess.make_dir_recursive_absolute(data_dir) != OK:
return "cannot create user data directory at %s" % data_dir
OS.set_environment("MT_40250_CLIENT", AssetRoot.client_path())
OS.set_environment("MT_40250_USER_DATA_DIR", data_dir)
var err: String = Metin2PythonHost.start("")
if err != "":
return err
+104
View File
@@ -0,0 +1,104 @@
#!/usr/bin/env bash
# Run the exported native 40250 macOS client from an unrelated working directory.
# Credentials come only from MT_LIVE_LOGIN / MT_LIVE_PASSWORD in the environment.
set -euo pipefail
set +x
cd "$(dirname "$0")/.."
repo="$PWD"
app="${1:-}"
output="${2:-$repo/build/beta-probe-$(date +%Y%m%d-%H%M%S)-$$}"
if [ -z "$app" ] || [ ! -d "$app" ]; then echo 'BETA_PROBE: BLOCKED provide an exported .app' >&2; exit 2; fi
if [ -z "${MT_LIVE_HOST:-}" ] || [ -z "${MT_LIVE_LOGIN:-}" ] || [ -z "${MT_LIVE_PASSWORD:-}" ]; then
echo 'BETA_PROBE: BLOCKED MT_LIVE_HOST, MT_LIVE_LOGIN and MT_LIVE_PASSWORD are required' >&2
exit 2
fi
app="$(cd "$app" && pwd)"
if [ -e "$output" ] && [ -n "$(find "$output" -mindepth 1 -print -quit)" ]; then
echo 'BETA_PROBE: BLOCKED output directory is not empty' >&2
exit 2
fi
mkdir -p "$output"
output="$(cd "$output" && pwd)"
engine="$app/Contents/MacOS/mtgodot-poc"
client="$app/Contents/Resources/40250/Client"
if [ ! -x "$engine" ] || [ ! -s "$client/pack/Index" ] || ! codesign --verify --strict "$app" >/dev/null 2>&1; then
echo 'BETA_PROBE: BLOCKED bundle executable, 40250 pack or signature is invalid' >&2
exit 2
fi
bash script/playable_build_info.sh "$app" >"$output/candidate.json"
python3 - "$client" "$app/Contents/Resources/beta-build.json" "$output/candidate.json" <<'PY'
import hashlib, json, pathlib, sys
client, build_manifest, output = map(pathlib.Path, sys.argv[1:])
digest = hashlib.sha256()
for path in sorted(p for p in client.rglob('*') if p.is_file()):
relative = path.relative_to(client).as_posix().encode()
digest.update(len(relative).to_bytes(4, 'big'))
digest.update(relative)
with path.open('rb') as stream:
for chunk in iter(lambda: stream.read(1024 * 1024), b''):
digest.update(chunk)
candidate = json.loads(output.read_text())
candidate.update(json.loads(build_manifest.read_text()))
candidate['client_resources_sha256'] = digest.hexdigest()
output.write_text(json.dumps(candidate, indent=2) + '\n')
PY
# Python owns exactly this child, enforces the wall-clock deadline, and writes only the process
# code and status to its report. The password is inherited by the child, never placed in argv.
python3 - "$engine" "$output" <<'PY'
import datetime, json, os, pathlib, platform, re, subprocess, sys, tempfile
engine, output = sys.argv[1:]
root = pathlib.Path(output)
env = os.environ.copy()
for key in ('MT_40250_CLIENT', 'MT_ASSETS', 'MT_ASSETS_ZIP', 'MT_PYTHON_STDLIB'):
env.pop(key, None)
env['MT_TEST_MODE'] = 'beta_probe'
env['MT_BETA_OUTPUT'] = output
env['MT_BETA_REQUIRE_BUNDLE'] = '1'
timed_out = False
with tempfile.TemporaryDirectory(prefix='mt-beta-cwd-') as cwd, (root / 'client.log').open('wb') as log:
proc = subprocess.Popen([engine], cwd=cwd, env=env, stdout=log, stderr=subprocess.STDOUT)
try:
code = proc.wait(timeout=180)
except subprocess.TimeoutExpired:
timed_out = True
proc.terminate()
try:
code = proc.wait(timeout=10)
except subprocess.TimeoutExpired:
proc.kill()
code = proc.wait()
client = None
try:
client = json.loads((root / 'client-report.json').read_text())
except (OSError, ValueError):
pass
log_path = root / 'client.log'
log_text = log_path.read_text(errors='replace')
# Native trace output is outside the probe's control; keep account credentials out of artifacts.
for secret in (env.get('MT_LIVE_PASSWORD', ''), env.get('MT_LIVE_LOGIN', '')):
if secret:
log_text = log_text.replace(secret, '[REDACTED]')
log_path.write_text(log_text)
errors = []
if timed_out: errors.append('wall_clock_timeout')
if code != 0: errors.append('client_exit_nonzero')
if not isinstance(client, dict) or client.get('route') != 'native_40250' or client.get('status') != 'PASS':
errors.append('native_client_report_missing_or_failed')
if not (root / 'game.png').is_file(): errors.append('screenshot_missing')
if re.search(r'SCRIPT ERROR|Parse Error|^ERROR:|leaked at exit|shaders of type .* were never freed', log_text, re.M):
errors.append('client_log_error')
if re.search(r'Cannot open property pack|CArea::LoadObject Property\(\d+\) Load ERROR', log_text):
errors.append('map_property_load_error')
candidate = json.loads((root / 'candidate.json').read_text())
report = {'schema_version': 1, 'status': 'PASS' if not errors else 'FAIL',
'tested_at_utc': datetime.datetime.now(datetime.timezone.utc).isoformat(),
'device': {'macos': platform.mac_ver()[0], 'arch': platform.machine()},
'candidate': candidate, 'process_code': code, 'timed_out': timed_out,
'cases': client.get('cases', []) if isinstance(client, dict) else [],
'map': client.get('map', '') if isinstance(client, dict) else '',
'errors': errors}
(root / 'report.json').write_text(json.dumps(report, indent=2) + '\n')
print('BETA_PROBE: ' + report['status'] + ' report=' + str(root / 'report.json'))
sys.exit(0 if not errors else 1)
PY