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
14 changes: 10 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,10 +49,9 @@ URLab communicates with external systems over ZMQ. The companion package [**urla

## Requirements

- **Unreal Engine 5.7+**
- **Windows** (Win64). Linux is experimental.
- **Unreal Engine 5.7+** -- Windows binary or Linux binary distribution.
- **Windows (Win64)** with **Visual Studio 2022**, or **Linux (x86_64)** with UE's bundled clang. See [Linux setup](docs/linux_setup.md) for the Linux-specific build flow.
- **MuJoCo 3.7+** -- bundled in `third_party/`, built from source.
- **Visual Studio 2022** or compatible C++ toolchain.
- **CMake 3.24+** -- for building third-party libraries.
- **Python 3.11+** -- optional, for `urlab_bridge` policies.
- **[uv](https://github.com/astral-sh/uv)** -- optional, for Python dependency management.
Expand All @@ -71,10 +70,17 @@ git clone https://github.com/URLab-Sim/UnrealRoboticsLab.git
### 2. Build Dependencies
Navigate to the plugin's `third_party` folder and run the build script to fetch and compile MuJoCo, CoACD, and ZMQ:
```powershell
# Windows (PowerShell)
cd UnrealRoboticsLab/third_party
.\build_all.ps1
```
*(If this script fails with a **Stack Overflow** error, see [Troubleshooting](#troubleshooting) below).*
```bash
# Linux: see docs/linux_setup.md for the env vars needed to build
# the third-party libs against UE's bundled clang + libc++.
cd UnrealRoboticsLab/third_party
./build_all.sh
```
*(If the Windows script fails with a **Stack Overflow** error, see [Troubleshooting](#troubleshooting) below).*

### 3. Compile & Launch
1. Right-click your `.uproject` and select **Generate Visual Studio project files**.
Expand Down
208 changes: 208 additions & 0 deletions Scripts/build_and_test_linux.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
#!/usr/bin/env bash
# Copyright (c) 2026 Jonathan Embley-Riches. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# --- LEGAL DISCLAIMER ---
# UnrealRoboticsLab is an independent software plugin. It is NOT affiliated with,
# endorsed by, or sponsored by Epic Games, Inc. "Unreal" and "Unreal Engine" are
# trademarks or registered trademarks of Epic Games, Inc. in the US and elsewhere.
#
# This plugin incorporates third-party software: MuJoCo (Apache 2.0),
# CoACD (MIT), and libzmq (MPL 2.0). See ThirdPartyNotices.txt for details.

# Linux variant of build_and_test.sh. Same summary block format so the PR
# template is satisfied. Differs from the upstream script in:
# - Uses the Linux Build.sh / UnrealEditor-Cmd binaries
# - Builds the project's Editor target on the Linux platform
# - Stages third-party shared libs into the plugin's Binaries/Linux/ via
# setup_runtime_linux.sh after build (RuntimeDependencies aren't auto-
# staged for editor builds on Linux).
#
# Usage:
# ./Scripts/build_and_test_linux.sh \
# --engine /home/ubuntu/UnrealEngine \
# --project /home/ubuntu/URLabTest/URLabTest.uproject \
# [--target CustomEditorTargetName] \
# [--filter URLab] \
# [--log /tmp/urlab_test.log]
#
# --target defaults to <ProjectName>Editor derived from the .uproject filename.
# Only override for projects that don't follow UE's naming convention.
#
# Exit codes: 0 ok, 1 build failed, 2 tests failed, 3 bad args.

set -eu

TARGET=""
FILTER="URLab"
LOG="/tmp/urlab_test.log"
ENGINE=""
PROJECT=""

usage() {
cat >&2 <<'HELP'
build_and_test_linux.sh — build the project's Editor target and run the URLab automation suite (Linux).

Usage:
./Scripts/build_and_test_linux.sh \
--engine /home/ubuntu/UnrealEngine \
--project /home/ubuntu/URLabTest/URLabTest.uproject \
[--target CustomEditorTargetName] \
[--filter URLab] \
[--log /tmp/urlab_test.log]

--target defaults to <ProjectName>Editor derived from the .uproject filename.

Exit codes: 0 ok, 1 build failed, 2 tests failed, 3 bad args.
HELP
exit 3
}

while [[ $# -gt 0 ]]; do
case "$1" in
--engine) ENGINE="$2"; shift 2 ;;
--project) PROJECT="$2"; shift 2 ;;
--target) TARGET="$2"; shift 2 ;;
--filter) FILTER="$2"; shift 2 ;;
--log) LOG="$2"; shift 2 ;;
-h|--help) usage ;;
*) echo "Unknown arg: $1" >&2; usage ;;
esac
done

[[ -z "$ENGINE" ]] && { echo "Missing --engine" >&2; usage; }
[[ -z "$PROJECT" ]] && { echo "Missing --project" >&2; usage; }

# Derive the editor target from the .uproject filename if not explicitly
# supplied. UE's convention is <ProjectName>Editor — e.g. MyGame.uproject ->
# MyGameEditor. Override with --target for projects that don't follow this.
if [[ -z "$TARGET" ]]; then
PROJECT_BASENAME=$(basename "$PROJECT")
TARGET="${PROJECT_BASENAME%.*}Editor"
fi

BUILD_SH="$ENGINE/Engine/Build/BatchFiles/Linux/Build.sh"
CMD="$ENGINE/Engine/Binaries/Linux/UnrealEditor-Cmd"

[[ -x "$BUILD_SH" ]] || { echo "Build.sh not found: $BUILD_SH" >&2; exit 3; }
[[ -x "$CMD" ]] || { echo "UnrealEditor-Cmd not found: $CMD" >&2; exit 3; }

# Pre-flight: refuse to run while an editor instance has the project locked.
# Match only the actual UnrealEditor binary, not the project string in our own
# command line.
if pgrep -fa "/UnrealEditor( |$)" >/dev/null 2>&1; then
echo "ERROR: an UnrealEditor process is running. Close it first." >&2
exit 3
fi

: > "$LOG"

# --- Build -----------------------------------------------------------------
echo ">>> Building $TARGET (Linux Development)..."
BUILD_OUT=$("$BUILD_SH" "$TARGET" Linux Development "-Project=$PROJECT" -WaitMutex 2>&1 || true)
echo "$BUILD_OUT" | tail -10
if echo "$BUILD_OUT" | grep -q "Result: Succeeded\|Target is up to date"; then
BUILD_STATUS="Succeeded"
else
BUILD_STATUS="Failed"
fi

# --- Stage runtime libs ---------------------------------------------------
# UBT's auto-RPATH for plugins symlinked outside the host project resolves
# incorrectly. Symlink third-party .so files into the plugin's Binaries/Linux/
# so the loader resolves them via ${ORIGIN} (which UBT does add correctly).
SETUP_RUNTIME="$(dirname "$0")/setup_runtime_linux.sh"
if [[ "$BUILD_STATUS" == "Succeeded" && -x "$SETUP_RUNTIME" ]]; then
echo ">>> Staging third-party runtime libs..."
bash "$SETUP_RUNTIME" || echo "WARN: setup_runtime_linux.sh failed (continuing)" >&2
fi

# --- Test ------------------------------------------------------------------
PASS=0
FAIL=0
TOTAL=0
TESTS_PERFORMED_LINE=""

if [[ "$BUILD_STATUS" == "Succeeded" ]]; then
echo ">>> Running automation tests (filter=$FILTER, log=$LOG)..."
"$CMD" "$PROJECT" \
-ExecCmds="Automation RunTests $FILTER" \
-Unattended -NullRHI -NoSound -NoSplash -stdout -log \
-TestExit="Automation Test Queue Empty" \
> "$LOG" 2>&1 || true

if [[ ! -s "$LOG" ]]; then
echo "ERROR: test log is empty — editor likely held the project lock." >&2
fi

PASS=$(grep -cE 'Result=\{Success\}' "$LOG" || true)
FAIL=$(grep -cE 'Result=\{(Fail|Error)\}' "$LOG" || true)
TOTAL=$((PASS + FAIL))
TESTS_PERFORMED_LINE=$(grep -oE '[0-9]+ tests performed' "$LOG" | tail -1 || true)
fi

# --- Fingerprint + summary -------------------------------------------------
# Note: Host and Log-path lines are intentionally omitted from the summary
# block because they leak the reporter's hostname / username on local runs.
# The SHA-256 of the log is sufficient for reviewers to verify content
# integrity when they re-run the suite themselves.
TS=$(date -u '+%Y-%m-%d %H:%M:%S UTC')
GIT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
GIT_SHA=$(git -C "$GIT_DIR" rev-parse --short=8 HEAD 2>/dev/null || echo "unknown")
GIT_BRANCH=$(git -C "$GIT_DIR" rev-parse --abbrev-ref HEAD 2>/dev/null || echo "unknown")

# Strip the engine path down to its last segment (e.g. 'UnrealEngine'). The
# full path can contain the user's install root on a non-standard layout.
ENGINE_LABEL=$(basename "$ENGINE")
[[ -z "$ENGINE_LABEL" ]] && ENGINE_LABEL="$ENGINE"

# Third-party dep SHAs from third_party/install/<dep>/INSTALLED_SHA.txt
# (written by the CMake build scripts). Lets a reviewer verify they're
# comparing against the same binary toolchain. Silent skip if missing.
DEPS_LINE=""
for pair in "mj:MuJoCo" "coacd:CoACD" "zmq:libzmq"; do
key="${pair%%:*}"
dir="${pair##*:}"
sha_file="$GIT_DIR/third_party/install/$dir/INSTALLED_SHA.txt"
if [[ -s "$sha_file" ]]; then
sha=$(head -c 7 "$sha_file")
if [[ -n "$DEPS_LINE" ]]; then DEPS_LINE="$DEPS_LINE "; fi
DEPS_LINE="${DEPS_LINE}${key}=${sha}"
fi
done
[[ -z "$DEPS_LINE" ]] && DEPS_LINE="unavailable"

LOG_HASH="n/a"
if [[ -s "$LOG" ]]; then
if command -v sha256sum >/dev/null 2>&1; then
LOG_HASH=$(sha256sum "$LOG" | cut -c1-16)
fi
fi

cat <<EOF

=== URLab build+test summary ===
Timestamp : $TS
Git HEAD : $GIT_SHA ($GIT_BRANCH)
Engine : $ENGINE_LABEL
Deps : $DEPS_LINE
Build : $BUILD_STATUS
Tests : $PASS / $TOTAL passed ($FAIL failed)${TESTS_PERFORMED_LINE:+ [$TESTS_PERFORMED_LINE]}
Log sha256: $LOG_HASH
================================
EOF

[[ "$BUILD_STATUS" != "Succeeded" ]] && exit 1
[[ "$FAIL" -gt 0 || "$TOTAL" -eq 0 ]] && exit 2
exit 0
70 changes: 70 additions & 0 deletions Scripts/setup_runtime_linux.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
#!/usr/bin/env bash
# Copyright (c) 2026 Jonathan Embley-Riches. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

# setup_runtime_linux.sh — symlink URLab's third-party shared libraries into
# the plugin's Binaries/Linux/ directory so the editor's dynamic loader can
# resolve them via ${ORIGIN} (UBT already adds ${ORIGIN} to the plugin .so's
# RPATH).
#
# Why this is needed:
# UBT's auto-computed relative RPATH for PublicAdditionalLibraries on Linux
# assumes the plugin lives inside the host project's Plugins/ tree under a
# path UBT can express relative to ${ORIGIN}. For plugins symlinked in from
# outside the host project that calculation produces RPATH entries that
# resolve to non-existent directories, so libmujoco / lib_coacd / libzmq
# can't be found at editor startup.
#
# Run this after building both the third-party libs and the plugin .so. It is
# idempotent; existing symlinks are replaced.
#
# Usage: ./Scripts/setup_runtime_linux.sh
# (run from the plugin root, or any cwd — paths are resolved relative
# to this script's location)

set -eu

SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PLUGIN_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
THIRD_PARTY="$PLUGIN_ROOT/third_party/install"
BIN_DIR="$PLUGIN_ROOT/Binaries/Linux"

if [[ ! -d "$THIRD_PARTY" ]]; then
echo "ERROR: third_party/install not found at $THIRD_PARTY" >&2
echo "Run third_party/build_all.sh first." >&2
exit 1
fi

if [[ ! -d "$BIN_DIR" ]]; then
# Fresh checkout: plugin .so hasn't been built yet. Skip silently with a
# one-line note so per-dep build.sh invocations during initial
# third-party setup don't error out. The next plugin build will create
# Binaries/Linux/, and the next call to this script (from build.sh,
# build_all.sh, or build_and_test_linux.sh) will populate the symlinks.
echo "Skipping runtime stage: $BIN_DIR doesn't exist yet (build the plugin first)."
exit 0
fi

count=0
for pkg_lib in "$THIRD_PARTY/MuJoCo/lib" "$THIRD_PARTY/CoACD/lib" "$THIRD_PARTY/libzmq/lib"; do
[[ -d "$pkg_lib" ]] || continue
for so in "$pkg_lib"/*.so*; do
[[ -e "$so" ]] || continue
target="$BIN_DIR/$(basename "$so")"
ln -sfn "$so" "$target"
count=$((count + 1))
done
done

echo "Linked $count third-party shared libraries into $BIN_DIR"
7 changes: 6 additions & 1 deletion Source/URLab/Private/MuJoCo/Components/Tendons/MjTendon.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -245,7 +245,12 @@ void UMjTendon::RegisterToSpec(FMujocoSpecWrapper& Wrapper, mjsBody* ParentBody)

case EMjTendonWrapType::Geom:
{
const char* SideSiteStr = Wrap.SideSite.IsEmpty() ? "" : TCHAR_TO_UTF8(*Wrap.SideSite);
// FTCHARToUTF8 keeps the converted string alive for the
// duration of the wrapGeom call. Storing TCHAR_TO_UTF8 macro
// output in a const char* would dangle on Linux (see
// MjSpecWrapper::AddDefault for the same fix).
FTCHARToUTF8 SideSiteConv(*Wrap.SideSite);
const char* SideSiteStr = Wrap.SideSite.IsEmpty() ? "" : SideSiteConv.Get();
mjsWrap* W = mjs_wrapGeom(Tendon, TCHAR_TO_UTF8(*Wrap.TargetName), SideSiteStr);
if (!W)
{
Expand Down
18 changes: 15 additions & 3 deletions Source/URLab/Private/MuJoCo/Core/MjPhysicsEngine.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -109,9 +109,11 @@ static void URLab_InstallMujocoCallbacks()
{
if (GMujocoCallbacksInstalled) return;

// mujoco.dll is delay-loaded in URLab's Build.cs, and the linker refuses
// to bind data symbols through a delayed import. Resolve the two
// mju_user_* function pointers manually via GetDllExport.
#if PLATFORM_WINDOWS
// mujoco.dll is delay-loaded on Windows (URLab.Build.cs adds it via
// PublicDelayLoadDLLs), and the linker refuses to bind data symbols
// through a delayed import. Resolve the two mju_user_* function
// pointers manually via GetDllExport.
void* Handle = FPlatformProcess::GetDllHandle(TEXT("mujoco.dll"));
if (!Handle)
{
Expand All @@ -127,6 +129,16 @@ static void URLab_InstallMujocoCallbacks()
if (PErr) { *PErr = &URLab_OnMujocoError; }
if (PWarn) { *PWarn = &URLab_OnMujocoWarning; }
UE_LOG(LogURLab, Log, TEXT("[URLab] MuJoCo error callbacks installed (err=%p warn=%p)"), (void*)PErr, (void*)PWarn);
#else
// On Linux/macOS the lib is linked directly (no delay-load), so
// mju_user_error / mju_user_warning are resolvable as ordinary BSS
// data symbols at link time. Direct assignment is enough — no
// GetDllHandle / GetDllExport, no hardcoded SONAME literal needed.
mju_user_error = &URLab_OnMujocoError;
mju_user_warning = &URLab_OnMujocoWarning;
UE_LOG(LogURLab, Log, TEXT("[URLab] MuJoCo error callbacks installed (direct)"));
#endif

GMujocoCallbacksInstalled = true;
}

Expand Down
14 changes: 11 additions & 3 deletions Source/URLab/Private/MuJoCo/Core/Spec/MjSpecWrapper.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -159,12 +159,20 @@ void FMujocoSpecWrapper::AddDefault(UMjDefault* DefaultComp)
{
if (!DefaultComp) return;

const char* ClassName = DefaultComp->ClassName.IsEmpty() ? nullptr : TCHAR_TO_UTF8(*DefaultComp->ClassName);

// TCHAR_TO_UTF8 returns a pointer into a temporary that dies at the end of
// the full expression. Storing it in a long-lived variable produces a
// dangling pointer; on Linux/clang the stack is reclaimed before the value
// is used and the class registers under a garbage name, breaking
// childclass / inheritance resolution. Use FTCHARToUTF8 with explicit
// function-scope lifetime instead.
FTCHARToUTF8 ClassNameConv(*DefaultComp->ClassName);
const char* ClassName = DefaultComp->ClassName.IsEmpty() ? nullptr : ClassNameConv.Get();

mjsDefault* parentDef = nullptr;
if (!DefaultComp->ParentClassName.IsEmpty())
{
parentDef = mjs_findDefault(Spec, TCHAR_TO_UTF8(*DefaultComp->ParentClassName));
FTCHARToUTF8 ParentNameConv(*DefaultComp->ParentClassName);
parentDef = mjs_findDefault(Spec, ParentNameConv.Get());
if (parentDef)
{
UE_LOG(LogURLabWrapper, Log, TEXT("[AddDefault] Linked Class '%s' to Parent '%s'"), *DefaultComp->ClassName, *DefaultComp->ParentClassName);
Expand Down
Loading