From a57c83dd7188ea4cc15fb52c80340929300be71a Mon Sep 17 00:00:00 2001 From: shen <> Date: Thu, 24 Sep 2026 00:59:08 -0700 Subject: [PATCH] Build self-contained macOS client and verify native beta path --- build-macos-client.sh | 29 +++- .../src/platform/ScriptLib/PythonBoot.cpp | 13 +- .../port/UserInterface/PythonBackground.cpp | 7 +- extension/src/python_stdlib.cpp | 10 +- project/asset_root.gd | 7 +- project/audio_driver.gd.uid | 1 + project/audio_driver_test.gd.uid | 1 + project/beta_live_probe.gd | 157 ++++++++++++++++++ project/beta_live_probe.gd.uid | 1 + project/client_main.gd | 6 +- project/python_ui_surface.gd | 7 + script/beta_probe.sh | 104 ++++++++++++ 12 files changed, 331 insertions(+), 12 deletions(-) create mode 100644 project/audio_driver.gd.uid create mode 100644 project/audio_driver_test.gd.uid create mode 100644 project/beta_live_probe.gd create mode 100644 project/beta_live_probe.gd.uid create mode 100755 script/beta_probe.sh diff --git a/build-macos-client.sh b/build-macos-client.sh index 9543fb62..a137b97c 100755 --- a/build-macos-client.sh +++ b/build-macos-client.sh @@ -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 diff --git a/extension/src/platform/ScriptLib/PythonBoot.cpp b/extension/src/platform/ScriptLib/PythonBoot.cpp index 19fe9223..0eb227f6 100644 --- a/extension/src/platform/ScriptLib/PythonBoot.cpp +++ b/extension/src/platform/ScriptLib/PythonBoot.cpp @@ -49,6 +49,7 @@ #include #include +#include #include #include #include @@ -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()) diff --git a/extension/src/port/UserInterface/PythonBackground.cpp b/extension/src/port/UserInterface/PythonBackground.cpp index 8b4a89de..bcf95dd4 100644 --- a/extension/src/port/UserInterface/PythonBackground.cpp +++ b/extension/src/port/UserInterface/PythonBackground.cpp @@ -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()); } } diff --git a/extension/src/python_stdlib.cpp b/extension/src/python_stdlib.cpp index 549dd07c..a20f413b 100644 --- a/extension/src/python_stdlib.cpp +++ b/extension/src/python_stdlib.cpp @@ -7,6 +7,7 @@ #include #include +#include #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(); diff --git a/project/asset_root.gd b/project/asset_root.gd index f7d55eca..a7abad90 100644 --- a/project/asset_root.gd +++ b/project/asset_root.gd @@ -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") 注册 /pack;只注册一次,之后返回首次结果。 diff --git a/project/audio_driver.gd.uid b/project/audio_driver.gd.uid new file mode 100644 index 00000000..25611f98 --- /dev/null +++ b/project/audio_driver.gd.uid @@ -0,0 +1 @@ +uid://vqw8iy17y4m0 diff --git a/project/audio_driver_test.gd.uid b/project/audio_driver_test.gd.uid new file mode 100644 index 00000000..f2561ac6 --- /dev/null +++ b/project/audio_driver_test.gd.uid @@ -0,0 +1 @@ +uid://dfbxpg1q8a0q8 diff --git a/project/beta_live_probe.gd b/project/beta_live_probe.gd new file mode 100644 index 00000000..11afbcfb --- /dev/null +++ b/project/beta_live_probe.gd @@ -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) diff --git a/project/beta_live_probe.gd.uid b/project/beta_live_probe.gd.uid new file mode 100644 index 00000000..f26bfc43 --- /dev/null +++ b/project/beta_live_probe.gd.uid @@ -0,0 +1 @@ +uid://b0y8dh6cv657q diff --git a/project/client_main.gd b/project/client_main.gd index e6a3a7fc..d569c6a1 100644 --- a/project/client_main.gd +++ b/project/client_main.gd @@ -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) diff --git a/project/python_ui_surface.gd b/project/python_ui_surface.gd index cf05e31c..58051f7a 100644 --- a/project/python_ui_surface.gd +++ b/project/python_ui_surface.gd @@ -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 diff --git a/script/beta_probe.sh b/script/beta_probe.sh new file mode 100755 index 00000000..333143be --- /dev/null +++ b/script/beta_probe.sh @@ -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