Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
212 changes: 160 additions & 52 deletions Assets/Tests/Demo/scripts/verify-replay-via-cli.sh
Original file line number Diff line number Diff line change
@@ -1,30 +1,88 @@
#!/bin/sh
# E2E verification: human plays freely, then CLI replays and verifies.
# E2E verification: record input, replay it through the CLI, and compare behavior.
#
# Usage: sh verify-replay-via-cli.sh [--project-path <path>]
# Usage: sh verify-replay-via-cli.sh [--project-path <path>] [--automated-input]
#
# Prerequisites:
# - Unity Editor running with InputReplayVerificationScene loaded
# - PlayMode is NOT running (script starts it)
# - Unity Editor running for the target project
# - PlayMode is not running because this script starts it

set -e

PROJECT_PATH=""
if [ "$1" = "--project-path" ] && [ -n "$2" ]; then
PROJECT_PATH="$2"
fi
ULOOP_PATH="${ULOOP_BIN:-uloop}"
AUTOMATED_INPUT=false
SCENE_PATH="Assets/Scenes/InputReplayVerificationScene.unity"

fail() {
echo "ERROR: $1" >&2
exit 1
}

while [ "$#" -gt 0 ]; do
case "$1" in
--project-path)
[ "$#" -ge 2 ] || fail "--project-path requires a value"
PROJECT_PATH=$2
shift 2
;;
--uloop-path)
[ "$#" -ge 2 ] || fail "--uloop-path requires a value"
ULOOP_PATH=$2
shift 2
;;
--automated-input)
AUTOMATED_INPUT=true
shift
;;
-h|--help)
echo "Usage: sh verify-replay-via-cli.sh [--project-path <path>] [--uloop-path <path>] [--automated-input]"
exit 0
;;
*)
fail "unknown option: $1"
;;
esac
done

run_uloop() {
if [ -n "$PROJECT_PATH" ]; then
uloop "$@" --project-path "$PROJECT_PATH"
"$ULOOP_PATH" --project-path "$PROJECT_PATH" "$@"
else
uloop "$@"
"$ULOOP_PATH" "$@"
fi
}

RECORDING_LOG=".uloop/outputs/InputRecordings/recording-event-log.txt"
REPLAY_LOG=".uloop/outputs/InputRecordings/replay-event-log.txt"

if [ -n "$PROJECT_PATH" ]; then
RECORDING_LOG="$PROJECT_PATH/$RECORDING_LOG"
REPLAY_LOG="$PROJECT_PATH/$REPLAY_LOG"
fi

run_uloop_json() {
output=$(run_uloop "$@" 2>&1) || {
printf '%s\n' "$output" >&2
fail "uloop $* failed"
}
if printf '%s\n' "$output" | grep -Eq '"Success"[[:space:]]*:[[:space:]]*false'; then
printf '%s\n' "$output" >&2
fail "uloop $* returned Success=false"
fi

printf '%s\n' "$output"
}

assert_json_result() {
json=$1
expected=$2
context=$3

actual=$(printf '%s\n' "$json" | sed -n 's/.*"Result"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p')
[ "$actual" = "$expected" ] || fail "$context: expected '$expected', got '$actual'"
}

wait_for_unity() {
i=0
while [ $i -lt 15 ]; do
Expand All @@ -39,30 +97,65 @@ wait_for_unity() {
}

activate_for_record() {
run_uloop execute-dynamic-code --code '
json=$(run_uloop_json execute-dynamic-code --code '
var cube = GameObject.Find("VerificationCube");
if (cube == null) return "ERROR: VerificationCube not found";
cube.SendMessage("ActivateForExternalControl");
return "OK: activated for recording";
'
')
assert_json_result "$json" "OK: activated for recording" "Activate recording controller"
}

activate_for_replay() {
run_uloop execute-dynamic-code --code '
json=$(run_uloop_json execute-dynamic-code --code '
var cube = GameObject.Find("VerificationCube");
if (cube == null) return "ERROR: VerificationCube not found";
cube.SendMessage("ActivateForExternalReplay");
return "OK: activated for replay";
'
')
assert_json_result "$json" "OK: activated for replay" "Activate replay controller"
}

save_log() {
run_uloop execute-dynamic-code --code "
unity_path=$1
if command -v cygpath >/dev/null 2>&1; then
unity_path=$(cygpath -w "$1")
fi
escaped_path=$(printf '%s\n' "$unity_path" | sed 's/\\/\\\\/g; s/"/\\"/g')
rm -f "$1"
json=$(run_uloop_json execute-dynamic-code --code "
var cube = GameObject.Find(\"VerificationCube\");
if (cube == null) return \"ERROR: VerificationCube not found\";
cube.SendMessage(\"SaveLog\", \"$1\");
cube.SendMessage(\"SaveLog\", \"$escaped_path\");
return \"OK: log saved\";
"
")
assert_json_result "$json" "OK: log saved" "Save event log"
[ -f "$1" ] || fail "Save event log did not create $1"
}

initialize_replay_scene() {
run_uloop control-play-mode --action Stop >/dev/null 2>&1 || true

json=$(run_uloop_json execute-dynamic-code --code "
using UnityEditor.SceneManagement;
using UnityEngine.SceneManagement;
string scenePath = \"$SCENE_PATH\";
if (SceneManager.GetActiveScene().path != scenePath)
{
EditorSceneManager.OpenScene(scenePath, OpenSceneMode.Single);
}
return SceneManager.GetActiveScene().path;
")
assert_json_result "$json" "$SCENE_PATH" "Load replay verification scene"
}

invoke_automated_input() {
run_uloop_json simulate-mouse-input --action SmoothDelta --delta-x 96 --delta-y 0 --duration 0.25 >/dev/null
sleep 1
run_uloop_json simulate-mouse-input --action Click --x 400 --y 300 >/dev/null
sleep 1
run_uloop_json simulate-mouse-input --action Scroll --scroll-y 120 >/dev/null
sleep 1
}

echo ""
Expand All @@ -73,58 +166,72 @@ echo "========================================="
# ---- Phase 1: Record human input ----

echo ""
echo "[1/8] Starting PlayMode..."
run_uloop control-play-mode --action Play
echo "[1/9] Loading replay verification scene..."
initialize_replay_scene

echo "[2/9] Starting PlayMode..."
run_uloop_json control-play-mode --action Play >/dev/null
echo " Waiting for Unity..."
sleep 6
wait_for_unity

echo "[2/8] Activating controller..."
echo "[3/9] Activating controller..."
activate_for_record

echo "[3/8] Starting recording via CLI..."
run_uloop record-input --action Start
echo "[4/9] Starting recording via CLI..."
if [ "$AUTOMATED_INPUT" = true ]; then
run_uloop_json record-input --action Start --delay-seconds 0 --no-show-overlay >/dev/null
else
run_uloop_json record-input --action Start >/dev/null
fi

echo ""
echo "========================================="
echo " Recording is active!"
echo " Go to the Unity Game View and play."
echo ""
echo " WASD: move | Mouse: rotate"
echo " Left click: red | Right click: blue"
echo " Scroll: scale"
echo ""
echo " Press ENTER here when done."
echo "========================================="
echo ""
read dummy
if [ "$AUTOMATED_INPUT" = true ]; then
echo " Running automated input sequence..."
sleep 1
invoke_automated_input
else
echo ""
echo "========================================="
echo " Recording is active!"
echo " Go to the Unity Game View and play."
echo ""
echo " WASD: move | Mouse: rotate"
echo " Left click: red | Right click: blue"
echo " Scroll: scale"
echo ""
echo " Press ENTER here when done."
echo "========================================="
echo ""
read -r _
fi

echo "[4/8] Saving event log + deactivating controller..."
run_uloop execute-dynamic-code --code '
var cube = GameObject.Find("VerificationCube");
if (cube == null) return "ERROR: VerificationCube not found";
cube.SendMessage("SaveLog", ".uloop/outputs/InputRecordings/recording-event-log.txt");
cube.SendMessage("ClearLog");
return "OK: log saved, controller deactivated";
'
echo "[5/9] Stopping recording via CLI..."
RECORD_STOP_RESULT=$(run_uloop_json record-input --action Stop)
echo " $RECORD_STOP_RESULT"
RECORDING_INPUT_PATH=$(printf '%s\n' "$RECORD_STOP_RESULT" | sed -n 's/.*"OutputPath"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p')

echo " Stopping recording via CLI..."
run_uloop record-input --action Stop
echo "[6/9] Saving recording event log..."
save_log "$RECORDING_LOG"
[ -s "$RECORDING_LOG" ] || fail "Recording event log is empty"

# ---- Phase 2: Replay via CLI ----

echo "[5/8] Restarting PlayMode..."
run_uloop control-play-mode --action Stop
echo "[7/9] Restarting PlayMode..."
run_uloop_json control-play-mode --action Stop >/dev/null
sleep 3
run_uloop control-play-mode --action Play
run_uloop_json control-play-mode --action Play >/dev/null
echo " Waiting for Unity..."
sleep 6
wait_for_unity

echo "[6/8] Activating controller + starting replay via CLI..."
echo "[8/9] Activating controller + starting replay via CLI..."
activate_for_replay
echo " Starting replay..."
REPLAY_RESULT=$(run_uloop replay-input --action Start 2>&1) || true
if [ "$AUTOMATED_INPUT" = true ] && [ -n "$RECORDING_INPUT_PATH" ]; then
REPLAY_RESULT=$(run_uloop replay-input --action Start --input-path "$RECORDING_INPUT_PATH" --no-show-overlay 2>&1) || true
else
REPLAY_RESULT=$(run_uloop replay-input --action Start 2>&1) || true
fi
echo " $REPLAY_RESULT"

echo " Waiting for replay to finish..."
Expand Down Expand Up @@ -153,13 +260,14 @@ if [ $waited -ge 60 ]; then
fi
sleep 1

echo "[7/8] Saving replay event log..."
save_log ".uloop/outputs/InputRecordings/replay-event-log.txt"
echo "[9/9] Saving replay event log..."
save_log "$REPLAY_LOG"
[ -s "$REPLAY_LOG" ] || fail "Replay event log is empty"

# ---- Phase 3: Compare ----

echo ""
echo "[8/8] Comparing logs..."
echo "[Final] Comparing logs..."
echo ""

# Normalize frame numbers to relative (first event = frame 0).
Expand Down
Binary file modified Packages/src/Cli~/dist/darwin-amd64/uloop
Binary file not shown.
Binary file modified Packages/src/Cli~/dist/darwin-arm64/uloop
Binary file not shown.
Binary file modified Packages/src/Cli~/dist/windows-amd64/uloop.exe
Binary file not shown.
60 changes: 47 additions & 13 deletions Packages/src/Cli~/internal/cli/server_state.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,20 @@ import (
"fmt"
"os"
"path/filepath"
"time"
)

const (
serverStateRelativePath = "Temp/UnityCliLoop/server-state.json"
serverStateCompletedTempSuffix = ".tmp"
serverStateInProgressTempSuffix = ".tmp.write"
serverStateBackupSuffix = ".bak"
serverStateReadRetryAttempts = 20
)

var (
readServerStateFileContent = os.ReadFile
serverStateReadRetryDelay = 25 * time.Millisecond
)

type serverState struct {
Expand Down Expand Up @@ -48,23 +55,33 @@ func (err staleServerStateError) Error() string {

func readServerState(projectRoot string) (serverState, bool, error) {
statePath := filepath.Join(projectRoot, serverStateRelativePath)
content, ok, err := readServerStateFile(statePath)
if err != nil {
return serverState{}, false, err
}
if !ok {
return serverState{}, false, nil
}
var lastErr error
for attempt := 0; attempt <= serverStateReadRetryAttempts; attempt++ {
content, ok, err := readServerStateFile(statePath)
if err != nil {
return serverState{}, false, err
}
if !ok {
return serverState{}, false, nil
}

var state serverState
if err := json.Unmarshal(content, &state); err != nil {
return serverState{}, false, fmt.Errorf("server readiness state is unreadable: %w", err)
var state serverState
if err := json.Unmarshal(content, &state); err == nil {
return state, true, nil
} else {
lastErr = err
}

if attempt < serverStateReadRetryAttempts {
// Unity can briefly expose a truncated readiness file while replacing it.
time.Sleep(serverStateReadRetryDelay)
}
}
return state, true, nil
return serverState{}, false, fmt.Errorf("server readiness state is unreadable: %w", lastErr)
}

func readServerStateFile(statePath string) ([]byte, bool, error) {
content, err := os.ReadFile(statePath)
content, err := readServerStateFileWithRetry(statePath)
if err != nil {
if !os.IsNotExist(err) {
return nil, false, err
Expand All @@ -74,7 +91,7 @@ func readServerStateFile(statePath string) ([]byte, bool, error) {
}

for _, sidecarPath := range []string{statePath + serverStateCompletedTempSuffix, statePath + serverStateBackupSuffix} {
sidecarContent, sidecarErr := os.ReadFile(sidecarPath)
sidecarContent, sidecarErr := readServerStateFileWithRetry(sidecarPath)
if sidecarErr == nil {
return sidecarContent, true, nil
}
Expand All @@ -86,6 +103,23 @@ func readServerStateFile(statePath string) ([]byte, bool, error) {
return nil, false, nil
}

func readServerStateFileWithRetry(statePath string) ([]byte, error) {
var lastErr error
for attempt := 0; attempt <= serverStateReadRetryAttempts; attempt++ {
content, err := readServerStateFileContent(statePath)
if err == nil || os.IsNotExist(err) {
return content, err
}

lastErr = err
if attempt < serverStateReadRetryAttempts {
// Unity can briefly publish the readiness file with an exclusive Windows handle.
time.Sleep(serverStateReadRetryDelay)
}
}
return nil, lastErr
}

func isServerStateBusy(state serverState) bool {
switch state.Phase {
case "starting", "compiling", "reloading", "recovering", "stopping":
Expand Down
Loading
Loading