Implement playable Mac client and rendering validation

This commit is contained in:
shen
2026-09-11 14:58:22 +08:00
parent 1ab68bd06e
commit 374d4165d8
233 changed files with 6546 additions and 284 deletions
+43
View File
@@ -0,0 +1,43 @@
import fs from 'node:fs';
import path from 'node:path';
import crypto from 'node:crypto';
import {execFileSync} from 'node:child_process';
const assets = process.argv[2] ?? 'assets';
const output = process.argv[3];
const manifest = JSON.parse(fs.readFileSync(path.join(assets, 'TreeGeometry/manifest.json')));
const normalize = s => s.replaceAll('\\', '/').toLowerCase().replace(/^.*?ymir work\//, 'ymir work/');
const converted = new Set(Object.values(manifest.trees).map(r => normalize(r.source)));
const list = pattern => execFileSync('rg', ['--files', assets, '-g', pattern], {encoding: 'utf8', maxBuffer: 8 * 1024 * 1024}).trim().split('\n').filter(Boolean);
const references = new Set();
const propertyReferences = new Map();
for (const file of list('*.prt')) {
const source = fs.readFileSync(file, 'utf8');
for (const match of source.matchAll(/^\s*treefile\s+"([^"]+)"/gmi)) {
const reference = normalize(match[1]);
references.add(reference);
propertyReferences.set(source.split(/\r?\n/)[1].trim(), reference);
}
}
const placedReferences = new Set();
for (const file of list('areadata.txt')) {
const source = fs.readFileSync(file, 'utf8');
for (const match of source.matchAll(/^Start Object\d+\r?\n[^\n]*\n\s*(\d+)/gm)) {
const reference = propertyReferences.get(match[1]);
if (reference) placedReferences.add(reference);
}
}
const unmatched = [];
let matched = 0;
for (const file of list('*.spt')) {
const hash = crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
if (manifest.trees[hash]) matched++;
else unmatched.push({file, source_sha256: hash, referenced_by_property: references.has(normalize(file))});
}
const missing_referenced = [...references].filter(r => !converted.has(r)).sort();
const missing_placed = [...placedReferences].filter(r => !converted.has(r)).sort();
const report = {matched_spt_files: matched, converted_species: converted.size, referenced_species: references.size,
placed_species: placedReferences.size, missing_placed,
missing_referenced, unconverted_spt: unmatched, scope: 'SPT inventory and text PRT/AreaData references; six-map runtime validation is separate'};
if (output) fs.writeFileSync(output, JSON.stringify(report, null, 2) + '\n');
console.log(JSON.stringify({matched, converted: converted.size, referenced: references.size, placed: placedReferences.size, missing_placed, missing_referenced, unconverted: unmatched.length}));
if (!placedReferences.size || missing_placed.length) process.exitCode = 1;
+130
View File
@@ -0,0 +1,130 @@
#!/usr/bin/env node
/* Static prerequisite audit for the first playable-map fixture. It does not
* connect to a server and cannot prove which monsters a live server spawned. */
import fs from 'node:fs';
import path from 'node:path';
import crypto from 'node:crypto';
function parseArgs(argv) {
const out = { assets: 'assets', output: '', maps: ['outdoortrent/metin2_map_trent', 'outdoortrent02/metin2_map_trent02'] };
for (let i = 0; i < argv.length; i += 1) {
const a = argv[i];
if (a === '--help') { console.log('usage: audit_playable_maps.mjs [--assets DIR] [--maps DIR[,DIR...]] [--output FILE]'); process.exit(0); }
if (!a.startsWith('--') || i + 1 >= argv.length) throw new Error(`invalid argument: ${a}`);
const k = a.slice(2).replaceAll('-', '_');
out[k] = argv[++i];
}
if (typeof out.maps === 'string') out.maps = out.maps.split(',').map(s => s.trim()).filter(Boolean);
return out;
}
function filesUnder(root) {
if (!fs.existsSync(root)) return [];
const result = [];
const visit = dir => {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) visit(full); else result.push(full);
}
};
visit(root);
return result;
}
const norm = value => value.replaceAll('\\', '/').toLowerCase().replace(/^.*?ymir work\//, 'ymir work/');
const read = file => fs.readFileSync(file, 'utf8');
const sha256 = file => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
function parseProperties(allFiles) {
const byId = new Map();
for (const file of allFiles.filter(f => f.toLowerCase().endsWith('.prt'))) {
const lines = read(file).split(/\r?\n/);
const id = lines[1]?.trim();
if (!/^\d+$/.test(id || '')) continue;
const text = lines.join('\n');
const tree = text.match(/^\s*treefile\s+"([^"]+)"/im)?.[1] || '';
const type = text.match(/^\s*propertytype\s+"([^"]+)"/im)?.[1] || '';
byId.set(id, { id: Number(id), file, type, treefile: tree });
}
return byId;
}
function parseArea(file) {
const text = read(file);
const objects = [];
const blockRe = /Start Object\d+\r?\n([^]*?)\r?\nEnd Object/g;
for (const match of text.matchAll(blockRe)) {
const lines = match[1].split(/\r?\n/).map(s => s.trim()).filter(Boolean);
if (lines.length < 2) continue;
const id = Number(lines[1]);
if (Number.isInteger(id)) objects.push({ property_id: id, position: lines[0] });
}
return objects;
}
function parseNpcList(file) {
const result = new Map();
if (!fs.existsSync(file)) return result;
for (const line of read(file).split(/\r?\n/)) {
const parts = line.trim().split(/\s+/);
if (parts.length >= 2 && /^\d+$/.test(parts[0])) result.set(Number(parts[0]), parts[1]);
}
return result;
}
function mobRecord(assets, code, race, allFiles) {
const candidates = allFiles.filter(file => file.toLowerCase().endsWith('.msm') && path.basename(file, '.msm').toLowerCase() === code.toLowerCase())
.filter(file => file.toLowerCase().includes(`/monster2/`) || file.toLowerCase().includes(`/monster/`));
const msm = candidates[0] || '';
const dir = msm ? path.dirname(msm) : '';
const sibling = name => dir && fs.existsSync(path.join(dir, name)) ? path.join(dir, name) : '';
const motlist = sibling('motlist.txt');
const actions = motlist ? [...read(motlist).matchAll(/^\s*\S+\s+([A-Z0-9_]+)\s+([^\s]+\.msa)/gmi)].map(m => ({ name: m[1], file: m[2], exists: fs.existsSync(path.join(dir, m[2])) })) : [];
return {
race, code, directory: dir ? path.relative(assets, dir).replaceAll('\\', '/') : '',
files: { msm: Boolean(msm), gr2: Boolean(sibling(`${code}.gr2`)), dds: Boolean(sibling(`${code}.dds`)), motlist: Boolean(motlist) },
action_count: actions.length, actions,
required_states: Object.fromEntries(['WAIT', 'RUN', 'NORMAL_ATTACK', 'FRONT_DAMAGE', 'FRONT_DEAD'].map(state => [state, actions.some(a => a.name === state)])),
source_sha256: msm ? sha256(msm) : null,
};
}
function main() {
const options = parseArgs(process.argv.slice(2));
const assets = path.resolve(options.assets);
const allFiles = filesUnder(assets);
const manifestPath = path.join(assets, 'TreeGeometry', 'manifest.json');
const manifest = fs.existsSync(manifestPath) ? JSON.parse(read(manifestPath)) : { trees: {} };
const sourceByName = new Map(Object.values(manifest.trees || {}).map(item => [norm(item.source), item]));
const properties = parseProperties(allFiles);
const maps = options.maps.map(key => {
const root = path.join(assets, key);
const areaFiles = filesUnder(root).filter(f => path.basename(f).toLowerCase() === 'areadata.txt');
const objects = areaFiles.flatMap(file => parseArea(file).map(item => ({ ...item, file: path.relative(assets, file).replaceAll('\\', '/') })));
const propertyIds = [...new Set(objects.map(item => item.property_id))];
const resolved = propertyIds.map(id => {
const prop = properties.get(String(id));
if (!prop) return { property_id: id, resolved: false };
const record = sourceByName.get(norm(prop.treefile));
return { property_id: id, resolved: true, property_type: prop.type, property_file: path.relative(assets, prop.file).replaceAll('\\', '/'),
treefile: prop.treefile, native_tree: Boolean(record), source_sha256: record?.source_sha256 || null };
});
const treeObjects = objects.filter(item => resolved.find(r => r.property_id === item.property_id)?.native_tree);
return { key, exists: fs.existsSync(root), setting: fs.existsSync(path.join(root, 'setting.txt')),
area_files: areaFiles.length, object_count: objects.length, property_count: propertyIds.length,
resolved_properties: resolved.length, unresolved_property_ids: resolved.filter(r => !r.resolved).map(r => r.property_id),
tree_object_count: treeObjects.length, native_tree_object_count: treeObjects.length,
properties: resolved };
});
const npc = parseNpcList(path.join(assets, 'root/npclist.txt'));
const treeMobRaces = [...Array(15)].map((_, i) => 2301 + i).filter(race => npc.has(race));
const mobs = treeMobRaces.map(race => mobRecord(assets, npc.get(race), race, allFiles));
const report = { schema_version: 1, suite: 'playable-map-assets', assets_root: assets,
maps, tree_monster_candidates: mobs,
caveat: 'Static map AreaData and local resources only; server spawn membership and visual correctness require live/map-render tests.' };
if (options.output) { fs.mkdirSync(path.dirname(path.resolve(options.output)), { recursive: true }); fs.writeFileSync(options.output, `${JSON.stringify(report, null, 2)}\n`); }
console.log(JSON.stringify({ maps: maps.map(m => ({ key: m.key, exists: m.exists, objects: m.object_count, native_tree_objects: m.native_tree_object_count, unresolved: m.unresolved_property_ids.length })), tree_monsters: mobs.length }));
if (maps.some(m => !m.exists || !m.setting || m.area_files === 0) || mobs.some(m => !m.files.msm || !m.files.gr2 || !m.files.dds || !m.files.motlist)) process.exitCode = 1;
}
main();
+22
View File
@@ -0,0 +1,22 @@
#!/usr/bin/env bash
# Reproducible Metal-only release template used by the particle exit checks.
# Does not replace Godot's installed templates. Pass the resulting executable
# to build-macos-client.sh using MT_MAC_ENGINE and the matching MT_MAC_ARCHES.
set -euo pipefail
cd "$(dirname "$0")/.."
repo_dir="$PWD"
source_dir="$repo_dir/build/godot-particle-diagnostic"
revision="a13da4feb8d8aefc283c3763d33a2f170a18d541"
engine_arch="${1:-arm64}"
case "$engine_arch" in arm64|x86_64) ;; *) echo "Expected arm64 or x86_64" >&2; exit 1 ;; esac
if [ ! -d "$source_dir" ]; then
git clone --depth 1 --branch 4.7.1-stable https://github.com/godotengine/godot.git "$source_dir"
fi
test "$(git -C "$source_dir" rev-parse HEAD)" = "$revision"
git -C "$source_dir" diff --exit-code
git -C "$source_dir" diff --cached --exit-code
cd "$source_dir"
scons platform=macos arch="$engine_arch" target=template_release \
debug_symbols=yes optimize=speed lto=none vulkan=no -j8
echo "Engine: $source_dir/bin/godot.macos.template_release.$engine_arch"
shasum -a 256 "bin/godot.macos.template_release.$engine_arch"
+9 -4
View File
@@ -12,9 +12,9 @@ set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
REPO="$(cd "$SCRIPT_DIR/.." && pwd)"
APP="$REPO/build/export/mtgodot-poc.app"
APP="${MT_SMOKE_APP:-$REPO/build/export/mtgodot-poc.app}"
EXECUTABLE="$APP/Contents/MacOS/mtgodot-poc"
ARTIFACT_DIR="$REPO/build/live-smoke"
ARTIFACT_DIR="${MT_SMOKE_OUTPUT:-$REPO/build/live-smoke}"
LOG="$ARTIFACT_DIR/live-smoke.log"
REPORT="$ARTIFACT_DIR/live-smoke-report.json"
CONFIG_FILE="${MT_SMOKE_CONFIG:-$REPO/.env.live-smoke.local}"
@@ -31,6 +31,7 @@ usage() {
--no-build 使用现有 APP,不重新打包
--no-mutations 只做登录、进游戏和数据读取,不发送移动/聊天/重连包
--no-reconnect 做移动和聊天,但跳过重连
--reconnect-only 只测试登录/主动重连,不发送移动、聊天或施法
--help 显示帮助
环境变量:
@@ -42,8 +43,9 @@ EOF
while [ "$#" -gt 0 ]; do
case "$1" in
--no-build) BUILD=0 ;;
--no-mutations) MUTATIONS=0 ;;
--no-mutations) MUTATIONS=0; RECONNECT=0 ;;
--no-reconnect) RECONNECT=0 ;;
--reconnect-only) MUTATIONS=0; RECONNECT=1 ;;
--help) usage; exit 0 ;;
*) echo "未知选项: $1" >&2; usage >&2; exit 2 ;;
esac
@@ -90,7 +92,10 @@ for port in 11000 13002; do
fi
done
pkill -x mtgodot-poc 2>/dev/null || true
if pgrep -x mtgodot-poc >/dev/null; then
echo "已有客户端运行,请先关闭后再执行独立登录测试。" >&2
exit 2
fi
export MT_PROTOCOL=classic
export MT_AUTOLOGIN=1
+18
View File
@@ -0,0 +1,18 @@
#!/usr/bin/env bash
# A JSON PASS is insufficient: shutdown happens after the report is written.
# Keep the exported client's complete log and fail on renderer/RID leaks too.
set -euo pipefail
cd "$(dirname "$0")/.."
app_path="${MT_RENDER_APP:-$PWD/build/export/mtgodot-poc.app}"
output_dir="${MT_RENDER_OUTPUT:-$PWD/build/rendering/package-gate-$(date +%Y%m%d-%H%M%S)-$$}"
test -x "$app_path/Contents/MacOS/mtgodot-poc"
mkdir -p "$output_dir"
status=0
env -u MT_ASSETS MT_TEST_MODE=render MT_RENDER_OUTPUT="$output_dir" \
"$app_path/Contents/MacOS/mtgodot-poc" --quit-after 1800 >"$output_dir/client.log" 2>&1 || status=$?
if [ "$status" -ne 0 ] || ! rg -q '^PKGRENDER: PASS$' "$output_dir/client.log" || \
rg -q 'SCRIPT ERROR|^ERROR:|leaked at exit|shaders of type .* were never freed' "$output_dir/client.log"; then
echo "FAIL: package load/exit gate (exit=$status): $output_dir/client.log"
exit 1
fi
echo "PASS: package load and clean exit: $output_dir/client.log"
+14
View File
@@ -0,0 +1,14 @@
extends SceneTree
const Config = preload("res://testing/playable_config.gd")
func _init() -> void:
var path := OS.get_environment("MT_PLAYABLE_VALIDATE_CONFIG")
var result := Config.load_file(path)
if not result.ok:
for error in result.errors:
printerr("CONFIG: " + String(error))
quit(2)
return
print("PLAYABLE CONFIG: PASS " + path)
quit(0)
+44
View File
@@ -0,0 +1,44 @@
#!/usr/bin/env bash
# INF-02 negative/positive checks. Uses only static fixtures and a temporary
# output directory; it never starts the game or contacts the server.
set -euo pipefail
cd "$(dirname "$0")/.."
tmp_dir="$(mktemp -d "${TMPDIR:-/tmp}/mt-playable-gate.XXXXXX")"
trap 'rm -R "$tmp_dir"' EXIT
node script/validate_playable_report.mjs \
--report test/playable/report.valid.json \
--output "$tmp_dir/valid.json" \
--log test/playable/clean.log --process-code 0 --require-pass
if node script/validate_playable_report.mjs \
--report test/playable/report.blocked.json \
--output "$tmp_dir/blocked.json" \
--log test/playable/clean.log --process-code 0 --require-pass; then
echo "FAIL: BLOCKED fixture was accepted" >&2
exit 1
fi
if node script/validate_playable_report.mjs \
--report test/playable/report.valid.json \
--output "$tmp_dir/exit.json" \
--log test/playable/clean.log --process-code 1 --require-pass; then
echo "FAIL: non-zero child exit was accepted" >&2
exit 1
fi
if node script/validate_playable_report.mjs \
--report test/playable/report.valid.json \
--output "$tmp_dir/rid.json" \
--log test/playable/rid-warning.log --process-code 0 --require-pass; then
echo "FAIL: renderer warning was accepted" >&2
exit 1
fi
if MT_PLAYABLE_VALIDATE_CONFIG="$PWD/test/playable/scenario.example.json" \
godot --headless --path project --script playable_config_gate.gd; then
echo "FAIL: empty scenario configuration was accepted" >&2
exit 1
fi
echo "playable_gate_test: PASS"
+56
View File
@@ -0,0 +1,56 @@
#!/usr/bin/env bash
# Public entry for the first playable client gate. It delegates process
# ownership and post-exit validation to run_client_gate.sh.
set -euo pipefail
cd "$(dirname "$0")/.."
app="${MT_PLAYABLE_APP:-$PWD/build/export-native-trees-20260911/mtgodot-poc.app}"
config=""
output=""
mode="playable"
timeout="180"
allow=0
repeat=1
suite="full"
usage() {
cat <<'EOF'
用法: script/playable_test.sh --config CONFIG [选项]
--app APP 导出包路径
--output DIR 单次运行输出目录(repeat=1)
--suite full 当前固定为 full/playable
--allow-gameplay 允许发送游戏状态变更请求
--repeat N 独立进程运行次数,默认 1
--timeout-seconds N 每次墙钟超时,默认 180
EOF
}
while [ "$#" -gt 0 ]; do
case "$1" in
--app) app="$2"; shift 2 ;;
--config) config="$2"; shift 2 ;;
--output) output="$2"; shift 2 ;;
--suite) suite="$2"; shift 2 ;;
--allow-gameplay) allow=1; shift ;;
--repeat) repeat="$2"; shift 2 ;;
--timeout-seconds) timeout="$2"; shift 2 ;;
--help) usage; exit 0 ;;
*) echo "未知选项: $1" >&2; usage >&2; exit 2 ;;
esac
done
if [ -z "$config" ] || [ ! -f "$config" ]; then echo "需要 --config CONFIG" >&2; exit 2; fi
if [ "$suite" != "full" ] && [ "$suite" != "playable" ]; then echo "--suite 只支持 full/playable" >&2; exit 2; fi
if ! [[ "$repeat" =~ ^[1-9][0-9]*$ ]]; then echo "--repeat 必须是正整数" >&2; exit 2; fi
if [ "$repeat" -gt 1 ] && [ -n "$output" ]; then echo "repeat>1 时不能指定单一 --output" >&2; exit 2; fi
root="$output"
if [ -z "$root" ]; then root="${MT_PLAYABLE_OUTPUT:-$PWD/build/playable/suite-$(date +%Y%m%d-%H%M%S)-$$}"; fi
mkdir -p "$root"
failed=0
for index in $(seq 1 "$repeat"); do
run_dir="$root"
if [ "$repeat" -gt 1 ]; then run_dir="$root/run-$index"; fi
args=(--app "$app" --config "$config" --output "$run_dir" --mode "$mode" --timeout-seconds "$timeout")
if [ "$allow" -eq 1 ]; then args+=(--allow-gameplay); fi
if ! bash script/run_client_gate.sh "${args[@]}"; then failed=$((failed + 1)); fi
done
if [ "$failed" -ne 0 ]; then echo "PLAYABLE SUITE: FAIL runs=$failed/$repeat" >&2; exit 1; fi
echo "PLAYABLE SUITE: PASS runs=$repeat root=$root"
+34
View File
@@ -0,0 +1,34 @@
#!/usr/bin/env bash
# Repeatable, non-network regression sweep; logs retain failures and warnings.
set -uo pipefail
cd "$(dirname "$0")/.."
run_dir="$(pwd)/build/rendering/batch-$(date +%Y%m%d-%H%M%S)-$$"
mkdir -p "$run_dir"
failed=0
run_test() {
local mode="$1" test_script="$2" log="$run_dir/$1-${2%.gd}.log"
local options=(--path project)
if [ "$mode" = headless ]; then options+=(--headless); fi
"${GODOT:-godot}" "${options[@]}" --script "$test_script" --quit-after 1800 >"$log" 2>&1
local status=$?
if [ "$status" -ne 0 ] || rg -q 'SCRIPT ERROR|Parse Error|^FAIL:|check\(s\) failed' "$log" || ! rg -q 'PASS|failures=0' "$log"; then
echo "FAIL $mode $test_script (exit=$status): $log"
failed=$((failed + 1))
else
echo "PASS $mode $test_script"
fi
if rg -q 'leaked at exit' "$log"; then echo "WARN resource leak: $log"; fi
}
for test_script in animation_cache_test.gd target_effect_test.gd effect_space_test.gd effect_faces_test.gd motion_effect_anchor_test.gd mob_winding_test.gd water_reference_test.gd effect_texture_animation_test.gd dds_lifecycle_test.gd effect_color_operation_test.gd effect_color_test.gd effect_scale_test.gd effect_rotation_test.gd effect_surface_test.gd effect_emission_test.gd effect_render_regression_test.gd effect_playback_test.gd fx_test.gd skill_fx_test.gd player_skill_test.gd skill_test.gd damage_effect_test.gd combat_fx_test.gd app_flow_lifecycle_test.gd playable_harness_test.gd playable_adapter_test.gd gamescene_test.gd equip_model_test.gd equip_rules_test.gd race_motion_assembly_test.gd character_winding_test.gd; do
run_test headless "$test_script"
done
for test_script in effect_faces_test.gd effect_lie_test.gd effect_texture_animation_test.gd effect_color_operation_test.gd effect_surface_test.gd effect_emission_test.gd effect_rotation_test.gd model_render_test.gd gpu_lod_attachment_test.gd gpu_pose_bounds_test.gd forest_mob_render_test.gd; do
run_test metal "$test_script"
done
for mode in headless metal; do
MT_TREE_OUTPUT="$run_dir/native-trees-$mode" run_test "$mode" native_tree_test.gd
run_test "$mode" effect_random_rotation_test.gd
run_test "$mode" fly_target_bounds_test.gd
done
echo "BATCH failures=$failed logs=$run_dir"
test "$failed" -eq 0
+125
View File
@@ -0,0 +1,125 @@
#!/usr/bin/env bash
# Run one exported client process and seal its report after exit.
# This script owns exactly one child PID; it never pkills other clients.
set -euo pipefail
cd "$(dirname "$0")/.."
app_path="${MT_PLAYABLE_APP:-$PWD/build/export-native-trees-20260911/mtgodot-poc.app}"
mode="playable"
config_path=""
output_dir="${MT_PLAYABLE_OUTPUT:-$PWD/build/playable/run-$(date +%Y%m%d-%H%M%S)-$$}"
timeout_seconds="${MT_PLAYABLE_TIMEOUT_SECONDS:-180}"
allow_gameplay=0
usage() {
cat <<'EOF'
用法: script/run_client_gate.sh --app APP --config CONFIG [选项]
选项:
--output DIR 本次运行目录,必须不存在或为空
--mode MODE 默认 playable
--timeout-seconds N 墙钟超时,默认 180
--allow-gameplay 允许专用测试账号发送移动/战斗/拾取请求
EOF
}
while [ "$#" -gt 0 ]; do
case "$1" in
--app) app_path="$2"; shift 2 ;;
--config) config_path="$2"; shift 2 ;;
--output) output_dir="$2"; shift 2 ;;
--mode) mode="$2"; shift 2 ;;
--timeout-seconds) timeout_seconds="$2"; shift 2 ;;
--allow-gameplay) allow_gameplay=1; shift ;;
--help) usage; exit 0 ;;
*) echo "未知选项: $1" >&2; usage >&2; exit 2 ;;
esac
done
if [ -z "$config_path" ] || [ ! -f "$config_path" ]; then
echo "配置不存在;不会启动客户端:$config_path" >&2
exit 2
fi
if [ ! -x "$app_path/Contents/MacOS/mtgodot-poc" ]; then
echo "找不到可执行包:$app_path" >&2
exit 2
fi
if ! [[ "$timeout_seconds" =~ ^[1-9][0-9]*$ ]]; then
echo "timeout-seconds 必须是正整数" >&2
exit 2
fi
if [ -e "$output_dir" ] && [ "$(find "$output_dir" -mindepth 1 -maxdepth 1 -print -quit)" ]; then
echo "输出目录已有内容,拒绝复用历史报告:$output_dir" >&2
exit 2
fi
mkdir -p "$output_dir"
log="$output_dir/client.log"
client_report="$output_dir/client-report.json"
final_report="$output_dir/report.json"
events="$output_dir/events.jsonl"
if ! MT_PLAYABLE_VALIDATE_CONFIG="$config_path" godot --headless --path project --script playable_config_gate.gd >"$output_dir/config.log" 2>&1; then
echo "配置校验失败;不会启动客户端:$output_dir/config.log" >&2
exit 2
fi
char_slot="$(node -e 'const fs=require("node:fs"); const p=process.argv[1]; const d=JSON.parse(fs.readFileSync(p,"utf8")); process.stdout.write(String(d.character_slot));' "$config_path")"
sha256_of() {
if [ -f "$1" ]; then shasum -a 256 "$1" | awk '{print $1}'; else echo ""; fi
}
engine="$app_path/Contents/MacOS/mtgodot-poc"
pck="$app_path/Contents/Resources/mtgodot-poc.pck"
dylib="$(find "$app_path/Contents/Frameworks" -maxdepth 1 -type f -name '*.dylib' -print -quit)"
export MT_TEST_MODE="$mode"
export MT_PLAYABLE_CONFIG="$config_path"
export MT_TEST_REPORT="$client_report"
export MT_TEST_EVENTS="$events"
export MT_TEST_OUTPUT="$output_dir"
export MT_PROTOCOL="classic"
export MT_AUTOLOGIN=1
export MT_CHAR_SLOT="$char_slot"
export MT_PLAYABLE_RUN_ID="$(basename "$output_dir")"
export MT_BUILD_ENGINE_SHA256="$(sha256_of "$engine")"
export MT_BUILD_EXTENSION_SHA256="$(sha256_of "$dylib")"
export MT_BUILD_PCK_SHA256="$(sha256_of "$pck")"
file_description="$(file "$engine")"
case "$file_description" in
*arm64*) export MT_BUILD_ARCH="arm64" ;;
*x86_64*) export MT_BUILD_ARCH="x86_64" ;;
*) export MT_BUILD_ARCH="unknown" ;;
esac
if [ "$allow_gameplay" -eq 1 ]; then export MT_PLAYABLE_ALLOW_GAMEPLAY=1; else export MT_PLAYABLE_ALLOW_GAMEPLAY=0; fi
start_epoch="$(date +%s)"
set +e
env -u MT_ASSETS "$engine" >"$log" 2>&1 &
child_pid=$!
timed_out=0
while kill -0 "$child_pid" 2>/dev/null; do
now="$(date +%s)"
if [ $((now - start_epoch)) -ge "$timeout_seconds" ]; then
timed_out=1
kill -TERM "$child_pid" 2>/dev/null || true
for _ in 1 2 3 4 5 6 7 8 9 10; do
kill -0 "$child_pid" 2>/dev/null || break
sleep 1
done
kill -KILL "$child_pid" 2>/dev/null || true
break
fi
sleep 1
done
wait "$child_pid"
app_status=$?
set -e
if [ "$timed_out" -eq 1 ]; then app_status=124; fi
set +e
node script/validate_playable_report.mjs --report "$client_report" --output "$final_report" \
--log "$log" --process-code "$app_status" --require-pass
gate_status=$?
set -e
if [ "$app_status" -ne 0 ] || [ "$gate_status" -ne 0 ]; then
echo "PLAYABLE GATE: FAIL exit=$app_status report=$final_report log=$log" >&2
exit 1
fi
echo "PLAYABLE GATE: PASS report=$final_report log=$log"
+44
View File
@@ -0,0 +1,44 @@
// Stage already-extracted, hash-matched native trees. Never manufacture geometry.
import fs from 'node:fs';
import path from 'node:path';
import crypto from 'node:crypto';
const [source, assets] = process.argv.slice(2);
if (!source || !assets) throw Error('Usage: node script/stage_tree_geometry.mjs CONVERTED_ROOT ASSETS_ROOT');
const hash = b => crypto.createHash('sha256').update(b).digest('hex');
const index = JSON.parse(fs.readFileSync(path.join(source, 'asset-index.json')));
const records = [];
for (const item of Object.values(index.assets)) {
if (item.kind !== 'tree') continue;
if (item.status !== 'converted' || item.build.native_extractor !== 'spt_extract.exe' ||
item.build.coordinate_policy !== 'raw_speedtree_z_up_centimeters_to_godot_x_z_neg_y_meters') throw Error(`Unverified tree: ${item.id}`);
const raw = item.source.raw;
const spt = fs.readFileSync(path.join(assets, raw.source_physical));
const bytes = fs.readFileSync(path.join(source, item.output));
if (hash(spt) !== raw.source_sha256 || hash(bytes) !== item.output_sha256) throw Error(`Hash mismatch: ${item.id}`);
if (bytes.readUInt32LE(0) !== 0x46546c67 || bytes.readUInt32LE(4) !== 2 || bytes.readUInt32LE(8) !== bytes.length) throw Error('Invalid GLB');
const gltf = JSON.parse(bytes.subarray(20, 20 + bytes.readUInt32LE(12)).toString());
// Runtime loads mesh zero directly: reject transforms, external data and scenes
// that would need flattening instead of silently dropping their transforms.
if (gltf.nodes.length !== 1 || gltf.nodes[0].mesh !== 0 || gltf.meshes.length !== 1 ||
['matrix', 'translation', 'rotation', 'scale', 'children'].some(k => k in gltf.nodes[0]) ||
gltf.buffers.some(b => b.uri) || (gltf.images ?? []).some(i => i.uri)) throw Error(`Unsupported scene: ${item.id}`);
records.push({bytes, key: raw.source_sha256, record: {
source: raw.source_physical, source_sha256: raw.source_sha256,
glb: `${raw.source_sha256}.glb`, glb_sha256: item.output_sha256,
native_extractor: item.build.native_extractor, extractor_runs: item.build.extractor_runs,
primitives: gltf.meshes[0].primitives.length, leaves: 'static_extracted_cards', wind: 'not_baked'
}});
}
if (!records.length) throw Error('No native trees found');
const dest = path.join(assets, 'TreeGeometry');
// Validate the whole set before staging. Do not overwrite unrelated asset files.
fs.mkdirSync(dest, {recursive: true});
const trees = {};
for (const {bytes, key, record} of records) {
const target = path.join(dest, record.glb);
if (fs.existsSync(target) && hash(fs.readFileSync(target)) !== record.glb_sha256) throw Error(`Conflicting existing GLB: ${target}`);
if (!fs.existsSync(target)) fs.writeFileSync(target, bytes);
trees[key] = record;
}
fs.writeFileSync(path.join(dest, 'manifest.json'), JSON.stringify({schema_version: 1, coordinates: 'godot_y_up_meters', trees}, null, 2) + '\n');
console.log(`Staged ${records.length} verified native tree species into ${dest}`);
+113
View File
@@ -0,0 +1,113 @@
#!/usr/bin/env node
/* Validate the machine-readable playable report and, when requested, seal it
* with the parent process' exit/log gate. The client cannot mark a run PASS by
* itself: the final report is written only after the child has exited. */
import fs from 'node:fs';
import path from 'node:path';
const VALID = new Set(['PASS', 'FAIL', 'BLOCKED', 'SKIP']);
const ERROR_RE = /SCRIPT ERROR|Parse Error|^ERROR:|leaked at exit|shaders of type .* were never freed/m;
function usage() {
console.error('usage: validate_playable_report.mjs --report PATH [--output PATH] [--log PATH] [--process-code N] [--run-id ID] [--require-pass]');
}
function argsOf(argv) {
const out = { require_pass: false };
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
if (arg === '--require-pass') out.require_pass = true;
else if (arg.startsWith('--')) {
const key = arg.slice(2).replaceAll('-', '_');
if (i + 1 >= argv.length) throw new Error(`missing value for ${arg}`);
out[key] = argv[++i];
} else throw new Error(`unknown argument: ${arg}`);
}
if (!out.report) throw new Error('--report is required');
return out;
}
function readJson(file) {
try { return JSON.parse(fs.readFileSync(file, 'utf8')); }
catch (error) { return { __read_error: `${file}: ${error.message}` }; }
}
function validate(report, options) {
const errors = [];
const readError = report?.__read_error;
if (readError) errors.push(readError);
if (!report || typeof report !== 'object' || Array.isArray(report)) {
errors.push('report root must be an object');
return errors;
}
if (report.schema_version !== 1) errors.push('schema_version must be 1');
if (typeof report.run_id !== 'string' || report.run_id.trim() === '') errors.push('run_id is missing');
if (options.run_id && report.run_id !== options.run_id) errors.push('run_id does not match requested run');
if (report.suite !== 'playable') errors.push('suite must be playable');
if (!VALID.has(report.status)) errors.push(`invalid report status: ${report.status}`);
for (const key of ['engine_sha256', 'extension_sha256', 'pck_sha256', 'arch']) {
if (typeof report.build?.[key] !== 'string' || report.build[key].trim() === '') errors.push(`build.${key} is missing`);
}
if (!Array.isArray(report.cases) || report.cases.length === 0) errors.push('cases must be non-empty');
const cases = Array.isArray(report.cases) ? report.cases : [];
let passed = 0;
for (const item of cases) {
if (!item || typeof item !== 'object' || typeof item.id !== 'string' || item.id.trim() === '') {
errors.push('case id is missing');
continue;
}
if (!VALID.has(item.status)) errors.push(`${item.id}: invalid status`);
if (item.status === 'PASS') passed += 1;
if (item.status === 'BLOCKED' || item.status === 'SKIP') errors.push(`${item.id}: ${item.status} is not releasable`);
if (!Array.isArray(item.evidence)) errors.push(`${item.id}: evidence must be an array`);
for (const evidence of (Array.isArray(item.evidence) ? item.evidence : [])) {
if (typeof evidence !== 'string' || evidence.trim() === '') errors.push(`${item.id}: empty evidence path`);
else if (!fs.existsSync(path.resolve(path.dirname(options.report), evidence))) errors.push(`${item.id}: missing evidence ${evidence}`);
}
}
const coverage = report.coverage;
if (!coverage || coverage.required !== cases.length || coverage.passed !== passed) errors.push('coverage does not match cases');
if (!Array.isArray(report.failures) || !Array.isArray(report.blocked)) errors.push('failures and blocked must be arrays');
if (!report.exit_gate || report.exit_gate.checked !== true) errors.push('exit_gate.checked must be true in final report');
if (options.process_code !== undefined && Number(report.exit_gate?.process_code) !== Number(options.process_code)) errors.push('exit_gate.process_code does not match child process');
if (Number(report.exit_gate?.process_code) !== 0) errors.push('child process did not exit with code 0');
if (options.log) {
if (!fs.existsSync(options.log)) errors.push(`log does not exist: ${options.log}`);
else {
const lines = fs.readFileSync(options.log, 'utf8').split(/\r?\n/);
lines.forEach((line, index) => { if (ERROR_RE.test(line)) errors.push(`log:${index + 1}: ${line}`); });
}
}
if (options.require_pass && report.status !== 'PASS') errors.push(`report status is ${report.status}, expected PASS`);
if (report.status === 'FAIL') errors.push('client report already contains failures');
return [...new Set(errors)];
}
function main() {
let options;
try { options = argsOf(process.argv.slice(2)); }
catch (error) { usage(); console.error(error.message); return 2; }
const input = readJson(options.report);
const report = input.__read_error ? {
schema_version: 1, run_id: options.run_id || 'missing-report', suite: 'playable', status: 'FAIL',
build: { engine_sha256: '', extension_sha256: '', pck_sha256: '', arch: '' }, cases: [],
failures: [input.__read_error], blocked: [], coverage: { required: 0, passed: 0 },
} : input;
const exitCode = options.process_code === undefined ? null : Number(options.process_code);
report.exit_gate = { checked: true, process_code: exitCode, errors: [] };
const errors = validate(report, options);
report.exit_gate.errors = errors;
if (errors.length > 0) report.status = 'FAIL';
const output = options.output || options.report;
fs.mkdirSync(path.dirname(path.resolve(output)), { recursive: true });
fs.writeFileSync(output, `${JSON.stringify(report, null, 2)}\n`);
if (errors.length) {
console.error(`PLAYABLE REPORT: FAIL (${errors.length} errors)`);
errors.forEach((error) => console.error(`- ${error}`));
return 1;
}
console.log(`PLAYABLE REPORT: PASS ${output}`);
return 0;
}
process.exitCode = main();