Build self-contained macOS client and verify native beta path
This commit is contained in:
+23
-6
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;只注册一次,之后返回首次结果。
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
uid://vqw8iy17y4m0
|
||||
@@ -0,0 +1 @@
|
||||
uid://dfbxpg1q8a0q8
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://b0y8dh6cv657q
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Executable
+104
@@ -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
|
||||
Reference in New Issue
Block a user