From 55f9e186192991fccc0c91b8a36870559b2e9d22 Mon Sep 17 00:00:00 2001 From: Toys0125 Date: Tue, 24 Mar 2026 20:38:24 -0500 Subject: [PATCH 01/14] Add headless client CI builds and Docker packaging Add Linux and Windows headless Unity builds to CI. - add headless build matrix and artifact publishing - add Linux and Windows headless Docker image packaging - add project sanitization for XR/OpenVR/sample content in headless CI - add a custom Unity headless build entrypoint - support env-var-first headless connection config with config.xml fallback - fix headless input scaling and disconnect handling - skip HDR/URP runtime changes on server builds - switch server scripting backend to Mono for dedicated server compatibility - normalize published headless artifact folder names for Docker packaging --- .github/docker/headless/linux/Dockerfile | 15 ++ .github/docker/headless/windows/Dockerfile | 7 + .github/scripts/sanitize_headless_ci.py | 241 +++++++++++++++++ .github/workflows/build-docker-server.yml | 250 +++++++++++++++++- Basis/Assets/Editor/BasisHeadlessBuild.cs | 193 ++++++++++++++ .../Scripts/BasisNetworkHeadlessDriver.cs | 5 + .../Devices/Headless/BasisHeadlessInput.cs | 52 +++- .../Headless/BasisHeadlessManagement.cs | 136 ++++++++-- .../Networking/BasisNetworkEvents.cs | 47 ++++ .../Settings/SMModuleHDRURP.cs | 4 + Basis/ProjectSettings/ProjectSettings.asset | 2 +- 11 files changed, 919 insertions(+), 33 deletions(-) create mode 100644 .github/docker/headless/linux/Dockerfile create mode 100644 .github/docker/headless/windows/Dockerfile create mode 100644 .github/scripts/sanitize_headless_ci.py create mode 100644 Basis/Assets/Editor/BasisHeadlessBuild.cs diff --git a/.github/docker/headless/linux/Dockerfile b/.github/docker/headless/linux/Dockerfile new file mode 100644 index 000000000..68a8a5599 --- /dev/null +++ b/.github/docker/headless/linux/Dockerfile @@ -0,0 +1,15 @@ +FROM ubuntu:22.04 + +RUN apt-get update && \ + apt-get install -y --no-install-recommends \ + ca-certificates \ + libglib2.0-0 \ + libgtk-3-0 \ + libx11-6 \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app +COPY ./HeadlessLinux64/. . +RUN chmod +x ./HeadlessLinuxServer.x86_64 + +ENTRYPOINT ["./HeadlessLinuxServer.x86_64"] diff --git a/.github/docker/headless/windows/Dockerfile b/.github/docker/headless/windows/Dockerfile new file mode 100644 index 000000000..58c8a18cb --- /dev/null +++ b/.github/docker/headless/windows/Dockerfile @@ -0,0 +1,7 @@ +# escape=` +FROM mcr.microsoft.com/windows/servercore:ltsc2022 + +WORKDIR C:\app +COPY ./HeadlessWindows64/. . + +ENTRYPOINT ["C:\\app\\HeadlessWindowsServer.exe"] diff --git a/.github/scripts/sanitize_headless_ci.py b/.github/scripts/sanitize_headless_ci.py new file mode 100644 index 000000000..7e1c24477 --- /dev/null +++ b/.github/scripts/sanitize_headless_ci.py @@ -0,0 +1,241 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import shutil +import sys +from pathlib import Path + + +PACKAGE_DIRS_TO_REMOVE = ( + "Packages/com.valvesoftware.unity.openvr", + "Packages/com.basis.openvr", + "Packages/com.basis.openxr", + "Packages/com.basis.examples", + "Packages/com.basis.pooltable", +) + +PACKAGE_DEPENDENCIES_TO_REMOVE = ( + "com.unity.xr.openxr", + "com.valvesoftware.unity.openvr", +) + +ADDRESS_PREFIXES_TO_REMOVE = ( + "Packages/com.basis.examples/", + "Packages/com.basis.pooltable/", + "Packages/com.basis.tests/", +) + +XR_CONFIG_OBJECT_KEYS_TO_REMOVE = ( + "Unity.XR.OpenVR.Settings", + "com.unity.xr.management.loader_settings", + "com.unity.xr.openxr.settings4", +) + +XR_ASSET_PATHS_TO_REMOVE = ( + "Assets/XR", +) + + +def normalize_project_path(path: Path, project_root: Path) -> str: + return path.relative_to(project_root).as_posix() + + +def build_guid_index(project_root: Path) -> dict[str, str]: + guid_to_asset_path: dict[str, str] = {} + + for meta_path in project_root.rglob("*.meta"): + guid = "" + with meta_path.open(encoding="utf-8") as handle: + for line in handle: + if line.startswith("guid: "): + guid = line.removeprefix("guid: ").strip() + break + + if not guid: + continue + + asset_path = meta_path.with_suffix("") + guid_to_asset_path[guid] = normalize_project_path(asset_path, project_root) + + return guid_to_asset_path + + +def should_remove_entry(address: str, guid: str, guid_to_asset_path: dict[str, str]) -> bool: + if address.startswith(ADDRESS_PREFIXES_TO_REMOVE): + return True + + asset_path = guid_to_asset_path.get(guid, "") + return asset_path.startswith(PACKAGE_DIRS_TO_REMOVE) + + +def remove_manifest_dependencies(project_root: Path) -> list[str]: + manifest_path = project_root / "Packages/manifest.json" + lines = manifest_path.read_text(encoding="utf-8").splitlines(keepends=True) + output: list[str] = [] + removed_dependencies: list[str] = [] + + for line in lines: + stripped = line.strip() + removed_dependency = None + for dependency in PACKAGE_DEPENDENCIES_TO_REMOVE: + if stripped.startswith(f'"{dependency}"'): + removed_dependency = dependency + break + + if removed_dependency is not None: + removed_dependencies.append(removed_dependency) + continue + + output.append(line) + + if removed_dependencies: + manifest_path.write_text("".join(output), encoding="utf-8") + + return removed_dependencies + + +def strip_editor_build_settings(project_root: Path) -> list[str]: + settings_path = project_root / "ProjectSettings/EditorBuildSettings.asset" + lines = settings_path.read_text(encoding="utf-8").splitlines(keepends=True) + output: list[str] = [] + removed_keys: list[str] = [] + + for line in lines: + stripped = line.strip() + removed_key = None + for key in XR_CONFIG_OBJECT_KEYS_TO_REMOVE: + if stripped.startswith(f"{key}:"): + removed_key = key + break + + if removed_key is not None: + removed_keys.append(removed_key) + continue + + output.append(line) + + if removed_keys: + settings_path.write_text("".join(output), encoding="utf-8") + + return removed_keys + + +def remove_project_paths(project_root: Path, relative_paths: tuple[str, ...]) -> list[Path]: + removed_paths: list[Path] = [] + + for relative_path in relative_paths: + target_path = project_root / relative_path + meta_path = target_path.with_name(f"{target_path.name}.meta") + + if target_path.is_dir(): + shutil.rmtree(target_path) + removed_paths.append(target_path) + elif target_path.exists(): + target_path.unlink() + removed_paths.append(target_path) + + if meta_path.exists(): + meta_path.unlink() + removed_paths.append(meta_path) + + return removed_paths + + +def strip_addressable_entries(asset_path: Path, guid_to_asset_path: dict[str, str]) -> list[str]: + lines = asset_path.read_text(encoding="utf-8").splitlines(keepends=True) + output: list[str] = [] + removed_entries: list[str] = [] + index = 0 + + while index < len(lines): + line = lines[index] + output.append(line) + index += 1 + + if line != " m_SerializeEntries:\n": + continue + + while index < len(lines) and lines[index].startswith(" - m_GUID:"): + block_start = index + guid = lines[index].split(":", 1)[1].strip() + block_end = index + 1 + while block_end < len(lines): + current = lines[block_end] + if current.startswith(" - m_GUID:"): + break + block_end += 1 + + block = lines[block_start:block_end] + address = "" + for block_line in block: + marker = "m_Address:" + if marker in block_line: + address = block_line.split(marker, 1)[1].strip() + break + + if should_remove_entry(address, guid, guid_to_asset_path): + asset_ref = guid_to_asset_path.get(guid, "") + removed_entries.append(f"{address} [guid={guid} path={asset_ref}]") + else: + output.extend(block) + + index = block_end + + if removed_entries: + asset_path.write_text("".join(output), encoding="utf-8") + + return removed_entries + + +def main() -> int: + project_root = Path(sys.argv[1]) if len(sys.argv) > 1 else Path("Basis") + project_root = project_root.resolve() + + if not project_root.exists(): + print(f"Project root not found: {project_root}", file=sys.stderr) + return 1 + + guid_to_asset_path = build_guid_index(project_root) + + removed_dependencies = remove_manifest_dependencies(project_root) + if removed_dependencies: + print(f"Removed {len(removed_dependencies)} manifest dependencies from {project_root / 'Packages/manifest.json'}") + for dependency in removed_dependencies: + print(f" - {dependency}") + + removed_config_keys = strip_editor_build_settings(project_root) + if removed_config_keys: + print(f"Removed {len(removed_config_keys)} XR config objects from {project_root / 'ProjectSettings/EditorBuildSettings.asset'}") + for key in removed_config_keys: + print(f" - {key}") + + asset_groups_dir = project_root / "Assets/AddressableAssetsData/AssetGroups" + removed_total = 0 + for asset_path in sorted(asset_groups_dir.glob("*.asset")): + removed = strip_addressable_entries(asset_path, guid_to_asset_path) + if removed: + removed_total += len(removed) + print(f"Removed {len(removed)} Addressables entries from {asset_path}") + for entry in removed: + print(f" - {entry}") + + removed_xr_paths = remove_project_paths(project_root, XR_ASSET_PATHS_TO_REMOVE) + for path in removed_xr_paths: + print(f"Removed XR path: {path}") + + for relative_dir in PACKAGE_DIRS_TO_REMOVE: + package_dir = project_root / relative_dir + if package_dir.exists(): + shutil.rmtree(package_dir) + print(f"Removed package directory: {package_dir}") + else: + print(f"Package directory already absent: {package_dir}") + + if removed_total == 0: + print("No sample/test Addressables entries needed removal.") + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.github/workflows/build-docker-server.yml b/.github/workflows/build-docker-server.yml index 7074b1dbb..fe8da27a3 100644 --- a/.github/workflows/build-docker-server.yml +++ b/.github/workflows/build-docker-server.yml @@ -6,11 +6,29 @@ on: branches: - developer - long-term-support + env: REGISTRY: ghcr.io IMAGE_NAME: ${{ github.repository_owner }}/basis-server + HEADLESS_IMAGE_NAME: ${{ github.repository_owner }}/basis-headless jobs: + check-secret: + name: Check if secrets available + timeout-minutes: 5 + runs-on: ubuntu-latest + outputs: + secret-is-set: ${{ steps.secret-is-set.outputs.defined }} + steps: + - name: Check if secret is set, then set variable + id: secret-is-set + env: + TMP_SECRET1: ${{ secrets.UNITY_LICENSE }} + TMP_SECRET2: ${{ secrets.UNITY_EMAIL }} + TMP_SECRET3: ${{ secrets.UNITY_PASSWORD }} + if: "${{ env.TMP_SECRET1 != '' && env.TMP_SECRET2 != '' && env.TMP_SECRET3 != '' }}" + run: echo "defined=true" >> $GITHUB_OUTPUT + server-build-and-push: runs-on: ubuntu-latest permissions: @@ -68,4 +86,234 @@ jobs: BUILDKIT_INLINE_CACHE=1 - name: Image digest - run: 'echo "Image pushed with digest: ${{ steps.build.outputs.digest }}"' \ No newline at end of file + run: 'echo "Image pushed with digest: ${{ steps.build.outputs.digest }}"' + + headless-build: + name: Build headless for ${{ matrix.targetPlatform }} + timeout-minutes: 100 + runs-on: ${{ matrix.buildPlatform }} + permissions: + actions: write # to allow us to manage cache + env: + projectPath: Basis + strategy: + fail-fast: false + matrix: + include: + - targetPlatform: StandaloneLinux64 + buildPlatform: ubuntu-latest + buildName: HeadlessLinuxServer + buildOutput: LinuxServer + artifactName: LinuxHeadless + buildMethod: BasisHeadlessBuild.BuildLinuxServer + - targetPlatform: StandaloneWindows64 + buildPlatform: ubuntu-latest + buildName: HeadlessWindowsServer + buildOutput: WindowsServer + artifactName: WindowsHeadless + buildMethod: BasisHeadlessBuild.BuildWindowsServer + needs: [check-secret] + if: needs.check-secret.outputs.secret-is-set == 'true' + steps: + - name: Free Disk Space (Ubuntu) + uses: jlumbroso/free-disk-space@main + if: matrix.buildPlatform == 'ubuntu-latest' + with: + tool-cache: true + android: true + dotnet: true + haskell: true + large-packages: true + docker-images: false + swap-storage: false + - name: "Checkout repository" + timeout-minutes: 10 + uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: "Restore Library cache" + id: restore-cache + timeout-minutes: 10 + uses: actions/cache/restore@v3 + with: + path: ${{ env.projectPath }}/Library + key: Library-${{ env.projectPath }}-${{ matrix.targetPlatform }}-${{ hashFiles(env.projectPath) }} + restore-keys: Library-${{ env.projectPath }}-${{ matrix.targetPlatform }}- + - name: "Sanitize headless project (Linux)" + if: runner.os == 'Linux' + shell: bash + run: | + python3 .github/scripts/sanitize_headless_ci.py "${projectPath}" + - name: "Disable OpenVR editor setup for Windows headless builds" + if: runner.os == 'Windows' + shell: pwsh + run: | + $settingsPath = Join-Path $env:projectPath 'ProjectSettings/EditorBuildSettings.asset' + $settingsLines = Get-Content $settingsPath + $settingsLines | + Where-Object { $_ -notmatch '^\s*Unity\.XR\.OpenVR\.Settings:' } | + Set-Content $settingsPath + + @( + 'Assets/XR/Settings/OpenVRSettings.asset', + 'Assets/XR/Settings/OpenVRSettings.asset.meta' + ) | ForEach-Object { + $path = Join-Path $env:projectPath $_ + if (Test-Path $path) { + Remove-Item $path -Force + } + } + - name: "Sanitize headless project (Windows)" + if: runner.os == 'Windows' + shell: pwsh + run: | + python .github/scripts/sanitize_headless_ci.py "${env:projectPath}" + - name: "Build Unity project" + timeout-minutes: 100 + uses: BasisVR/unity-builder@main + env: + UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }} + UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }} + UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }} + with: + buildName: ${{ matrix.buildName }} + buildMethod: ${{ matrix.buildMethod }} + customParameters: -standaloneBuildSubtarget Server + projectPath: ${{ env.projectPath }} + targetPlatform: ${{ matrix.targetPlatform }} + versioning: None + buildsPath: build/${{ matrix.buildOutput }} + linux64RemoveExecutableExtension: false + - name: "Rename Windows headless output folder" + if: matrix.targetPlatform == 'StandaloneWindows64' + shell: bash + run: | + sudo mv "build/${{ matrix.buildOutput }}/StandaloneWindows64" "build/${{ matrix.buildOutput }}/HeadlessWindows64" + sudo chown -R "$(id -u):$(id -g)" "build/${{ matrix.buildOutput }}/HeadlessWindows64" + - name: "Rename Linux headless output folder" + if: matrix.targetPlatform == 'StandaloneLinux64' + shell: bash + run: | + sudo mv "build/${{ matrix.buildOutput }}/StandaloneLinux64" "build/${{ matrix.buildOutput }}/HeadlessLinux64" + sudo chown -R "$(id -u):$(id -g)" "build/${{ matrix.buildOutput }}/HeadlessLinux64" + - name: "Save Library Cache" + uses: actions/cache/save@v3 + if: always() + with: + path: ${{ env.projectPath }}/Library + key: ${{ steps.restore-cache.outputs.cache-primary-key }} + - name: "Only retain latest cache" + if: always() + shell: bash + env: + GH_TOKEN: ${{ github.token }} + run: | + OLD_CACHE_IDS=$(gh cache list --sort created_at --key Library-${{ env.projectPath }}-${{ matrix.targetPlatform }}- --json id --jq '.[1:] | map(.id) | @sh') + for cache_id in $OLD_CACHE_IDS; do + echo "Deleting cache id: $cache_id" + gh cache delete $cache_id + done + - name: "Upload headless artifact" + timeout-minutes: 5 + uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.artifactName }} + path: build/${{ matrix.buildOutput }} + + headless-docker-linux: + name: Build & push headless Linux image + runs-on: ubuntu-latest + needs: [headless-build] + permissions: + contents: read + packages: write + steps: + - name: Checkout repository + uses: actions/checkout@v4 + - name: Download Linux headless artifact + uses: actions/download-artifact@v4 + with: + name: LinuxHeadless + path: headless + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + - name: Log in to GitHub Container Registry + if: github.event_name != 'pull_request' + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Extract metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.HEADLESS_IMAGE_NAME }} + tags: | + type=raw,value=nightly-linux,enable=${{ github.ref == 'refs/heads/developer' }} + type=raw,value=latest-linux,enable=${{ github.ref == 'refs/heads/long-term-support' }} + type=sha,prefix={{branch}}-,suffix=-linux,format=short + type=ref,event=branch,suffix=-linux + - name: Build and push headless Linux image + uses: docker/build-push-action@v5 + id: build + with: + context: ./headless + file: ./.github/docker/headless/linux/Dockerfile + platforms: linux/amd64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + - name: Image digest + run: 'echo "Headless Linux image pushed with digest: ${{ steps.build.outputs.digest }}"' + + headless-docker-windows: + name: Build & push headless Windows image + runs-on: ubuntu-latest + needs: [headless-build] + permissions: + contents: read + packages: write + steps: + - name: Checkout repository + uses: actions/checkout@v4 + - name: Download Windows headless artifact + uses: actions/download-artifact@v4 + with: + name: WindowsHeadless + path: headless + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + - name: Log in to GitHub Container Registry + if: github.event_name != 'pull_request' + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Extract metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.HEADLESS_IMAGE_NAME }} + tags: | + type=raw,value=nightly-windows,enable=${{ github.ref == 'refs/heads/developer' }} + type=raw,value=latest-windows,enable=${{ github.ref == 'refs/heads/long-term-support' }} + type=sha,prefix={{branch}}-,suffix=-windows,format=short + type=ref,event=branch,suffix=-windows + - name: Build and push headless Windows image + uses: docker/build-push-action@v5 + id: build + with: + context: ./headless + file: ./.github/docker/headless/windows/Dockerfile + platforms: windows/amd64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + - name: Image digest + run: 'echo "Headless Windows image pushed with digest: ${{ steps.build.outputs.digest }}"' + + diff --git a/Basis/Assets/Editor/BasisHeadlessBuild.cs b/Basis/Assets/Editor/BasisHeadlessBuild.cs new file mode 100644 index 000000000..ecea0d771 --- /dev/null +++ b/Basis/Assets/Editor/BasisHeadlessBuild.cs @@ -0,0 +1,193 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using UnityEditor; +using UnityEditor.Build; +using UnityEditor.Build.Reporting; +using UnityEditor.AddressableAssets; +using UnityEditor.AddressableAssets.Build; +using UnityEditor.AddressableAssets.Settings; +using UnityEngine; + +public static class BasisHeadlessBuild +{ + private const string AddressablesBuildWithPlayerPreferenceKey = "Addressables.BuildAddressablesWithPlayerBuild"; + + public static void BuildLinuxServer() + { + BuildServer(BuildTarget.StandaloneLinux64); + } + + public static void BuildWindowsServer() + { + BuildServer(BuildTarget.StandaloneWindows64); + } + + private static void BuildServer(BuildTarget target) + { + string buildPath = RequireArgument("customBuildPath"); + string buildName = GetArgument("customBuildName") ?? Path.GetFileNameWithoutExtension(buildPath); + string projectPath = GetArgument("projectPath") ?? Directory.GetCurrentDirectory(); + string standaloneSubtargetArg = GetArgument("standaloneBuildSubtarget") ?? "Server"; + + Debug.Log($"[BasisHeadlessBuild] Starting {target} build"); + Debug.Log($"[BasisHeadlessBuild] projectPath={projectPath}"); + Debug.Log($"[BasisHeadlessBuild] buildName={buildName}"); + Debug.Log($"[BasisHeadlessBuild] buildPath={buildPath}"); + Debug.Log($"[BasisHeadlessBuild] activeBuildTarget(before)={EditorUserBuildSettings.activeBuildTarget}"); + Debug.Log($"[BasisHeadlessBuild] activeBuildTargetGroup(before)={BuildPipeline.GetBuildTargetGroup(EditorUserBuildSettings.activeBuildTarget)}"); + Debug.Log($"[BasisHeadlessBuild] standaloneBuildSubtarget(arg)={standaloneSubtargetArg}"); + + BuildTargetGroup targetGroup = BuildPipeline.GetBuildTargetGroup(target); + if (!BuildPipeline.IsBuildTargetSupported(targetGroup, target)) + { + throw new BuildFailedException($"Build target {target} is not supported in this editor."); + } + + if (EditorUserBuildSettings.activeBuildTarget != target) + { + bool switched = EditorUserBuildSettings.SwitchActiveBuildTarget(targetGroup, target); + Debug.Log($"[BasisHeadlessBuild] SwitchActiveBuildTarget({target}) => {switched}"); + } + + StandaloneBuildSubtarget standaloneSubtarget = ParseStandaloneSubtarget(standaloneSubtargetArg); + EditorUserBuildSettings.standaloneBuildSubtarget = standaloneSubtarget; + Debug.Log($"[BasisHeadlessBuild] activeBuildTarget(after)={EditorUserBuildSettings.activeBuildTarget}"); + Debug.Log($"[BasisHeadlessBuild] standaloneBuildSubtarget(set)={EditorUserBuildSettings.standaloneBuildSubtarget}"); + + EnsureBuildDirectory(buildPath); + LogEnabledScenes(); + + AddressableAssetSettings addressableSettings = AddressableAssetSettingsDefaultObject.Settings; + bool restoreBuildAddressablesWithPlayerBuild = false; + AddressableAssetSettings.PlayerBuildOption originalBuildAddressablesWithPlayerBuild = AddressableAssetSettings.PlayerBuildOption.PreferencesValue; + if (addressableSettings != null) + { + originalBuildAddressablesWithPlayerBuild = addressableSettings.BuildAddressablesWithPlayerBuild; + restoreBuildAddressablesWithPlayerBuild = true; + if (ShouldBuildAddressablesWithPlayerBuild(originalBuildAddressablesWithPlayerBuild)) + { + BuildAddressables(target, addressableSettings); + } + addressableSettings.BuildAddressablesWithPlayerBuild = AddressableAssetSettings.PlayerBuildOption.DoNotBuildWithPlayer; + Debug.Log($"[BasisHeadlessBuild] Overriding BuildAddressablesWithPlayerBuild: {originalBuildAddressablesWithPlayerBuild} -> {addressableSettings.BuildAddressablesWithPlayerBuild}"); + } + else + { + Debug.LogWarning("[BasisHeadlessBuild] Addressables settings not found; continuing without Addressables override."); + } + + try + { + BuildPlayerOptions options = new BuildPlayerOptions + { + scenes = EditorBuildSettings.scenes.Where(scene => scene.enabled).Select(scene => scene.path).ToArray(), + locationPathName = buildPath, + target = target, + targetGroup = targetGroup, + subtarget = (int)standaloneSubtarget, + options = BuildOptions.None + }; + + BuildReport report = BuildPipeline.BuildPlayer(options); + Debug.Log($"[BasisHeadlessBuild] Build result={report.summary.result}"); + Debug.Log($"[BasisHeadlessBuild] Build output path={report.summary.outputPath}"); + Debug.Log($"[BasisHeadlessBuild] Build totalErrors={report.summary.totalErrors}"); + Debug.Log($"[BasisHeadlessBuild] Build totalWarnings={report.summary.totalWarnings}"); + + if (report.summary.result != UnityEditor.Build.Reporting.BuildResult.Succeeded) + { + throw new BuildFailedException($"Player build failed: {report.summary.result}"); + } + } + finally + { + if (restoreBuildAddressablesWithPlayerBuild) + { + addressableSettings.BuildAddressablesWithPlayerBuild = originalBuildAddressablesWithPlayerBuild; + Debug.Log($"[BasisHeadlessBuild] Restored BuildAddressablesWithPlayerBuild={addressableSettings.BuildAddressablesWithPlayerBuild}"); + } + } + } + + private static bool ShouldBuildAddressablesWithPlayerBuild(AddressableAssetSettings.PlayerBuildOption option) + { + switch (option) + { + case AddressableAssetSettings.PlayerBuildOption.BuildWithPlayer: + return true; + case AddressableAssetSettings.PlayerBuildOption.DoNotBuildWithPlayer: + return false; + case AddressableAssetSettings.PlayerBuildOption.PreferencesValue: + return EditorPrefs.GetBool(AddressablesBuildWithPlayerPreferenceKey, true); + default: + return false; + } + } + + private static void BuildAddressables(BuildTarget target, AddressableAssetSettings settings) + { + Debug.Log($"[BasisHeadlessBuild] Building Addressables explicitly for {target}. Active profile={settings.activeProfileId}, builderIndex={settings.ActivePlayerDataBuilderIndex}"); + AddressableAssetSettings.BuildPlayerContent(out AddressablesPlayerBuildResult result); + Debug.Log($"[BasisHeadlessBuild] Addressables result.Error='{result.Error}'"); + Debug.Log($"[BasisHeadlessBuild] Addressables outputPath='{result.OutputPath}'"); + + if (!string.IsNullOrWhiteSpace(result.Error)) + { + throw new BuildFailedException($"Addressables build failed: {result.Error}"); + } + } + + private static void LogEnabledScenes() + { + foreach (EditorBuildSettingsScene scene in EditorBuildSettings.scenes) + { + Debug.Log($"[BasisHeadlessBuild] Scene enabled={scene.enabled} path={scene.path}"); + } + } + + private static void EnsureBuildDirectory(string buildPath) + { + string directory = Path.GetDirectoryName(buildPath); + if (!string.IsNullOrEmpty(directory)) + { + Directory.CreateDirectory(directory); + } + } + + private static StandaloneBuildSubtarget ParseStandaloneSubtarget(string value) + { + if (Enum.TryParse(value, true, out StandaloneBuildSubtarget parsed)) + { + return parsed; + } + + return StandaloneBuildSubtarget.Server; + } + + private static string RequireArgument(string name) + { + string value = GetArgument(name); + if (string.IsNullOrWhiteSpace(value)) + { + throw new BuildFailedException($"Required command line argument '-{name}' was not provided."); + } + + return value; + } + + private static string GetArgument(string name) + { + string[] args = Environment.GetCommandLineArgs(); + for (int index = 0; index < args.Length - 1; index++) + { + if (args[index] == $"-{name}") + { + return args[index + 1]; + } + } + + return null; + } +} diff --git a/Basis/Packages/com.basis.examples/Scripts/BasisNetworkHeadlessDriver.cs b/Basis/Packages/com.basis.examples/Scripts/BasisNetworkHeadlessDriver.cs index 45c49a414..6866b07c0 100644 --- a/Basis/Packages/com.basis.examples/Scripts/BasisNetworkHeadlessDriver.cs +++ b/Basis/Packages/com.basis.examples/Scripts/BasisNetworkHeadlessDriver.cs @@ -173,6 +173,11 @@ public override async void OnNetworkMessage(ushort playerID, byte[] buffer, Deli BasisLocalPlayer.Instance.Teleport(Position, Rotation); await BasisLocalPlayer.Instance.CreateAvatar(0, data); + if (BasisHeadlessInput.Instance != null) + { + BasisHeadlessInput.Instance.ResumeMovement(); + } + BasisDebug.Log($"[HeadlessDriver] Teleported player {playerID} to transform[{index}] at {Position}.", BasisDebug.LogTag.Remote); break; } diff --git a/Basis/Packages/com.basis.framework/Device Management/Devices/Headless/BasisHeadlessInput.cs b/Basis/Packages/com.basis.framework/Device Management/Devices/Headless/BasisHeadlessInput.cs index 3f86a3a41..12195ee8d 100644 --- a/Basis/Packages/com.basis.framework/Device Management/Devices/Headless/BasisHeadlessInput.cs +++ b/Basis/Packages/com.basis.framework/Device Management/Devices/Headless/BasisHeadlessInput.cs @@ -168,14 +168,42 @@ private void CancelStopForSecondsCo() // ---------------------------------------------------------------------- + private float GetBaseUnscaledHeight() + { + float height = BasisHeightDriver.SelectedUnScaledPlayerHeight + BasisHeightDriver.AdditionalPlayerHeight; + if (float.IsNaN(height) || float.IsInfinity(height) || height <= 0f) + { + height = BasisHeightDriver.FallbackHeightInMeters; + } + return height; + } + + private float GetUnscaledHeadHeightForCrouch() + { + if (Control == null) + { + return GetBaseUnscaledHeight(); + } + + float headHeight = Control.TposeLocalScaled.position.y; + float scale = BasisHeightDriver.DeviceScale; + if (float.IsNaN(scale) || float.IsInfinity(scale) || scale <= 0f) + { + return headHeight; + } + + return headHeight / scale; + } + public void Initialize(string ID = "Desktop Eye", string subSystems = "BasisDesktopManagement") { BasisDebug.Log("Initializing Avatar Eye", BasisDebug.LogTag.Input); - float height = BasisHeightDriver.SelectedScaledPlayerHeight; + float height = GetBaseUnscaledHeight(); - ScaledDeviceCoord.position = new Vector3(0, height, 0); - ScaledDeviceCoord.rotation = Quaternion.identity; + UnscaledDeviceCoord.position = new Vector3(0, height, 0); + UnscaledDeviceCoord.rotation = Quaternion.identity; + ConvertToScaledDeviceCoord(); InitalizeTracking(ID, ID, subSystems, true, BasisBoneTrackedRole.CenterEye); @@ -262,7 +290,7 @@ public override void LateDoPollData() UnscaledDeviceCoord.rotation = currentRotation; // maintain height with crouch compensation - float baseHeightLocked = BasisHeightDriver.SelectedScaledPlayerHeight; + float baseHeightLocked = GetBaseUnscaledHeight(); Vector3 posLocked = new Vector3(0, baseHeightLocked, 0); if (!BasisLocks.GetContext(BasisLocks.Crouching)) @@ -270,12 +298,13 @@ public override void LateDoPollData() float crouchMin = charDriverLocked.MinimumCrouchPercent; float crouchBlend = charDriverLocked.CrouchBlend; float heightAdjust = (1f - crouchMin) * crouchBlend + crouchMin; - posLocked.y -= Control.TposeLocalScaled.position.y * (1f - heightAdjust); + float headHeightUnscaled = GetUnscaledHeadHeightForCrouch(); + posLocked.y -= headHeightUnscaled * (1f - heightAdjust); } UnscaledDeviceCoord.position = posLocked; - ScaledDeviceCoord.position = posLocked; - ScaledDeviceCoord.rotation = currentRotation; + UnscaledDeviceCoord.rotation = currentRotation; + ConvertToScaledDeviceCoord(); ControlOnlyAsDevice(); ComputeRaycastDirection(ScaledDeviceCoord.position, ScaledDeviceCoord.rotation, Quaternion.identity); @@ -363,7 +392,7 @@ public override void LateDoPollData() // --- Head pose at eye height with crouch compensation --- UnscaledDeviceCoord.rotation = currentRotation; - float baseHeight = BasisHeightDriver.SelectedScaledPlayerHeight; + float baseHeight = GetBaseUnscaledHeight(); Vector3 pos = new Vector3(0, baseHeight, 0); if (!BasisLocks.GetContext(BasisLocks.Crouching)) @@ -371,12 +400,13 @@ public override void LateDoPollData() float crouchMin = charDriver.MinimumCrouchPercent; float crouchBlend = charDriver.CrouchBlend; float heightAdjust = (1f - crouchMin) * crouchBlend + crouchMin; - pos.y -= Control.TposeLocalScaled.position.y * (1f - heightAdjust); + float headHeightUnscaled = GetUnscaledHeadHeightForCrouch(); + pos.y -= headHeightUnscaled * (1f - heightAdjust); } UnscaledDeviceCoord.position = pos; - ScaledDeviceCoord.position = pos; - ScaledDeviceCoord.rotation = currentRotation; + UnscaledDeviceCoord.rotation = currentRotation; + ConvertToScaledDeviceCoord(); // Drive our CenterEye bone ControlOnlyAsDevice(); diff --git a/Basis/Packages/com.basis.framework/Device Management/Devices/Headless/BasisHeadlessManagement.cs b/Basis/Packages/com.basis.framework/Device Management/Devices/Headless/BasisHeadlessManagement.cs index ee8e2d3ff..1ea82ee70 100644 --- a/Basis/Packages/com.basis.framework/Device Management/Devices/Headless/BasisHeadlessManagement.cs +++ b/Basis/Packages/com.basis.framework/Device Management/Devices/Headless/BasisHeadlessManagement.cs @@ -3,6 +3,7 @@ using Basis.Scripts.Device_Management.Devices.Headless; using Basis.Scripts.Drivers; using Basis.Scripts.Networking; +using System; using System.Collections.Generic; using System.IO; using System.Threading.Tasks; @@ -124,24 +125,85 @@ private void RemoveAllText() public static void LoadOrCreateConfigXml() { string filePath = Path.Combine(Application.dataPath, "config.xml"); - if (!File.Exists(filePath)) + string defaultPassword = Password; + string defaultIp = Ip; + int defaultPort = Port; + string envPassword = ReadEnvironmentString("Password"); + string envIp = ReadEnvironmentString("Ip"); + int? envPort = ReadEnvironmentInt("Port"); + + if (envPassword != null && envIp != null && envPort.HasValue) + { + Password = envPassword; + Ip = envIp; + Port = envPort.Value; + return; + } + + XElement root = null; + if (File.Exists(filePath)) + { + var doc = XDocument.Load(filePath); + root = doc.Element("Configuration"); + } + else + { + TryCreateDefaultConfigXml(filePath, defaultPassword, defaultIp, defaultPort); + } + + Password = envPassword ?? root?.Element("Password")?.Value ?? defaultPassword; + Ip = envIp ?? root?.Element("Ip")?.Value ?? defaultIp; + Port = envPort ?? ReadXmlInt(root?.Element("Port")?.Value, defaultPort); + } + + private static void TryCreateDefaultConfigXml(string filePath, string password, string ip, int port) + { + try { var defaultConfig = new XElement("Configuration", - new XElement("Password", Password), - new XElement("Ip", Ip), - new XElement("Port", Port) + new XElement("Password", password), + new XElement("Ip", ip), + new XElement("Port", port) ); new XDocument(defaultConfig).Save(filePath); - return; } + catch (Exception ex) + { + Debug.LogWarning($"Unable to create default headless config at '{filePath}'. Continuing with environment/default values. {ex.Message}"); + } + } - var doc = XDocument.Load(filePath); - var root = doc.Element("Configuration"); - if (root == null) return; + private static string ReadEnvironmentString(string envName) + { + string envValue = Environment.GetEnvironmentVariable(envName); + if (string.IsNullOrWhiteSpace(envValue)) + { + return null; + } - Password = root.Element("Password")?.Value ?? Password; - Ip = root.Element("Ip")?.Value ?? Ip; - Port = int.TryParse(root.Element("Port")?.Value, out var p) ? p : Port; + return envValue; + } + + private static int? ReadEnvironmentInt(string envName) + { + string envValue = Environment.GetEnvironmentVariable(envName); + if (string.IsNullOrWhiteSpace(envValue)) + { + return null; + } + + if (int.TryParse(envValue, out int parsed)) + { + return parsed; + } + + Debug.LogWarning($"Invalid headless environment variable '{envName}' value '{envValue}'. Falling back to config.xml/defaults."); + return null; + } + + private static int ReadXmlInt(string value, int fallback) + { + return int.TryParse(value, out int parsed) ? parsed : fallback; } /// @@ -183,16 +245,14 @@ public async Task CreateAssetBundle() public override void StartSDK() { #if UNITY_SERVER - if (BasisHeadlessInput == null) + if (BasisLocalPlayer.PlayerReady && BasisLocalPlayer.Instance != null) { - GameObject gameObject = new GameObject("Headless Eye"); - if (BasisLocalPlayer.Instance != null) - { - gameObject.transform.parent = BasisLocalPlayer.Instance.transform; - } - BasisHeadlessInput = gameObject.AddComponent(); - BasisHeadlessInput.Initialize("Desktop Eye", nameof(Basis.Scripts.Device_Management.Devices.Headless.BasisHeadlessInput)); - BasisDeviceManagement.Instance.TryAdd(BasisHeadlessInput); + EnsureHeadlessInput(); + } + else + { + BasisLocalPlayer.OnLocalPlayerInitalized -= OnLocalPlayerReadyForHeadless; + BasisLocalPlayer.OnLocalPlayerInitalized += OnLocalPlayerReadyForHeadless; } BasisDebug.Log(nameof(StartSDK), BasisDebug.LogTag.Device); @@ -216,6 +276,42 @@ public override void StartSDK() BasisDebug.Log(nameof(StartSDK), BasisDebug.LogTag.Device); } + private void OnDestroy() + { +#if UNITY_SERVER + BasisLocalPlayer.OnLocalPlayerInitalized -= OnLocalPlayerReadyForHeadless; +#endif + } + +#if UNITY_SERVER + private void OnLocalPlayerReadyForHeadless() + { + BasisLocalPlayer.OnLocalPlayerInitalized -= OnLocalPlayerReadyForHeadless; + EnsureHeadlessInput(); + } + + private void EnsureHeadlessInput() + { + if (BasisHeadlessInput != null) + { + return; + } + + if (BasisLocalPlayer.Instance == null) + { + BasisDebug.LogWarning("Headless input creation delayed: LocalPlayer instance is null.", BasisDebug.LogTag.Device); + return; + } + + GameObject gameObject = new GameObject("Headless Eye"); + gameObject.transform.parent = BasisLocalPlayer.Instance.transform; + + BasisHeadlessInput = gameObject.AddComponent(); + BasisHeadlessInput.Initialize("Desktop Eye", nameof(Basis.Scripts.Device_Management.Devices.Headless.BasisHeadlessInput)); + BasisDeviceManagement.Instance.TryAdd(BasisHeadlessInput); + } +#endif + /// public override void StopSDK() { diff --git a/Basis/Packages/com.basis.framework/Networking/BasisNetworkEvents.cs b/Basis/Packages/com.basis.framework/Networking/BasisNetworkEvents.cs index 30f52476b..785316daf 100644 --- a/Basis/Packages/com.basis.framework/Networking/BasisNetworkEvents.cs +++ b/Basis/Packages/com.basis.framework/Networking/BasisNetworkEvents.cs @@ -439,8 +439,39 @@ public static bool ValidateSize(NetPacketReader reader, NetPeer peer, byte chann } public static void HandleDisconnectionReason(DisconnectInfo disconnectInfo) { +#if UNITY_SERVER + bool canShowMenu = !UnityEngine.Application.isBatchMode; +#endif + if (disconnectInfo.Reason == DisconnectReason.RemoteConnectionClose) { +#if UNITY_SERVER + string reason = null; + if (disconnectInfo.AdditionalData != null && + disconnectInfo.AdditionalData.TryGetString(out string parsedReason)) + { + reason = parsedReason; + } + + if (!string.IsNullOrEmpty(reason)) + { + if (canShowMenu) + { + BasisMainMenu.Open(); + if (BasisMainMenu.Instance != null) + { + BasisMainMenu.Instance.OpenDialogue("Server Connection", reason, "ok", value => + { + }); + } + } + BasisDebug.LogError(reason); + } + else + { + BasisDebug.Log($"Unexpected Failure Of Reason {disconnectInfo.Reason}"); + } +#else if (disconnectInfo.AdditionalData.TryGetString(out string Reason)) { BasisMainMenu.Open(); @@ -453,15 +484,31 @@ public static void HandleDisconnectionReason(DisconnectInfo disconnectInfo) { BasisDebug.Log($"Unexpected Failure Of Reason {disconnectInfo.Reason}"); } +#endif } else { +#if UNITY_SERVER + if (canShowMenu) + { + BasisMainMenu.Open(); + if (BasisMainMenu.Instance != null) + { + BasisMainMenu.Instance.OpenDialogue("Server Disconnected", disconnectInfo.Reason.ToString(), "ok", value => + { + }); + } + } + + BasisDebug.LogError(disconnectInfo.Reason.ToString()); +#else BasisMainMenu.Open(); BasisMainMenu.Instance.OpenDialogue("Server Disconnected", disconnectInfo.Reason.ToString(), "ok", value => { }); BasisDebug.LogError(disconnectInfo.Reason.ToString()); +#endif } } } diff --git a/Basis/Packages/com.basis.framework/Settings/SMModuleHDRURP.cs b/Basis/Packages/com.basis.framework/Settings/SMModuleHDRURP.cs index b20af0153..b34043ea4 100644 --- a/Basis/Packages/com.basis.framework/Settings/SMModuleHDRURP.cs +++ b/Basis/Packages/com.basis.framework/Settings/SMModuleHDRURP.cs @@ -12,6 +12,10 @@ public override void ValidSettingsChange(string matchedSettingName, string optio // Only react to the HDR setting if (matchedSettingName != K_HDR_SUPPORT) return; +#if UNITY_SERVER + BasisDebug.LogWarning("SMModuleHDRURP: Running on server build. HDR changes will not be applied.", BasisDebug.LogTag.Local); + return; +#endif UniversalRenderPipelineAsset asset = (UniversalRenderPipelineAsset)QualitySettings.renderPipeline; diff --git a/Basis/ProjectSettings/ProjectSettings.asset b/Basis/ProjectSettings/ProjectSettings.asset index abd5ab849..92ca99a6a 100644 --- a/Basis/ProjectSettings/ProjectSettings.asset +++ b/Basis/ProjectSettings/ProjectSettings.asset @@ -850,7 +850,7 @@ PlayerSettings: platformArchitecture: {} scriptingBackend: Android: 1 - Server: 1 + Server: 0 Standalone: 1 il2cppCompilerConfiguration: {} il2cppCodeGeneration: {} From 4b41d56515b89592b18f1b5410c449907db2afed Mon Sep 17 00:00:00 2001 From: Toys0125 Date: Tue, 24 Mar 2026 21:28:49 -0500 Subject: [PATCH 02/14] Add default config. --- .github/docker/headless/linux/Dockerfile | 8 ++++++++ .github/docker/headless/windows/Dockerfile | 8 ++++++++ 2 files changed, 16 insertions(+) diff --git a/.github/docker/headless/linux/Dockerfile b/.github/docker/headless/linux/Dockerfile index 68a8a5599..2a6ab288b 100644 --- a/.github/docker/headless/linux/Dockerfile +++ b/.github/docker/headless/linux/Dockerfile @@ -10,6 +10,14 @@ RUN apt-get update && \ WORKDIR /app COPY ./HeadlessLinux64/. . +RUN cat <<'EOF' > ./HeadlessLinuxServer_Data/config.xml + + + default_password + server1.basisvr.org + 4296 + +EOF RUN chmod +x ./HeadlessLinuxServer.x86_64 ENTRYPOINT ["./HeadlessLinuxServer.x86_64"] diff --git a/.github/docker/headless/windows/Dockerfile b/.github/docker/headless/windows/Dockerfile index 58c8a18cb..886f0ab9f 100644 --- a/.github/docker/headless/windows/Dockerfile +++ b/.github/docker/headless/windows/Dockerfile @@ -3,5 +3,13 @@ FROM mcr.microsoft.com/windows/servercore:ltsc2022 WORKDIR C:\app COPY ./HeadlessWindows64/. . +RUN cat <<'EOF' > ./HeadlessLinuxServer_Data/config.xml + + + default_password + server1.basisvr.org + 4296 + +EOF ENTRYPOINT ["C:\\app\\HeadlessWindowsServer.exe"] From 25160451947b273de995b13777e4c620d3dfc651 Mon Sep 17 00:00:00 2001 From: Toys0125 Date: Wed, 25 Mar 2026 00:05:26 -0500 Subject: [PATCH 03/14] Have the config.xml be in the headless artifacts. --- .github/docker/headless/linux/Dockerfile | 8 -------- .github/docker/headless/windows/Dockerfile | 9 --------- .github/workflows/build-docker-server.yml | 18 ++++++++++++++++++ 3 files changed, 18 insertions(+), 17 deletions(-) diff --git a/.github/docker/headless/linux/Dockerfile b/.github/docker/headless/linux/Dockerfile index 2a6ab288b..68a8a5599 100644 --- a/.github/docker/headless/linux/Dockerfile +++ b/.github/docker/headless/linux/Dockerfile @@ -10,14 +10,6 @@ RUN apt-get update && \ WORKDIR /app COPY ./HeadlessLinux64/. . -RUN cat <<'EOF' > ./HeadlessLinuxServer_Data/config.xml - - - default_password - server1.basisvr.org - 4296 - -EOF RUN chmod +x ./HeadlessLinuxServer.x86_64 ENTRYPOINT ["./HeadlessLinuxServer.x86_64"] diff --git a/.github/docker/headless/windows/Dockerfile b/.github/docker/headless/windows/Dockerfile index 886f0ab9f..259b63dfb 100644 --- a/.github/docker/headless/windows/Dockerfile +++ b/.github/docker/headless/windows/Dockerfile @@ -1,15 +1,6 @@ -# escape=` FROM mcr.microsoft.com/windows/servercore:ltsc2022 WORKDIR C:\app COPY ./HeadlessWindows64/. . -RUN cat <<'EOF' > ./HeadlessLinuxServer_Data/config.xml - - - default_password - server1.basisvr.org - 4296 - -EOF ENTRYPOINT ["C:\\app\\HeadlessWindowsServer.exe"] diff --git a/.github/workflows/build-docker-server.yml b/.github/workflows/build-docker-server.yml index fe8da27a3..9e7796a78 100644 --- a/.github/workflows/build-docker-server.yml +++ b/.github/workflows/build-docker-server.yml @@ -196,6 +196,24 @@ jobs: run: | sudo mv "build/${{ matrix.buildOutput }}/StandaloneLinux64" "build/${{ matrix.buildOutput }}/HeadlessLinux64" sudo chown -R "$(id -u):$(id -g)" "build/${{ matrix.buildOutput }}/HeadlessLinux64" + - name: "Write headless config.xml into artifact" + shell: bash + run: | + if [ "${{ matrix.targetPlatform }}" = "StandaloneWindows64" ]; then + data_dir="build/${{ matrix.buildOutput }}/HeadlessWindows64/HeadlessWindowsServer_Data" + else + data_dir="build/${{ matrix.buildOutput }}/HeadlessLinux64/HeadlessLinuxServer_Data" + fi + + mkdir -p "$data_dir" + cat > "$data_dir/config.xml" <<'EOF' + + + default_password + server1.basisvr.org + 4296 + + EOF - name: "Save Library Cache" uses: actions/cache/save@v3 if: always() From 78a9d7f1085821ea8014c1cd484648364dd49b8f Mon Sep 17 00:00:00 2001 From: Toys0125 Date: Tue, 24 Mar 2026 20:38:24 -0500 Subject: [PATCH 04/14] Add headless client CI builds and Docker packaging Add Linux and Windows headless Unity builds to CI. - add headless build matrix and artifact publishing - add Linux and Windows headless Docker image packaging - add project sanitization for XR/OpenVR/sample content in headless CI - add a custom Unity headless build entrypoint - support env-var-first headless connection config with config.xml fallback - fix headless input scaling and disconnect handling - skip HDR/URP runtime changes on server builds - switch server scripting backend to Mono for dedicated server compatibility - normalize published headless artifact folder names for Docker packaging --- .github/docker/headless/linux/Dockerfile | 15 ++ .github/docker/headless/windows/Dockerfile | 7 + .github/scripts/sanitize_headless_ci.py | 241 +++++++++++++++++ .github/workflows/build-docker-server.yml | 250 +++++++++++++++++- Basis/Assets/Editor/BasisHeadlessBuild.cs | 193 ++++++++++++++ .../Scripts/BasisNetworkHeadlessDriver.cs | 5 + .../Devices/Headless/BasisHeadlessInput.cs | 52 +++- .../Headless/BasisHeadlessManagement.cs | 136 ++++++++-- .../Networking/BasisNetworkEvents.cs | 47 ++++ .../Settings/SMModuleHDRURP.cs | 4 + Basis/ProjectSettings/ProjectSettings.asset | 2 +- 11 files changed, 919 insertions(+), 33 deletions(-) create mode 100644 .github/docker/headless/linux/Dockerfile create mode 100644 .github/docker/headless/windows/Dockerfile create mode 100644 .github/scripts/sanitize_headless_ci.py create mode 100644 Basis/Assets/Editor/BasisHeadlessBuild.cs diff --git a/.github/docker/headless/linux/Dockerfile b/.github/docker/headless/linux/Dockerfile new file mode 100644 index 000000000..68a8a5599 --- /dev/null +++ b/.github/docker/headless/linux/Dockerfile @@ -0,0 +1,15 @@ +FROM ubuntu:22.04 + +RUN apt-get update && \ + apt-get install -y --no-install-recommends \ + ca-certificates \ + libglib2.0-0 \ + libgtk-3-0 \ + libx11-6 \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app +COPY ./HeadlessLinux64/. . +RUN chmod +x ./HeadlessLinuxServer.x86_64 + +ENTRYPOINT ["./HeadlessLinuxServer.x86_64"] diff --git a/.github/docker/headless/windows/Dockerfile b/.github/docker/headless/windows/Dockerfile new file mode 100644 index 000000000..58c8a18cb --- /dev/null +++ b/.github/docker/headless/windows/Dockerfile @@ -0,0 +1,7 @@ +# escape=` +FROM mcr.microsoft.com/windows/servercore:ltsc2022 + +WORKDIR C:\app +COPY ./HeadlessWindows64/. . + +ENTRYPOINT ["C:\\app\\HeadlessWindowsServer.exe"] diff --git a/.github/scripts/sanitize_headless_ci.py b/.github/scripts/sanitize_headless_ci.py new file mode 100644 index 000000000..7e1c24477 --- /dev/null +++ b/.github/scripts/sanitize_headless_ci.py @@ -0,0 +1,241 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import shutil +import sys +from pathlib import Path + + +PACKAGE_DIRS_TO_REMOVE = ( + "Packages/com.valvesoftware.unity.openvr", + "Packages/com.basis.openvr", + "Packages/com.basis.openxr", + "Packages/com.basis.examples", + "Packages/com.basis.pooltable", +) + +PACKAGE_DEPENDENCIES_TO_REMOVE = ( + "com.unity.xr.openxr", + "com.valvesoftware.unity.openvr", +) + +ADDRESS_PREFIXES_TO_REMOVE = ( + "Packages/com.basis.examples/", + "Packages/com.basis.pooltable/", + "Packages/com.basis.tests/", +) + +XR_CONFIG_OBJECT_KEYS_TO_REMOVE = ( + "Unity.XR.OpenVR.Settings", + "com.unity.xr.management.loader_settings", + "com.unity.xr.openxr.settings4", +) + +XR_ASSET_PATHS_TO_REMOVE = ( + "Assets/XR", +) + + +def normalize_project_path(path: Path, project_root: Path) -> str: + return path.relative_to(project_root).as_posix() + + +def build_guid_index(project_root: Path) -> dict[str, str]: + guid_to_asset_path: dict[str, str] = {} + + for meta_path in project_root.rglob("*.meta"): + guid = "" + with meta_path.open(encoding="utf-8") as handle: + for line in handle: + if line.startswith("guid: "): + guid = line.removeprefix("guid: ").strip() + break + + if not guid: + continue + + asset_path = meta_path.with_suffix("") + guid_to_asset_path[guid] = normalize_project_path(asset_path, project_root) + + return guid_to_asset_path + + +def should_remove_entry(address: str, guid: str, guid_to_asset_path: dict[str, str]) -> bool: + if address.startswith(ADDRESS_PREFIXES_TO_REMOVE): + return True + + asset_path = guid_to_asset_path.get(guid, "") + return asset_path.startswith(PACKAGE_DIRS_TO_REMOVE) + + +def remove_manifest_dependencies(project_root: Path) -> list[str]: + manifest_path = project_root / "Packages/manifest.json" + lines = manifest_path.read_text(encoding="utf-8").splitlines(keepends=True) + output: list[str] = [] + removed_dependencies: list[str] = [] + + for line in lines: + stripped = line.strip() + removed_dependency = None + for dependency in PACKAGE_DEPENDENCIES_TO_REMOVE: + if stripped.startswith(f'"{dependency}"'): + removed_dependency = dependency + break + + if removed_dependency is not None: + removed_dependencies.append(removed_dependency) + continue + + output.append(line) + + if removed_dependencies: + manifest_path.write_text("".join(output), encoding="utf-8") + + return removed_dependencies + + +def strip_editor_build_settings(project_root: Path) -> list[str]: + settings_path = project_root / "ProjectSettings/EditorBuildSettings.asset" + lines = settings_path.read_text(encoding="utf-8").splitlines(keepends=True) + output: list[str] = [] + removed_keys: list[str] = [] + + for line in lines: + stripped = line.strip() + removed_key = None + for key in XR_CONFIG_OBJECT_KEYS_TO_REMOVE: + if stripped.startswith(f"{key}:"): + removed_key = key + break + + if removed_key is not None: + removed_keys.append(removed_key) + continue + + output.append(line) + + if removed_keys: + settings_path.write_text("".join(output), encoding="utf-8") + + return removed_keys + + +def remove_project_paths(project_root: Path, relative_paths: tuple[str, ...]) -> list[Path]: + removed_paths: list[Path] = [] + + for relative_path in relative_paths: + target_path = project_root / relative_path + meta_path = target_path.with_name(f"{target_path.name}.meta") + + if target_path.is_dir(): + shutil.rmtree(target_path) + removed_paths.append(target_path) + elif target_path.exists(): + target_path.unlink() + removed_paths.append(target_path) + + if meta_path.exists(): + meta_path.unlink() + removed_paths.append(meta_path) + + return removed_paths + + +def strip_addressable_entries(asset_path: Path, guid_to_asset_path: dict[str, str]) -> list[str]: + lines = asset_path.read_text(encoding="utf-8").splitlines(keepends=True) + output: list[str] = [] + removed_entries: list[str] = [] + index = 0 + + while index < len(lines): + line = lines[index] + output.append(line) + index += 1 + + if line != " m_SerializeEntries:\n": + continue + + while index < len(lines) and lines[index].startswith(" - m_GUID:"): + block_start = index + guid = lines[index].split(":", 1)[1].strip() + block_end = index + 1 + while block_end < len(lines): + current = lines[block_end] + if current.startswith(" - m_GUID:"): + break + block_end += 1 + + block = lines[block_start:block_end] + address = "" + for block_line in block: + marker = "m_Address:" + if marker in block_line: + address = block_line.split(marker, 1)[1].strip() + break + + if should_remove_entry(address, guid, guid_to_asset_path): + asset_ref = guid_to_asset_path.get(guid, "") + removed_entries.append(f"{address} [guid={guid} path={asset_ref}]") + else: + output.extend(block) + + index = block_end + + if removed_entries: + asset_path.write_text("".join(output), encoding="utf-8") + + return removed_entries + + +def main() -> int: + project_root = Path(sys.argv[1]) if len(sys.argv) > 1 else Path("Basis") + project_root = project_root.resolve() + + if not project_root.exists(): + print(f"Project root not found: {project_root}", file=sys.stderr) + return 1 + + guid_to_asset_path = build_guid_index(project_root) + + removed_dependencies = remove_manifest_dependencies(project_root) + if removed_dependencies: + print(f"Removed {len(removed_dependencies)} manifest dependencies from {project_root / 'Packages/manifest.json'}") + for dependency in removed_dependencies: + print(f" - {dependency}") + + removed_config_keys = strip_editor_build_settings(project_root) + if removed_config_keys: + print(f"Removed {len(removed_config_keys)} XR config objects from {project_root / 'ProjectSettings/EditorBuildSettings.asset'}") + for key in removed_config_keys: + print(f" - {key}") + + asset_groups_dir = project_root / "Assets/AddressableAssetsData/AssetGroups" + removed_total = 0 + for asset_path in sorted(asset_groups_dir.glob("*.asset")): + removed = strip_addressable_entries(asset_path, guid_to_asset_path) + if removed: + removed_total += len(removed) + print(f"Removed {len(removed)} Addressables entries from {asset_path}") + for entry in removed: + print(f" - {entry}") + + removed_xr_paths = remove_project_paths(project_root, XR_ASSET_PATHS_TO_REMOVE) + for path in removed_xr_paths: + print(f"Removed XR path: {path}") + + for relative_dir in PACKAGE_DIRS_TO_REMOVE: + package_dir = project_root / relative_dir + if package_dir.exists(): + shutil.rmtree(package_dir) + print(f"Removed package directory: {package_dir}") + else: + print(f"Package directory already absent: {package_dir}") + + if removed_total == 0: + print("No sample/test Addressables entries needed removal.") + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.github/workflows/build-docker-server.yml b/.github/workflows/build-docker-server.yml index 7074b1dbb..fe8da27a3 100644 --- a/.github/workflows/build-docker-server.yml +++ b/.github/workflows/build-docker-server.yml @@ -6,11 +6,29 @@ on: branches: - developer - long-term-support + env: REGISTRY: ghcr.io IMAGE_NAME: ${{ github.repository_owner }}/basis-server + HEADLESS_IMAGE_NAME: ${{ github.repository_owner }}/basis-headless jobs: + check-secret: + name: Check if secrets available + timeout-minutes: 5 + runs-on: ubuntu-latest + outputs: + secret-is-set: ${{ steps.secret-is-set.outputs.defined }} + steps: + - name: Check if secret is set, then set variable + id: secret-is-set + env: + TMP_SECRET1: ${{ secrets.UNITY_LICENSE }} + TMP_SECRET2: ${{ secrets.UNITY_EMAIL }} + TMP_SECRET3: ${{ secrets.UNITY_PASSWORD }} + if: "${{ env.TMP_SECRET1 != '' && env.TMP_SECRET2 != '' && env.TMP_SECRET3 != '' }}" + run: echo "defined=true" >> $GITHUB_OUTPUT + server-build-and-push: runs-on: ubuntu-latest permissions: @@ -68,4 +86,234 @@ jobs: BUILDKIT_INLINE_CACHE=1 - name: Image digest - run: 'echo "Image pushed with digest: ${{ steps.build.outputs.digest }}"' \ No newline at end of file + run: 'echo "Image pushed with digest: ${{ steps.build.outputs.digest }}"' + + headless-build: + name: Build headless for ${{ matrix.targetPlatform }} + timeout-minutes: 100 + runs-on: ${{ matrix.buildPlatform }} + permissions: + actions: write # to allow us to manage cache + env: + projectPath: Basis + strategy: + fail-fast: false + matrix: + include: + - targetPlatform: StandaloneLinux64 + buildPlatform: ubuntu-latest + buildName: HeadlessLinuxServer + buildOutput: LinuxServer + artifactName: LinuxHeadless + buildMethod: BasisHeadlessBuild.BuildLinuxServer + - targetPlatform: StandaloneWindows64 + buildPlatform: ubuntu-latest + buildName: HeadlessWindowsServer + buildOutput: WindowsServer + artifactName: WindowsHeadless + buildMethod: BasisHeadlessBuild.BuildWindowsServer + needs: [check-secret] + if: needs.check-secret.outputs.secret-is-set == 'true' + steps: + - name: Free Disk Space (Ubuntu) + uses: jlumbroso/free-disk-space@main + if: matrix.buildPlatform == 'ubuntu-latest' + with: + tool-cache: true + android: true + dotnet: true + haskell: true + large-packages: true + docker-images: false + swap-storage: false + - name: "Checkout repository" + timeout-minutes: 10 + uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: "Restore Library cache" + id: restore-cache + timeout-minutes: 10 + uses: actions/cache/restore@v3 + with: + path: ${{ env.projectPath }}/Library + key: Library-${{ env.projectPath }}-${{ matrix.targetPlatform }}-${{ hashFiles(env.projectPath) }} + restore-keys: Library-${{ env.projectPath }}-${{ matrix.targetPlatform }}- + - name: "Sanitize headless project (Linux)" + if: runner.os == 'Linux' + shell: bash + run: | + python3 .github/scripts/sanitize_headless_ci.py "${projectPath}" + - name: "Disable OpenVR editor setup for Windows headless builds" + if: runner.os == 'Windows' + shell: pwsh + run: | + $settingsPath = Join-Path $env:projectPath 'ProjectSettings/EditorBuildSettings.asset' + $settingsLines = Get-Content $settingsPath + $settingsLines | + Where-Object { $_ -notmatch '^\s*Unity\.XR\.OpenVR\.Settings:' } | + Set-Content $settingsPath + + @( + 'Assets/XR/Settings/OpenVRSettings.asset', + 'Assets/XR/Settings/OpenVRSettings.asset.meta' + ) | ForEach-Object { + $path = Join-Path $env:projectPath $_ + if (Test-Path $path) { + Remove-Item $path -Force + } + } + - name: "Sanitize headless project (Windows)" + if: runner.os == 'Windows' + shell: pwsh + run: | + python .github/scripts/sanitize_headless_ci.py "${env:projectPath}" + - name: "Build Unity project" + timeout-minutes: 100 + uses: BasisVR/unity-builder@main + env: + UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }} + UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }} + UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }} + with: + buildName: ${{ matrix.buildName }} + buildMethod: ${{ matrix.buildMethod }} + customParameters: -standaloneBuildSubtarget Server + projectPath: ${{ env.projectPath }} + targetPlatform: ${{ matrix.targetPlatform }} + versioning: None + buildsPath: build/${{ matrix.buildOutput }} + linux64RemoveExecutableExtension: false + - name: "Rename Windows headless output folder" + if: matrix.targetPlatform == 'StandaloneWindows64' + shell: bash + run: | + sudo mv "build/${{ matrix.buildOutput }}/StandaloneWindows64" "build/${{ matrix.buildOutput }}/HeadlessWindows64" + sudo chown -R "$(id -u):$(id -g)" "build/${{ matrix.buildOutput }}/HeadlessWindows64" + - name: "Rename Linux headless output folder" + if: matrix.targetPlatform == 'StandaloneLinux64' + shell: bash + run: | + sudo mv "build/${{ matrix.buildOutput }}/StandaloneLinux64" "build/${{ matrix.buildOutput }}/HeadlessLinux64" + sudo chown -R "$(id -u):$(id -g)" "build/${{ matrix.buildOutput }}/HeadlessLinux64" + - name: "Save Library Cache" + uses: actions/cache/save@v3 + if: always() + with: + path: ${{ env.projectPath }}/Library + key: ${{ steps.restore-cache.outputs.cache-primary-key }} + - name: "Only retain latest cache" + if: always() + shell: bash + env: + GH_TOKEN: ${{ github.token }} + run: | + OLD_CACHE_IDS=$(gh cache list --sort created_at --key Library-${{ env.projectPath }}-${{ matrix.targetPlatform }}- --json id --jq '.[1:] | map(.id) | @sh') + for cache_id in $OLD_CACHE_IDS; do + echo "Deleting cache id: $cache_id" + gh cache delete $cache_id + done + - name: "Upload headless artifact" + timeout-minutes: 5 + uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.artifactName }} + path: build/${{ matrix.buildOutput }} + + headless-docker-linux: + name: Build & push headless Linux image + runs-on: ubuntu-latest + needs: [headless-build] + permissions: + contents: read + packages: write + steps: + - name: Checkout repository + uses: actions/checkout@v4 + - name: Download Linux headless artifact + uses: actions/download-artifact@v4 + with: + name: LinuxHeadless + path: headless + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + - name: Log in to GitHub Container Registry + if: github.event_name != 'pull_request' + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Extract metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.HEADLESS_IMAGE_NAME }} + tags: | + type=raw,value=nightly-linux,enable=${{ github.ref == 'refs/heads/developer' }} + type=raw,value=latest-linux,enable=${{ github.ref == 'refs/heads/long-term-support' }} + type=sha,prefix={{branch}}-,suffix=-linux,format=short + type=ref,event=branch,suffix=-linux + - name: Build and push headless Linux image + uses: docker/build-push-action@v5 + id: build + with: + context: ./headless + file: ./.github/docker/headless/linux/Dockerfile + platforms: linux/amd64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + - name: Image digest + run: 'echo "Headless Linux image pushed with digest: ${{ steps.build.outputs.digest }}"' + + headless-docker-windows: + name: Build & push headless Windows image + runs-on: ubuntu-latest + needs: [headless-build] + permissions: + contents: read + packages: write + steps: + - name: Checkout repository + uses: actions/checkout@v4 + - name: Download Windows headless artifact + uses: actions/download-artifact@v4 + with: + name: WindowsHeadless + path: headless + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + - name: Log in to GitHub Container Registry + if: github.event_name != 'pull_request' + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Extract metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.HEADLESS_IMAGE_NAME }} + tags: | + type=raw,value=nightly-windows,enable=${{ github.ref == 'refs/heads/developer' }} + type=raw,value=latest-windows,enable=${{ github.ref == 'refs/heads/long-term-support' }} + type=sha,prefix={{branch}}-,suffix=-windows,format=short + type=ref,event=branch,suffix=-windows + - name: Build and push headless Windows image + uses: docker/build-push-action@v5 + id: build + with: + context: ./headless + file: ./.github/docker/headless/windows/Dockerfile + platforms: windows/amd64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + - name: Image digest + run: 'echo "Headless Windows image pushed with digest: ${{ steps.build.outputs.digest }}"' + + diff --git a/Basis/Assets/Editor/BasisHeadlessBuild.cs b/Basis/Assets/Editor/BasisHeadlessBuild.cs new file mode 100644 index 000000000..ecea0d771 --- /dev/null +++ b/Basis/Assets/Editor/BasisHeadlessBuild.cs @@ -0,0 +1,193 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using UnityEditor; +using UnityEditor.Build; +using UnityEditor.Build.Reporting; +using UnityEditor.AddressableAssets; +using UnityEditor.AddressableAssets.Build; +using UnityEditor.AddressableAssets.Settings; +using UnityEngine; + +public static class BasisHeadlessBuild +{ + private const string AddressablesBuildWithPlayerPreferenceKey = "Addressables.BuildAddressablesWithPlayerBuild"; + + public static void BuildLinuxServer() + { + BuildServer(BuildTarget.StandaloneLinux64); + } + + public static void BuildWindowsServer() + { + BuildServer(BuildTarget.StandaloneWindows64); + } + + private static void BuildServer(BuildTarget target) + { + string buildPath = RequireArgument("customBuildPath"); + string buildName = GetArgument("customBuildName") ?? Path.GetFileNameWithoutExtension(buildPath); + string projectPath = GetArgument("projectPath") ?? Directory.GetCurrentDirectory(); + string standaloneSubtargetArg = GetArgument("standaloneBuildSubtarget") ?? "Server"; + + Debug.Log($"[BasisHeadlessBuild] Starting {target} build"); + Debug.Log($"[BasisHeadlessBuild] projectPath={projectPath}"); + Debug.Log($"[BasisHeadlessBuild] buildName={buildName}"); + Debug.Log($"[BasisHeadlessBuild] buildPath={buildPath}"); + Debug.Log($"[BasisHeadlessBuild] activeBuildTarget(before)={EditorUserBuildSettings.activeBuildTarget}"); + Debug.Log($"[BasisHeadlessBuild] activeBuildTargetGroup(before)={BuildPipeline.GetBuildTargetGroup(EditorUserBuildSettings.activeBuildTarget)}"); + Debug.Log($"[BasisHeadlessBuild] standaloneBuildSubtarget(arg)={standaloneSubtargetArg}"); + + BuildTargetGroup targetGroup = BuildPipeline.GetBuildTargetGroup(target); + if (!BuildPipeline.IsBuildTargetSupported(targetGroup, target)) + { + throw new BuildFailedException($"Build target {target} is not supported in this editor."); + } + + if (EditorUserBuildSettings.activeBuildTarget != target) + { + bool switched = EditorUserBuildSettings.SwitchActiveBuildTarget(targetGroup, target); + Debug.Log($"[BasisHeadlessBuild] SwitchActiveBuildTarget({target}) => {switched}"); + } + + StandaloneBuildSubtarget standaloneSubtarget = ParseStandaloneSubtarget(standaloneSubtargetArg); + EditorUserBuildSettings.standaloneBuildSubtarget = standaloneSubtarget; + Debug.Log($"[BasisHeadlessBuild] activeBuildTarget(after)={EditorUserBuildSettings.activeBuildTarget}"); + Debug.Log($"[BasisHeadlessBuild] standaloneBuildSubtarget(set)={EditorUserBuildSettings.standaloneBuildSubtarget}"); + + EnsureBuildDirectory(buildPath); + LogEnabledScenes(); + + AddressableAssetSettings addressableSettings = AddressableAssetSettingsDefaultObject.Settings; + bool restoreBuildAddressablesWithPlayerBuild = false; + AddressableAssetSettings.PlayerBuildOption originalBuildAddressablesWithPlayerBuild = AddressableAssetSettings.PlayerBuildOption.PreferencesValue; + if (addressableSettings != null) + { + originalBuildAddressablesWithPlayerBuild = addressableSettings.BuildAddressablesWithPlayerBuild; + restoreBuildAddressablesWithPlayerBuild = true; + if (ShouldBuildAddressablesWithPlayerBuild(originalBuildAddressablesWithPlayerBuild)) + { + BuildAddressables(target, addressableSettings); + } + addressableSettings.BuildAddressablesWithPlayerBuild = AddressableAssetSettings.PlayerBuildOption.DoNotBuildWithPlayer; + Debug.Log($"[BasisHeadlessBuild] Overriding BuildAddressablesWithPlayerBuild: {originalBuildAddressablesWithPlayerBuild} -> {addressableSettings.BuildAddressablesWithPlayerBuild}"); + } + else + { + Debug.LogWarning("[BasisHeadlessBuild] Addressables settings not found; continuing without Addressables override."); + } + + try + { + BuildPlayerOptions options = new BuildPlayerOptions + { + scenes = EditorBuildSettings.scenes.Where(scene => scene.enabled).Select(scene => scene.path).ToArray(), + locationPathName = buildPath, + target = target, + targetGroup = targetGroup, + subtarget = (int)standaloneSubtarget, + options = BuildOptions.None + }; + + BuildReport report = BuildPipeline.BuildPlayer(options); + Debug.Log($"[BasisHeadlessBuild] Build result={report.summary.result}"); + Debug.Log($"[BasisHeadlessBuild] Build output path={report.summary.outputPath}"); + Debug.Log($"[BasisHeadlessBuild] Build totalErrors={report.summary.totalErrors}"); + Debug.Log($"[BasisHeadlessBuild] Build totalWarnings={report.summary.totalWarnings}"); + + if (report.summary.result != UnityEditor.Build.Reporting.BuildResult.Succeeded) + { + throw new BuildFailedException($"Player build failed: {report.summary.result}"); + } + } + finally + { + if (restoreBuildAddressablesWithPlayerBuild) + { + addressableSettings.BuildAddressablesWithPlayerBuild = originalBuildAddressablesWithPlayerBuild; + Debug.Log($"[BasisHeadlessBuild] Restored BuildAddressablesWithPlayerBuild={addressableSettings.BuildAddressablesWithPlayerBuild}"); + } + } + } + + private static bool ShouldBuildAddressablesWithPlayerBuild(AddressableAssetSettings.PlayerBuildOption option) + { + switch (option) + { + case AddressableAssetSettings.PlayerBuildOption.BuildWithPlayer: + return true; + case AddressableAssetSettings.PlayerBuildOption.DoNotBuildWithPlayer: + return false; + case AddressableAssetSettings.PlayerBuildOption.PreferencesValue: + return EditorPrefs.GetBool(AddressablesBuildWithPlayerPreferenceKey, true); + default: + return false; + } + } + + private static void BuildAddressables(BuildTarget target, AddressableAssetSettings settings) + { + Debug.Log($"[BasisHeadlessBuild] Building Addressables explicitly for {target}. Active profile={settings.activeProfileId}, builderIndex={settings.ActivePlayerDataBuilderIndex}"); + AddressableAssetSettings.BuildPlayerContent(out AddressablesPlayerBuildResult result); + Debug.Log($"[BasisHeadlessBuild] Addressables result.Error='{result.Error}'"); + Debug.Log($"[BasisHeadlessBuild] Addressables outputPath='{result.OutputPath}'"); + + if (!string.IsNullOrWhiteSpace(result.Error)) + { + throw new BuildFailedException($"Addressables build failed: {result.Error}"); + } + } + + private static void LogEnabledScenes() + { + foreach (EditorBuildSettingsScene scene in EditorBuildSettings.scenes) + { + Debug.Log($"[BasisHeadlessBuild] Scene enabled={scene.enabled} path={scene.path}"); + } + } + + private static void EnsureBuildDirectory(string buildPath) + { + string directory = Path.GetDirectoryName(buildPath); + if (!string.IsNullOrEmpty(directory)) + { + Directory.CreateDirectory(directory); + } + } + + private static StandaloneBuildSubtarget ParseStandaloneSubtarget(string value) + { + if (Enum.TryParse(value, true, out StandaloneBuildSubtarget parsed)) + { + return parsed; + } + + return StandaloneBuildSubtarget.Server; + } + + private static string RequireArgument(string name) + { + string value = GetArgument(name); + if (string.IsNullOrWhiteSpace(value)) + { + throw new BuildFailedException($"Required command line argument '-{name}' was not provided."); + } + + return value; + } + + private static string GetArgument(string name) + { + string[] args = Environment.GetCommandLineArgs(); + for (int index = 0; index < args.Length - 1; index++) + { + if (args[index] == $"-{name}") + { + return args[index + 1]; + } + } + + return null; + } +} diff --git a/Basis/Packages/com.basis.examples/Scripts/BasisNetworkHeadlessDriver.cs b/Basis/Packages/com.basis.examples/Scripts/BasisNetworkHeadlessDriver.cs index 45c49a414..6866b07c0 100644 --- a/Basis/Packages/com.basis.examples/Scripts/BasisNetworkHeadlessDriver.cs +++ b/Basis/Packages/com.basis.examples/Scripts/BasisNetworkHeadlessDriver.cs @@ -173,6 +173,11 @@ public override async void OnNetworkMessage(ushort playerID, byte[] buffer, Deli BasisLocalPlayer.Instance.Teleport(Position, Rotation); await BasisLocalPlayer.Instance.CreateAvatar(0, data); + if (BasisHeadlessInput.Instance != null) + { + BasisHeadlessInput.Instance.ResumeMovement(); + } + BasisDebug.Log($"[HeadlessDriver] Teleported player {playerID} to transform[{index}] at {Position}.", BasisDebug.LogTag.Remote); break; } diff --git a/Basis/Packages/com.basis.framework/Device Management/Devices/Headless/BasisHeadlessInput.cs b/Basis/Packages/com.basis.framework/Device Management/Devices/Headless/BasisHeadlessInput.cs index 3f86a3a41..12195ee8d 100644 --- a/Basis/Packages/com.basis.framework/Device Management/Devices/Headless/BasisHeadlessInput.cs +++ b/Basis/Packages/com.basis.framework/Device Management/Devices/Headless/BasisHeadlessInput.cs @@ -168,14 +168,42 @@ private void CancelStopForSecondsCo() // ---------------------------------------------------------------------- + private float GetBaseUnscaledHeight() + { + float height = BasisHeightDriver.SelectedUnScaledPlayerHeight + BasisHeightDriver.AdditionalPlayerHeight; + if (float.IsNaN(height) || float.IsInfinity(height) || height <= 0f) + { + height = BasisHeightDriver.FallbackHeightInMeters; + } + return height; + } + + private float GetUnscaledHeadHeightForCrouch() + { + if (Control == null) + { + return GetBaseUnscaledHeight(); + } + + float headHeight = Control.TposeLocalScaled.position.y; + float scale = BasisHeightDriver.DeviceScale; + if (float.IsNaN(scale) || float.IsInfinity(scale) || scale <= 0f) + { + return headHeight; + } + + return headHeight / scale; + } + public void Initialize(string ID = "Desktop Eye", string subSystems = "BasisDesktopManagement") { BasisDebug.Log("Initializing Avatar Eye", BasisDebug.LogTag.Input); - float height = BasisHeightDriver.SelectedScaledPlayerHeight; + float height = GetBaseUnscaledHeight(); - ScaledDeviceCoord.position = new Vector3(0, height, 0); - ScaledDeviceCoord.rotation = Quaternion.identity; + UnscaledDeviceCoord.position = new Vector3(0, height, 0); + UnscaledDeviceCoord.rotation = Quaternion.identity; + ConvertToScaledDeviceCoord(); InitalizeTracking(ID, ID, subSystems, true, BasisBoneTrackedRole.CenterEye); @@ -262,7 +290,7 @@ public override void LateDoPollData() UnscaledDeviceCoord.rotation = currentRotation; // maintain height with crouch compensation - float baseHeightLocked = BasisHeightDriver.SelectedScaledPlayerHeight; + float baseHeightLocked = GetBaseUnscaledHeight(); Vector3 posLocked = new Vector3(0, baseHeightLocked, 0); if (!BasisLocks.GetContext(BasisLocks.Crouching)) @@ -270,12 +298,13 @@ public override void LateDoPollData() float crouchMin = charDriverLocked.MinimumCrouchPercent; float crouchBlend = charDriverLocked.CrouchBlend; float heightAdjust = (1f - crouchMin) * crouchBlend + crouchMin; - posLocked.y -= Control.TposeLocalScaled.position.y * (1f - heightAdjust); + float headHeightUnscaled = GetUnscaledHeadHeightForCrouch(); + posLocked.y -= headHeightUnscaled * (1f - heightAdjust); } UnscaledDeviceCoord.position = posLocked; - ScaledDeviceCoord.position = posLocked; - ScaledDeviceCoord.rotation = currentRotation; + UnscaledDeviceCoord.rotation = currentRotation; + ConvertToScaledDeviceCoord(); ControlOnlyAsDevice(); ComputeRaycastDirection(ScaledDeviceCoord.position, ScaledDeviceCoord.rotation, Quaternion.identity); @@ -363,7 +392,7 @@ public override void LateDoPollData() // --- Head pose at eye height with crouch compensation --- UnscaledDeviceCoord.rotation = currentRotation; - float baseHeight = BasisHeightDriver.SelectedScaledPlayerHeight; + float baseHeight = GetBaseUnscaledHeight(); Vector3 pos = new Vector3(0, baseHeight, 0); if (!BasisLocks.GetContext(BasisLocks.Crouching)) @@ -371,12 +400,13 @@ public override void LateDoPollData() float crouchMin = charDriver.MinimumCrouchPercent; float crouchBlend = charDriver.CrouchBlend; float heightAdjust = (1f - crouchMin) * crouchBlend + crouchMin; - pos.y -= Control.TposeLocalScaled.position.y * (1f - heightAdjust); + float headHeightUnscaled = GetUnscaledHeadHeightForCrouch(); + pos.y -= headHeightUnscaled * (1f - heightAdjust); } UnscaledDeviceCoord.position = pos; - ScaledDeviceCoord.position = pos; - ScaledDeviceCoord.rotation = currentRotation; + UnscaledDeviceCoord.rotation = currentRotation; + ConvertToScaledDeviceCoord(); // Drive our CenterEye bone ControlOnlyAsDevice(); diff --git a/Basis/Packages/com.basis.framework/Device Management/Devices/Headless/BasisHeadlessManagement.cs b/Basis/Packages/com.basis.framework/Device Management/Devices/Headless/BasisHeadlessManagement.cs index ee8e2d3ff..1ea82ee70 100644 --- a/Basis/Packages/com.basis.framework/Device Management/Devices/Headless/BasisHeadlessManagement.cs +++ b/Basis/Packages/com.basis.framework/Device Management/Devices/Headless/BasisHeadlessManagement.cs @@ -3,6 +3,7 @@ using Basis.Scripts.Device_Management.Devices.Headless; using Basis.Scripts.Drivers; using Basis.Scripts.Networking; +using System; using System.Collections.Generic; using System.IO; using System.Threading.Tasks; @@ -124,24 +125,85 @@ private void RemoveAllText() public static void LoadOrCreateConfigXml() { string filePath = Path.Combine(Application.dataPath, "config.xml"); - if (!File.Exists(filePath)) + string defaultPassword = Password; + string defaultIp = Ip; + int defaultPort = Port; + string envPassword = ReadEnvironmentString("Password"); + string envIp = ReadEnvironmentString("Ip"); + int? envPort = ReadEnvironmentInt("Port"); + + if (envPassword != null && envIp != null && envPort.HasValue) + { + Password = envPassword; + Ip = envIp; + Port = envPort.Value; + return; + } + + XElement root = null; + if (File.Exists(filePath)) + { + var doc = XDocument.Load(filePath); + root = doc.Element("Configuration"); + } + else + { + TryCreateDefaultConfigXml(filePath, defaultPassword, defaultIp, defaultPort); + } + + Password = envPassword ?? root?.Element("Password")?.Value ?? defaultPassword; + Ip = envIp ?? root?.Element("Ip")?.Value ?? defaultIp; + Port = envPort ?? ReadXmlInt(root?.Element("Port")?.Value, defaultPort); + } + + private static void TryCreateDefaultConfigXml(string filePath, string password, string ip, int port) + { + try { var defaultConfig = new XElement("Configuration", - new XElement("Password", Password), - new XElement("Ip", Ip), - new XElement("Port", Port) + new XElement("Password", password), + new XElement("Ip", ip), + new XElement("Port", port) ); new XDocument(defaultConfig).Save(filePath); - return; } + catch (Exception ex) + { + Debug.LogWarning($"Unable to create default headless config at '{filePath}'. Continuing with environment/default values. {ex.Message}"); + } + } - var doc = XDocument.Load(filePath); - var root = doc.Element("Configuration"); - if (root == null) return; + private static string ReadEnvironmentString(string envName) + { + string envValue = Environment.GetEnvironmentVariable(envName); + if (string.IsNullOrWhiteSpace(envValue)) + { + return null; + } - Password = root.Element("Password")?.Value ?? Password; - Ip = root.Element("Ip")?.Value ?? Ip; - Port = int.TryParse(root.Element("Port")?.Value, out var p) ? p : Port; + return envValue; + } + + private static int? ReadEnvironmentInt(string envName) + { + string envValue = Environment.GetEnvironmentVariable(envName); + if (string.IsNullOrWhiteSpace(envValue)) + { + return null; + } + + if (int.TryParse(envValue, out int parsed)) + { + return parsed; + } + + Debug.LogWarning($"Invalid headless environment variable '{envName}' value '{envValue}'. Falling back to config.xml/defaults."); + return null; + } + + private static int ReadXmlInt(string value, int fallback) + { + return int.TryParse(value, out int parsed) ? parsed : fallback; } /// @@ -183,16 +245,14 @@ public async Task CreateAssetBundle() public override void StartSDK() { #if UNITY_SERVER - if (BasisHeadlessInput == null) + if (BasisLocalPlayer.PlayerReady && BasisLocalPlayer.Instance != null) { - GameObject gameObject = new GameObject("Headless Eye"); - if (BasisLocalPlayer.Instance != null) - { - gameObject.transform.parent = BasisLocalPlayer.Instance.transform; - } - BasisHeadlessInput = gameObject.AddComponent(); - BasisHeadlessInput.Initialize("Desktop Eye", nameof(Basis.Scripts.Device_Management.Devices.Headless.BasisHeadlessInput)); - BasisDeviceManagement.Instance.TryAdd(BasisHeadlessInput); + EnsureHeadlessInput(); + } + else + { + BasisLocalPlayer.OnLocalPlayerInitalized -= OnLocalPlayerReadyForHeadless; + BasisLocalPlayer.OnLocalPlayerInitalized += OnLocalPlayerReadyForHeadless; } BasisDebug.Log(nameof(StartSDK), BasisDebug.LogTag.Device); @@ -216,6 +276,42 @@ public override void StartSDK() BasisDebug.Log(nameof(StartSDK), BasisDebug.LogTag.Device); } + private void OnDestroy() + { +#if UNITY_SERVER + BasisLocalPlayer.OnLocalPlayerInitalized -= OnLocalPlayerReadyForHeadless; +#endif + } + +#if UNITY_SERVER + private void OnLocalPlayerReadyForHeadless() + { + BasisLocalPlayer.OnLocalPlayerInitalized -= OnLocalPlayerReadyForHeadless; + EnsureHeadlessInput(); + } + + private void EnsureHeadlessInput() + { + if (BasisHeadlessInput != null) + { + return; + } + + if (BasisLocalPlayer.Instance == null) + { + BasisDebug.LogWarning("Headless input creation delayed: LocalPlayer instance is null.", BasisDebug.LogTag.Device); + return; + } + + GameObject gameObject = new GameObject("Headless Eye"); + gameObject.transform.parent = BasisLocalPlayer.Instance.transform; + + BasisHeadlessInput = gameObject.AddComponent(); + BasisHeadlessInput.Initialize("Desktop Eye", nameof(Basis.Scripts.Device_Management.Devices.Headless.BasisHeadlessInput)); + BasisDeviceManagement.Instance.TryAdd(BasisHeadlessInput); + } +#endif + /// public override void StopSDK() { diff --git a/Basis/Packages/com.basis.framework/Networking/BasisNetworkEvents.cs b/Basis/Packages/com.basis.framework/Networking/BasisNetworkEvents.cs index 30f52476b..785316daf 100644 --- a/Basis/Packages/com.basis.framework/Networking/BasisNetworkEvents.cs +++ b/Basis/Packages/com.basis.framework/Networking/BasisNetworkEvents.cs @@ -439,8 +439,39 @@ public static bool ValidateSize(NetPacketReader reader, NetPeer peer, byte chann } public static void HandleDisconnectionReason(DisconnectInfo disconnectInfo) { +#if UNITY_SERVER + bool canShowMenu = !UnityEngine.Application.isBatchMode; +#endif + if (disconnectInfo.Reason == DisconnectReason.RemoteConnectionClose) { +#if UNITY_SERVER + string reason = null; + if (disconnectInfo.AdditionalData != null && + disconnectInfo.AdditionalData.TryGetString(out string parsedReason)) + { + reason = parsedReason; + } + + if (!string.IsNullOrEmpty(reason)) + { + if (canShowMenu) + { + BasisMainMenu.Open(); + if (BasisMainMenu.Instance != null) + { + BasisMainMenu.Instance.OpenDialogue("Server Connection", reason, "ok", value => + { + }); + } + } + BasisDebug.LogError(reason); + } + else + { + BasisDebug.Log($"Unexpected Failure Of Reason {disconnectInfo.Reason}"); + } +#else if (disconnectInfo.AdditionalData.TryGetString(out string Reason)) { BasisMainMenu.Open(); @@ -453,15 +484,31 @@ public static void HandleDisconnectionReason(DisconnectInfo disconnectInfo) { BasisDebug.Log($"Unexpected Failure Of Reason {disconnectInfo.Reason}"); } +#endif } else { +#if UNITY_SERVER + if (canShowMenu) + { + BasisMainMenu.Open(); + if (BasisMainMenu.Instance != null) + { + BasisMainMenu.Instance.OpenDialogue("Server Disconnected", disconnectInfo.Reason.ToString(), "ok", value => + { + }); + } + } + + BasisDebug.LogError(disconnectInfo.Reason.ToString()); +#else BasisMainMenu.Open(); BasisMainMenu.Instance.OpenDialogue("Server Disconnected", disconnectInfo.Reason.ToString(), "ok", value => { }); BasisDebug.LogError(disconnectInfo.Reason.ToString()); +#endif } } } diff --git a/Basis/Packages/com.basis.framework/Settings/SMModuleHDRURP.cs b/Basis/Packages/com.basis.framework/Settings/SMModuleHDRURP.cs index b20af0153..b34043ea4 100644 --- a/Basis/Packages/com.basis.framework/Settings/SMModuleHDRURP.cs +++ b/Basis/Packages/com.basis.framework/Settings/SMModuleHDRURP.cs @@ -12,6 +12,10 @@ public override void ValidSettingsChange(string matchedSettingName, string optio // Only react to the HDR setting if (matchedSettingName != K_HDR_SUPPORT) return; +#if UNITY_SERVER + BasisDebug.LogWarning("SMModuleHDRURP: Running on server build. HDR changes will not be applied.", BasisDebug.LogTag.Local); + return; +#endif UniversalRenderPipelineAsset asset = (UniversalRenderPipelineAsset)QualitySettings.renderPipeline; diff --git a/Basis/ProjectSettings/ProjectSettings.asset b/Basis/ProjectSettings/ProjectSettings.asset index abd5ab849..92ca99a6a 100644 --- a/Basis/ProjectSettings/ProjectSettings.asset +++ b/Basis/ProjectSettings/ProjectSettings.asset @@ -850,7 +850,7 @@ PlayerSettings: platformArchitecture: {} scriptingBackend: Android: 1 - Server: 1 + Server: 0 Standalone: 1 il2cppCompilerConfiguration: {} il2cppCodeGeneration: {} From 004992e33ca87e12e84938f62509d9ae4ac34c6f Mon Sep 17 00:00:00 2001 From: Toys0125 Date: Tue, 24 Mar 2026 21:28:49 -0500 Subject: [PATCH 05/14] Add default config. --- .github/docker/headless/linux/Dockerfile | 8 ++++++++ .github/docker/headless/windows/Dockerfile | 8 ++++++++ 2 files changed, 16 insertions(+) diff --git a/.github/docker/headless/linux/Dockerfile b/.github/docker/headless/linux/Dockerfile index 68a8a5599..2a6ab288b 100644 --- a/.github/docker/headless/linux/Dockerfile +++ b/.github/docker/headless/linux/Dockerfile @@ -10,6 +10,14 @@ RUN apt-get update && \ WORKDIR /app COPY ./HeadlessLinux64/. . +RUN cat <<'EOF' > ./HeadlessLinuxServer_Data/config.xml + + + default_password + server1.basisvr.org + 4296 + +EOF RUN chmod +x ./HeadlessLinuxServer.x86_64 ENTRYPOINT ["./HeadlessLinuxServer.x86_64"] diff --git a/.github/docker/headless/windows/Dockerfile b/.github/docker/headless/windows/Dockerfile index 58c8a18cb..886f0ab9f 100644 --- a/.github/docker/headless/windows/Dockerfile +++ b/.github/docker/headless/windows/Dockerfile @@ -3,5 +3,13 @@ FROM mcr.microsoft.com/windows/servercore:ltsc2022 WORKDIR C:\app COPY ./HeadlessWindows64/. . +RUN cat <<'EOF' > ./HeadlessLinuxServer_Data/config.xml + + + default_password + server1.basisvr.org + 4296 + +EOF ENTRYPOINT ["C:\\app\\HeadlessWindowsServer.exe"] From 3eabbea55d735b79b23b63d94bb055ceced1c855 Mon Sep 17 00:00:00 2001 From: Toys0125 Date: Wed, 25 Mar 2026 00:05:26 -0500 Subject: [PATCH 06/14] Have the config.xml be in the headless artifacts. --- .github/docker/headless/linux/Dockerfile | 8 -------- .github/docker/headless/windows/Dockerfile | 9 --------- .github/workflows/build-docker-server.yml | 18 ++++++++++++++++++ 3 files changed, 18 insertions(+), 17 deletions(-) diff --git a/.github/docker/headless/linux/Dockerfile b/.github/docker/headless/linux/Dockerfile index 2a6ab288b..68a8a5599 100644 --- a/.github/docker/headless/linux/Dockerfile +++ b/.github/docker/headless/linux/Dockerfile @@ -10,14 +10,6 @@ RUN apt-get update && \ WORKDIR /app COPY ./HeadlessLinux64/. . -RUN cat <<'EOF' > ./HeadlessLinuxServer_Data/config.xml - - - default_password - server1.basisvr.org - 4296 - -EOF RUN chmod +x ./HeadlessLinuxServer.x86_64 ENTRYPOINT ["./HeadlessLinuxServer.x86_64"] diff --git a/.github/docker/headless/windows/Dockerfile b/.github/docker/headless/windows/Dockerfile index 886f0ab9f..259b63dfb 100644 --- a/.github/docker/headless/windows/Dockerfile +++ b/.github/docker/headless/windows/Dockerfile @@ -1,15 +1,6 @@ -# escape=` FROM mcr.microsoft.com/windows/servercore:ltsc2022 WORKDIR C:\app COPY ./HeadlessWindows64/. . -RUN cat <<'EOF' > ./HeadlessLinuxServer_Data/config.xml - - - default_password - server1.basisvr.org - 4296 - -EOF ENTRYPOINT ["C:\\app\\HeadlessWindowsServer.exe"] diff --git a/.github/workflows/build-docker-server.yml b/.github/workflows/build-docker-server.yml index fe8da27a3..9e7796a78 100644 --- a/.github/workflows/build-docker-server.yml +++ b/.github/workflows/build-docker-server.yml @@ -196,6 +196,24 @@ jobs: run: | sudo mv "build/${{ matrix.buildOutput }}/StandaloneLinux64" "build/${{ matrix.buildOutput }}/HeadlessLinux64" sudo chown -R "$(id -u):$(id -g)" "build/${{ matrix.buildOutput }}/HeadlessLinux64" + - name: "Write headless config.xml into artifact" + shell: bash + run: | + if [ "${{ matrix.targetPlatform }}" = "StandaloneWindows64" ]; then + data_dir="build/${{ matrix.buildOutput }}/HeadlessWindows64/HeadlessWindowsServer_Data" + else + data_dir="build/${{ matrix.buildOutput }}/HeadlessLinux64/HeadlessLinuxServer_Data" + fi + + mkdir -p "$data_dir" + cat > "$data_dir/config.xml" <<'EOF' + + + default_password + server1.basisvr.org + 4296 + + EOF - name: "Save Library Cache" uses: actions/cache/save@v3 if: always() From 1590e07616f629fb32e933c33859b9b8d3423f47 Mon Sep 17 00:00:00 2001 From: Toys0125 Date: Wed, 25 Mar 2026 17:18:54 -0500 Subject: [PATCH 07/14] Should disable Opus in Unity_Server. --- .../Recievers/BasisAudioReceiver.cs | 18 ++++++++++++++++ .../Recievers/BasisShoutAudioDriver.cs | 9 ++++++++ .../Transmitters/BasisAudioTransmission.cs | 21 +++++++++++++++++++ 3 files changed, 48 insertions(+) diff --git a/Basis/Packages/com.basis.framework/Networking/Recievers/BasisAudioReceiver.cs b/Basis/Packages/com.basis.framework/Networking/Recievers/BasisAudioReceiver.cs index f84bdf739..3fd59686c 100644 --- a/Basis/Packages/com.basis.framework/Networking/Recievers/BasisAudioReceiver.cs +++ b/Basis/Packages/com.basis.framework/Networking/Recievers/BasisAudioReceiver.cs @@ -3,8 +3,10 @@ using Basis.Scripts.Device_Management; using Basis.Scripts.Drivers; using Basis.Scripts.Networking.NetworkedAvatar; +#if !UNITY_SERVER using OpusSharp.Core; using OpusSharp.Core.Extensions; +#endif using System; using System.Runtime.CompilerServices; using System.Threading.Tasks; @@ -97,7 +99,9 @@ public class BasisAudioReceiver /// Opus decoder used for network voice frames. /// +#if !UNITY_SERVER public OpusDecoder decoder; +#endif private float[] _inputScratch; // big enough for the largest chunk we pull private int _cachedOutputRate = -1; @@ -114,11 +118,15 @@ public class BasisAudioReceiver /// Payload length in bytes. public void OnDecode(byte[] data, int length) { +#if UNITY_SERVER + return; +#else if (HasAudioSource) { pcmLength = decoder.Decode(data, length, pcmBuffer, RemoteOpusSettings.FrameSize, false); InOrderRead.Add(pcmBuffer, pcmLength, true); } +#endif } /// @@ -204,6 +212,9 @@ public void DrainAndDecode() /// public void OnDecodePLC() { +#if UNITY_SERVER + return; +#else if (HasAudioSource) { try @@ -216,6 +227,7 @@ public void OnDecodePLC() InOrderRead.Add(silentData, RemoteOpusSettings.FrameSize, false); } } +#endif } /// /// Creates/attaches an and begins playback for the given player. @@ -311,11 +323,13 @@ public void Initialize(BasisNetworkReceiver networkedPlayer) /// public void OnDestroy() { +#if !UNITY_SERVER if (decoder != null) { decoder.Dispose(); decoder = null; } +#endif UnloadAudioSource(); } @@ -455,6 +469,7 @@ public void ChangeRemotePlayersVolumeSettings(float volume = 1.0f, float doppler { if (audioSource == null) { +#if !UNITY_SERVER if (decoder != null) { try @@ -466,6 +481,7 @@ public void ChangeRemotePlayersVolumeSettings(float volume = 1.0f, float doppler // SetGain may fail on some Opus builds - non-fatal } } +#endif BasisDebug.LogError("AudioSource is null. Cannot apply volume settings.", BasisDebug.LogTag.Remote); return; } @@ -487,6 +503,7 @@ public void ChangeRemotePlayersVolumeSettings(float volume = 1.0f, float doppler gain = (short)(db * 256f); audioSource.volume = 1; } +#if !UNITY_SERVER if (decoder != null) { try @@ -505,6 +522,7 @@ public void ChangeRemotePlayersVolumeSettings(float volume = 1.0f, float doppler { BasisDebug.LogWarning("Decoder is null. Cannot apply gain."); } +#endif } /// diff --git a/Basis/Packages/com.basis.framework/Networking/Recievers/BasisShoutAudioDriver.cs b/Basis/Packages/com.basis.framework/Networking/Recievers/BasisShoutAudioDriver.cs index eb8135457..0eb20d23e 100644 --- a/Basis/Packages/com.basis.framework/Networking/Recievers/BasisShoutAudioDriver.cs +++ b/Basis/Packages/com.basis.framework/Networking/Recievers/BasisShoutAudioDriver.cs @@ -2,7 +2,9 @@ using Basis.Scripts.Device_Management; using Basis.Scripts.Drivers; using Basis.Scripts.Networking.NetworkedAvatar; +#if !UNITY_SERVER using OpusSharp.Core; +#endif using System.Collections.Generic; using UnityEngine; using static SerializableBasis; @@ -37,6 +39,10 @@ private class ShoutAudioEntry /// public static void EnableShoutMode(ushort playerId) { +#if UNITY_SERVER + BasisDebug.LogWarning($"Ignoring shout audio enable for player {playerId} on server/headless build."); + return; +#else if (_entries.ContainsKey(playerId)) { return; // already active @@ -100,6 +106,7 @@ public static void EnableShoutMode(ushort playerId) _entries[playerId] = entry; BasisDebug.Log($"Shout audio enabled for player {playerId}"); +#endif } /// @@ -114,11 +121,13 @@ public static void DisableShoutMode(ushort playerId) entry.Receiver.HasAudioSource = false; +#if !UNITY_SERVER if (entry.Receiver.decoder != null) { entry.Receiver.decoder.Dispose(); entry.Receiver.decoder = null; } +#endif if (entry.AudioSource != null) { diff --git a/Basis/Packages/com.basis.framework/Networking/Transmitters/BasisAudioTransmission.cs b/Basis/Packages/com.basis.framework/Networking/Transmitters/BasisAudioTransmission.cs index ee2525d04..09afe821d 100644 --- a/Basis/Packages/com.basis.framework/Networking/Transmitters/BasisAudioTransmission.cs +++ b/Basis/Packages/com.basis.framework/Networking/Transmitters/BasisAudioTransmission.cs @@ -2,7 +2,9 @@ using Basis.Scripts.BasisSdk.Players; using Basis.Scripts.Networking.NetworkedAvatar; using Basis.Scripts.Profiler; +#if !UNITY_SERVER using OpusSharp.Core; +#endif using static SerializableBasis; namespace Basis.Scripts.Networking.Transmitters @@ -10,7 +12,9 @@ namespace Basis.Scripts.Networking.Transmitters [System.Serializable] public class BasisAudioTransmission { +#if !UNITY_SERVER public OpusEncoder encoder; +#endif public BasisNetworkPlayer NetworkedPlayer; public BasisLocalPlayer Local; public bool HasEvents = false; @@ -28,6 +32,9 @@ public void Initialize(BasisNetworkPlayer networkedPlayer) NetworkedPlayer = networkedPlayer; Local = (BasisLocalPlayer)networkedPlayer.Player; +#if UNITY_SERVER + return; +#endif InitializeEncoder(); AttachMicrophoneEvents(); InitializeBuffers(); @@ -35,6 +42,9 @@ public void Initialize(BasisNetworkPlayer networkedPlayer) public void DeInitialize() { +#if UNITY_SERVER + return; +#else if (HasEvents) { DetachMicrophoneEvents(); @@ -42,8 +52,10 @@ public void DeInitialize() encoder?.Dispose(); encoder = null; +#endif } +#if !UNITY_SERVER private void InitializeEncoder() { #if UNITY_IOS && !UNITY_EDITOR @@ -65,6 +77,7 @@ private void InitializeEncoder() encoder.Ctl(EncoderCTL.OPUS_SET_BITRATE, 32000); encoder.Ctl(EncoderCTL.OPUS_SET_COMPLEXITY, 5); } +#endif private void AttachMicrophoneEvents() { @@ -108,6 +121,9 @@ private void InitializeBuffers() } public void OnAudioReady() { +#if UNITY_SERVER + return; +#else // In shout mode we always send (everyone hears us). // In normal mode we only send if someone is in range. if (!IsInShoutMode && !NetworkedPlayer.HasReasonToSendAudio) @@ -143,16 +159,21 @@ public void OnAudioReady() BasisLocalPlayer.Instance.AudioReceived?.Invoke(); } SilentForHowLong = 0; +#endif } private void SendSilenceOverNetwork() { +#if UNITY_SERVER + return; +#else if (!IsInShoutMode && !NetworkedPlayer.HasReasonToSendAudio) { return; } SilentForHowLong++; //how long in sample size this way on the remote side +#endif } } } From c33314417d62a81b966925a396035b4d514c5d9e Mon Sep 17 00:00:00 2001 From: Toys0125 Date: Wed, 25 Mar 2026 17:37:15 -0500 Subject: [PATCH 08/14] Make sure none are intitialized. --- .../Networking/Recievers/BasisAudioReceiver.cs | 3 ++- .../Networking/Transmitters/BasisAudioTransmission.cs | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/Basis/Packages/com.basis.framework/Networking/Recievers/BasisAudioReceiver.cs b/Basis/Packages/com.basis.framework/Networking/Recievers/BasisAudioReceiver.cs index 3fd59686c..795d4ca91 100644 --- a/Basis/Packages/com.basis.framework/Networking/Recievers/BasisAudioReceiver.cs +++ b/Basis/Packages/com.basis.framework/Networking/Recievers/BasisAudioReceiver.cs @@ -303,7 +303,7 @@ public void Initialize(BasisNetworkReceiver networkedPlayer) { #if UNITY_SERVER return; -#endif +#else outputSampleRate = AudioSettings.outputSampleRate; silentData ??= new float[RemoteOpusSettings.FrameSize]; @@ -315,6 +315,7 @@ public void Initialize(BasisNetworkReceiver networkedPlayer) decoder = new OpusDecoder(RemoteOpusSettings.NetworkSampleRate, RemoteOpusSettings.Channels, use_static: true); #else decoder = new OpusDecoder(RemoteOpusSettings.NetworkSampleRate, RemoteOpusSettings.Channels, use_static: false); +#endif #endif } diff --git a/Basis/Packages/com.basis.framework/Networking/Transmitters/BasisAudioTransmission.cs b/Basis/Packages/com.basis.framework/Networking/Transmitters/BasisAudioTransmission.cs index 09afe821d..75f675d91 100644 --- a/Basis/Packages/com.basis.framework/Networking/Transmitters/BasisAudioTransmission.cs +++ b/Basis/Packages/com.basis.framework/Networking/Transmitters/BasisAudioTransmission.cs @@ -34,10 +34,11 @@ public void Initialize(BasisNetworkPlayer networkedPlayer) #if UNITY_SERVER return; -#endif +#else InitializeEncoder(); AttachMicrophoneEvents(); InitializeBuffers(); +#endif } public void DeInitialize() From 8babd6bf8de113c7fb40461afd833a83625d757e Mon Sep 17 00:00:00 2001 From: Toys0125 Date: Wed, 25 Mar 2026 18:12:25 -0500 Subject: [PATCH 09/14] We remove audiolink out of the package. Hopefully scripts don't crash because of it. --- .github/scripts/sanitize_headless_ci.py | 41 +++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/.github/scripts/sanitize_headless_ci.py b/.github/scripts/sanitize_headless_ci.py index 7e1c24477..5bb5184e1 100644 --- a/.github/scripts/sanitize_headless_ci.py +++ b/.github/scripts/sanitize_headless_ci.py @@ -10,11 +10,13 @@ "Packages/com.valvesoftware.unity.openvr", "Packages/com.basis.openvr", "Packages/com.basis.openxr", + "Packages/com.llealloo.audiolink", "Packages/com.basis.examples", "Packages/com.basis.pooltable", ) PACKAGE_DEPENDENCIES_TO_REMOVE = ( + "com.llealloo.audiolink", "com.unity.xr.openxr", "com.valvesoftware.unity.openvr", ) @@ -35,6 +37,10 @@ "Assets/XR", ) +LINKER_ASSEMBLIES_TO_REMOVE = ( + "AudioLink", +) + def normalize_project_path(path: Path, project_root: Path) -> str: return path.relative_to(project_root).as_posix() @@ -141,6 +147,35 @@ def remove_project_paths(project_root: Path, relative_paths: tuple[str, ...]) -> return removed_paths +def strip_linker_assemblies(project_root: Path) -> list[str]: + link_path = project_root / "Assets/Basis/link.xml" + if not link_path.exists(): + return [] + + lines = link_path.read_text(encoding="utf-8").splitlines(keepends=True) + output: list[str] = [] + removed_assemblies: list[str] = [] + + for line in lines: + stripped = line.strip() + removed_name = None + for assembly_name in LINKER_ASSEMBLIES_TO_REMOVE: + if stripped == f'': + removed_name = assembly_name + break + + if removed_name is not None: + removed_assemblies.append(removed_name) + continue + + output.append(line) + + if removed_assemblies: + link_path.write_text("".join(output), encoding="utf-8") + + return removed_assemblies + + def strip_addressable_entries(asset_path: Path, guid_to_asset_path: dict[str, str]) -> list[str]: lines = asset_path.read_text(encoding="utf-8").splitlines(keepends=True) output: list[str] = [] @@ -223,6 +258,12 @@ def main() -> int: for path in removed_xr_paths: print(f"Removed XR path: {path}") + removed_linker_assemblies = strip_linker_assemblies(project_root) + if removed_linker_assemblies: + print(f"Removed {len(removed_linker_assemblies)} linker preserve entries from {project_root / 'Assets/Basis/link.xml'}") + for assembly_name in removed_linker_assemblies: + print(f" - {assembly_name}") + for relative_dir in PACKAGE_DIRS_TO_REMOVE: package_dir = project_root / relative_dir if package_dir.exists(): From 97a53312dbfaa0c5c4f4129942dd20243a548996 Mon Sep 17 00:00:00 2001 From: luke doolan Date: Thu, 26 Mar 2026 10:58:39 +1000 Subject: [PATCH 10/14] added some validation and corrected bug for headless, will still occur on client but will be much rarer --- Basis/Assets/Basis/link.xml | 7 ++++++- .../com.basis.eventdriver/BasisEventDriver.cs | 11 +++++++---- .../Headless/BasisHeadlessManagement.cs | 2 ++ .../Networking/BasisRemoteFaceManagement.cs | 18 ++++++++++++------ 4 files changed, 27 insertions(+), 11 deletions(-) diff --git a/Basis/Assets/Basis/link.xml b/Basis/Assets/Basis/link.xml index b5617468f..b77df14f9 100644 --- a/Basis/Assets/Basis/link.xml +++ b/Basis/Assets/Basis/link.xml @@ -41,7 +41,7 @@ - + @@ -49,6 +49,7 @@ + @@ -110,7 +111,11 @@ + + + + diff --git a/Basis/Packages/com.basis.eventdriver/BasisEventDriver.cs b/Basis/Packages/com.basis.eventdriver/BasisEventDriver.cs index 4bd8281ea..4a14aa951 100644 --- a/Basis/Packages/com.basis.eventdriver/BasisEventDriver.cs +++ b/Basis/Packages/com.basis.eventdriver/BasisEventDriver.cs @@ -185,9 +185,9 @@ public void LateUpdate() { BasisDeviceManagement.Instance.Simulate(); // poll things like steam audio } - #if STEAMAUDIO_ENABLED +#if STEAMAUDIO_ENABLED SteamAudioManager.Schedule();//schedule steam audio - #endif +#endif BasisRemoteFaceManagement.Simulate(TimeAsDouble, DeltaTime); // eye blinking if (BasisLocalPlayer.PlayerReady) @@ -201,9 +201,9 @@ public void LateUpdate() } BasisRemoteAudioDriver.Apply(); //apply visemes - #if STEAMAUDIO_ENABLED +#if STEAMAUDIO_ENABLED SteamAudioManager.Apply(); //apply steam audio transforms - #endif +#endif if (BasisLocalPlayer.PlayerReady) { @@ -239,6 +239,9 @@ public void LateUpdate() JigglePhysics.CompletePose(); BasisAvatarDriver.ApplyShadowCloneBlendShapes(); StateOfOnRenderBefore = true; +#if UNITY_SERVER + OnBeforeRender(); +#endif } /// diff --git a/Basis/Packages/com.basis.framework/Device Management/Devices/Headless/BasisHeadlessManagement.cs b/Basis/Packages/com.basis.framework/Device Management/Devices/Headless/BasisHeadlessManagement.cs index 1ea82ee70..fccfb9e07 100644 --- a/Basis/Packages/com.basis.framework/Device Management/Devices/Headless/BasisHeadlessManagement.cs +++ b/Basis/Packages/com.basis.framework/Device Management/Devices/Headless/BasisHeadlessManagement.cs @@ -1,3 +1,4 @@ +using Basis.BasisUI; using Basis.Scripts.BasisSdk.Players; using Basis.Scripts.Device_Management; using Basis.Scripts.Device_Management.Devices.Headless; @@ -219,6 +220,7 @@ public async void ConnectToNetwork() BasisNetworkManagement.Instance.Port = (ushort)Port; BasisNetworkManagement.Instance.Connect(); BasisDebug.Log("connecting to default"); + BasisMainMenu.Close(); } /// diff --git a/Basis/Packages/com.basis.framework/Networking/BasisRemoteFaceManagement.cs b/Basis/Packages/com.basis.framework/Networking/BasisRemoteFaceManagement.cs index 80b95c1d3..0c78d9317 100644 --- a/Basis/Packages/com.basis.framework/Networking/BasisRemoteFaceManagement.cs +++ b/Basis/Packages/com.basis.framework/Networking/BasisRemoteFaceManagement.cs @@ -1,3 +1,5 @@ +using Basis.Scripts.BasisSdk.Players; +using Basis.Scripts.Drivers; using Basis.Scripts.Networking; using Basis.Scripts.Networking.Receivers; using Unity.Burst; @@ -51,6 +53,7 @@ public static class BasisRemoteFaceManagement public static JobHandle handle; public static BasisNetworkReceiver[] snapshot; public static int count; + public static bool HasJob = false; public static void Simulate(double t,float dt) { snapshot = BasisNetworkPlayers.ReceiversSnapshot; @@ -91,6 +94,7 @@ public static void Simulate(double t,float dt) }; handle = job.Schedule(count, BatchSize); + HasJob = true; } public static void Apply() @@ -99,14 +103,17 @@ public static void Apply() { return; } - + if (!HasJob) + { + return; + } handle.Complete(); - + HasJob = false; for (int Index = 0; Index < count; Index++) { - var receiver = snapshot[Index]; - var remote = receiver.RemotePlayer; - var Face = remote.RemoteFaceDriver; + BasisNetworkReceiver receiver = snapshot[Index]; + BasisRemotePlayer remote = receiver.RemotePlayer; + BasisRemoteFaceDriver Face = remote.RemoteFaceDriver; if (!Face.OverrideEye) { @@ -135,7 +142,6 @@ public static void Apply() } } } - static void EnsureArrays(int requiredCount,double nowTime,BasisNetworkReceiver[] snapshot) { // Already sufficient From d7e9cec82a879683de168dde6f178979dadec640 Mon Sep 17 00:00:00 2001 From: Toys0125 Date: Wed, 25 Mar 2026 21:18:40 -0500 Subject: [PATCH 11/14] Adds Bundled platforms to the log. --- .../BasisIOManagement.cs | 2 +- .../Scripts/BasisBundleConnector.cs | 26 +++++++++++++++++-- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/Basis/Packages/com.basis.bundlemanagement/BasisIOManagement.cs b/Basis/Packages/com.basis.bundlemanagement/BasisIOManagement.cs index cf6842435..386e6eb4f 100644 --- a/Basis/Packages/com.basis.bundlemanagement/BasisIOManagement.cs +++ b/Basis/Packages/com.basis.bundlemanagement/BasisIOManagement.cs @@ -152,7 +152,7 @@ public static async Task> DownloadBEEEx(string url, if (platformSectionData == null || platformSectionData.Length == 0) { - return BeeResult.Fail($"DownloadBEEEx: No platform-matching section found in connector. Platform Request was {Application.platform} using platform keys -> {BasisBundleConnector.DebugOfPlatforms()}"); + return BeeResult.Fail($"DownloadBEEEx: No platform-matching section found in connector. Platform Request was {Application.platform}. {BasisBundleConnector.DebugOfPlatforms(connector)}"); } // 5) Write local .bee (Int32 header + connector + section) diff --git a/Basis/Packages/com.basis.sdk/Scripts/BasisBundleConnector.cs b/Basis/Packages/com.basis.sdk/Scripts/BasisBundleConnector.cs index 37b2c44c0..a08bbf448 100644 --- a/Basis/Packages/com.basis.sdk/Scripts/BasisBundleConnector.cs +++ b/Basis/Packages/com.basis.sdk/Scripts/BasisBundleConnector.cs @@ -75,9 +75,31 @@ public static bool IsPlatform(BasisBundleGenerated platformBundle) { Enum.GetName(typeof(BuildTarget), BuildTarget.StandaloneLinux64), new HashSet { RuntimePlatform.LinuxEditor, RuntimePlatform.LinuxPlayer, RuntimePlatform.LinuxServer } }, { Enum.GetName(typeof(BuildTarget), BuildTarget.iOS), new HashSet { RuntimePlatform.IPhonePlayer } } }; - public static string DebugOfPlatforms() + public static string DebugOfPlatforms(BasisBundleConnector connector = null) { - return string.Join("\n", platformMappings.Select(kvp => $" {kvp.Key} => [{string.Join(", ", kvp.Value)}]")); + string bundlePlatforms = " "; + + if (connector != null) + { + if (connector.BasisBundleGenerated == null || connector.BasisBundleGenerated.Length == 0) + { + bundlePlatforms = " "; + } + else + { + var platforms = connector.BasisBundleGenerated + .Where(bundle => bundle != null) + .Select(bundle => string.IsNullOrWhiteSpace(bundle.Platform) ? "" : bundle.Platform) + .ToArray(); + + bundlePlatforms = platforms.Length == 0 + ? " " + : string.Join("\n", platforms.Select(platform => $" {platform}")); + } + } + + string knownPlatforms = string.Join("\n", platformMappings.Select(kvp => $" {kvp.Key} => [{string.Join(", ", kvp.Value)}]")); + return $"Bundle Generated Platforms:\n{bundlePlatforms}\nKnown Platform Mappings:\n{knownPlatforms}"; } public enum BuildTarget { From edd2f23d5fa4055b30b3f4c9cfbaeae9ddfe1def Mon Sep 17 00:00:00 2001 From: Toys0125 Date: Thu, 26 Mar 2026 21:15:35 -0500 Subject: [PATCH 12/14] Add Arm64 support. --- .github/docker/headless/linux/Dockerfile | 11 +- .github/workflows/build-docker-server.yml | 112 +++++++++++++++--- .../Editor/BasisHeadlessBuild.cs | 27 +++++ 3 files changed, 133 insertions(+), 17 deletions(-) rename Basis/{Assets => Packages/com.basis.server}/Editor/BasisHeadlessBuild.cs (90%) diff --git a/.github/docker/headless/linux/Dockerfile b/.github/docker/headless/linux/Dockerfile index 68a8a5599..6df700d2e 100644 --- a/.github/docker/headless/linux/Dockerfile +++ b/.github/docker/headless/linux/Dockerfile @@ -9,7 +9,12 @@ RUN apt-get update && \ && rm -rf /var/lib/apt/lists/* WORKDIR /app -COPY ./HeadlessLinux64/. . -RUN chmod +x ./HeadlessLinuxServer.x86_64 +ARG ARTIFACT_DIR=HeadlessLinux64 +COPY ./${ARTIFACT_DIR}/. . +RUN set -eux; \ + exe="$(find . -maxdepth 1 -type f \( -name 'HeadlessLinuxServer.x86_64' -o -name 'HeadlessLinuxServer.arm64' -o -name 'HeadlessLinuxServer' \) | head -n1)"; \ + test -n "$exe"; \ + chmod +x "$exe"; \ + ln -sf "$(basename "$exe")" /app/HeadlessLinuxServer -ENTRYPOINT ["./HeadlessLinuxServer.x86_64"] +ENTRYPOINT ["./HeadlessLinuxServer"] diff --git a/.github/workflows/build-docker-server.yml b/.github/workflows/build-docker-server.yml index 9e7796a78..ad4b3a633 100644 --- a/.github/workflows/build-docker-server.yml +++ b/.github/workflows/build-docker-server.yml @@ -103,14 +103,29 @@ jobs: - targetPlatform: StandaloneLinux64 buildPlatform: ubuntu-latest buildName: HeadlessLinuxServer - buildOutput: LinuxServer - artifactName: LinuxHeadless + buildOutput: LinuxServerAmd64 + artifactName: LinuxHeadlessAmd64 + headlessFolderName: HeadlessLinux64 + linuxArchitecture: X64 + customParameters: -standaloneBuildSubtarget Server -linuxArchitecture X64 + buildMethod: BasisHeadlessBuild.BuildLinuxServer + - targetPlatform: StandaloneLinux64 + buildPlatform: ubuntu-latest + buildName: HeadlessLinuxServer + buildOutput: LinuxServerArm64 + artifactName: LinuxHeadlessArm64 + headlessFolderName: HeadlessLinuxArm64 + linuxArchitecture: ARM64 + customParameters: -standaloneBuildSubtarget Server -linuxArchitecture ARM64 buildMethod: BasisHeadlessBuild.BuildLinuxServer - targetPlatform: StandaloneWindows64 buildPlatform: ubuntu-latest buildName: HeadlessWindowsServer buildOutput: WindowsServer artifactName: WindowsHeadless + headlessFolderName: HeadlessWindows64 + linuxArchitecture: "" + customParameters: -standaloneBuildSubtarget Server buildMethod: BasisHeadlessBuild.BuildWindowsServer needs: [check-secret] if: needs.check-secret.outputs.secret-is-set == 'true' @@ -144,6 +159,21 @@ jobs: shell: bash run: | python3 .github/scripts/sanitize_headless_ci.py "${projectPath}" + - name: "Install Linux Arm64 Unity SDK package" + if: matrix.targetPlatform == 'StandaloneLinux64' && matrix.linuxArchitecture == 'ARM64' + shell: bash + run: | + python3 - <<'PY' + import json + from pathlib import Path + + manifest_path = Path("Basis/Packages/manifest.json") + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + dependencies = manifest.setdefault("dependencies", {}) + dependencies["com.unity.sdk.linux-arm64"] = "1.0.2" + manifest_path.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8") + print("Added com.unity.sdk.linux-arm64@1.0.2 to manifest.json") + PY - name: "Disable OpenVR editor setup for Windows headless builds" if: runner.os == 'Windows' shell: pwsh @@ -178,7 +208,7 @@ jobs: with: buildName: ${{ matrix.buildName }} buildMethod: ${{ matrix.buildMethod }} - customParameters: -standaloneBuildSubtarget Server + customParameters: ${{ matrix.customParameters }} projectPath: ${{ env.projectPath }} targetPlatform: ${{ matrix.targetPlatform }} versioning: None @@ -194,15 +224,15 @@ jobs: if: matrix.targetPlatform == 'StandaloneLinux64' shell: bash run: | - sudo mv "build/${{ matrix.buildOutput }}/StandaloneLinux64" "build/${{ matrix.buildOutput }}/HeadlessLinux64" - sudo chown -R "$(id -u):$(id -g)" "build/${{ matrix.buildOutput }}/HeadlessLinux64" + sudo mv "build/${{ matrix.buildOutput }}/StandaloneLinux64" "build/${{ matrix.buildOutput }}/${{ matrix.headlessFolderName }}" + sudo chown -R "$(id -u):$(id -g)" "build/${{ matrix.buildOutput }}/${{ matrix.headlessFolderName }}" - name: "Write headless config.xml into artifact" shell: bash run: | if [ "${{ matrix.targetPlatform }}" = "StandaloneWindows64" ]; then - data_dir="build/${{ matrix.buildOutput }}/HeadlessWindows64/HeadlessWindowsServer_Data" + data_dir="build/${{ matrix.buildOutput }}/${{ matrix.headlessFolderName }}/HeadlessWindowsServer_Data" else - data_dir="build/${{ matrix.buildOutput }}/HeadlessLinux64/HeadlessLinuxServer_Data" + data_dir="build/${{ matrix.buildOutput }}/${{ matrix.headlessFolderName }}/HeadlessLinuxServer_Data" fi mkdir -p "$data_dir" @@ -239,20 +269,34 @@ jobs: path: build/${{ matrix.buildOutput }} headless-docker-linux: - name: Build & push headless Linux image + name: Build & push headless Linux image (${{ matrix.archSuffix }}) runs-on: ubuntu-latest needs: [headless-build] permissions: contents: read packages: write + strategy: + fail-fast: false + matrix: + include: + - artifactName: LinuxHeadlessAmd64 + dockerPlatform: linux/amd64 + archSuffix: amd64 + artifactDir: HeadlessLinux64 + - artifactName: LinuxHeadlessArm64 + dockerPlatform: linux/arm64 + archSuffix: arm64 + artifactDir: HeadlessLinuxArm64 steps: - name: Checkout repository uses: actions/checkout@v4 - name: Download Linux headless artifact uses: actions/download-artifact@v4 with: - name: LinuxHeadless + name: ${{ matrix.artifactName }} path: headless + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Log in to GitHub Container Registry @@ -268,25 +312,65 @@ jobs: with: images: ${{ env.REGISTRY }}/${{ env.HEADLESS_IMAGE_NAME }} tags: | - type=raw,value=nightly-linux,enable=${{ github.ref == 'refs/heads/developer' }} - type=raw,value=latest-linux,enable=${{ github.ref == 'refs/heads/long-term-support' }} - type=sha,prefix={{branch}}-,suffix=-linux,format=short - type=ref,event=branch,suffix=-linux + type=raw,value=nightly-linux-${{ matrix.archSuffix }},enable=${{ github.ref == 'refs/heads/developer' }} + type=raw,value=latest-linux-${{ matrix.archSuffix }},enable=${{ github.ref == 'refs/heads/long-term-support' }} + type=sha,prefix={{branch}}-,suffix=-linux-${{ matrix.archSuffix }},format=short + type=ref,event=branch,suffix=-linux-${{ matrix.archSuffix }} - name: Build and push headless Linux image uses: docker/build-push-action@v5 id: build with: context: ./headless file: ./.github/docker/headless/linux/Dockerfile - platforms: linux/amd64 + platforms: ${{ matrix.dockerPlatform }} push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} cache-from: type=gha cache-to: type=gha,mode=max + build-args: | + ARTIFACT_DIR=${{ matrix.artifactDir }} - name: Image digest run: 'echo "Headless Linux image pushed with digest: ${{ steps.build.outputs.digest }}"' + headless-docker-linux-manifest: + name: Publish headless Linux manifest + runs-on: ubuntu-latest + needs: [headless-docker-linux] + permissions: + contents: read + packages: write + steps: + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + - name: Log in to GitHub Container Registry + if: github.event_name != 'pull_request' + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Extract metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.HEADLESS_IMAGE_NAME }} + tags: | + type=raw,value=nightly-linux,enable=${{ github.ref == 'refs/heads/developer' }} + type=raw,value=latest-linux,enable=${{ github.ref == 'refs/heads/long-term-support' }} + type=sha,prefix={{branch}}-,suffix=-linux,format=short + type=ref,event=branch,suffix=-linux + - name: Create Linux multi-arch manifest + shell: bash + run: | + while IFS= read -r tag; do + [ -n "$tag" ] || continue + docker buildx imagetools create \ + --tag "$tag" \ + "${tag}-amd64" \ + "${tag}-arm64" + done <<< "${{ steps.meta.outputs.tags }}" + headless-docker-windows: name: Build & push headless Windows image runs-on: ubuntu-latest diff --git a/Basis/Assets/Editor/BasisHeadlessBuild.cs b/Basis/Packages/com.basis.server/Editor/BasisHeadlessBuild.cs similarity index 90% rename from Basis/Assets/Editor/BasisHeadlessBuild.cs rename to Basis/Packages/com.basis.server/Editor/BasisHeadlessBuild.cs index ecea0d771..ada153cfe 100644 --- a/Basis/Assets/Editor/BasisHeadlessBuild.cs +++ b/Basis/Packages/com.basis.server/Editor/BasisHeadlessBuild.cs @@ -30,6 +30,7 @@ private static void BuildServer(BuildTarget target) string buildName = GetArgument("customBuildName") ?? Path.GetFileNameWithoutExtension(buildPath); string projectPath = GetArgument("projectPath") ?? Directory.GetCurrentDirectory(); string standaloneSubtargetArg = GetArgument("standaloneBuildSubtarget") ?? "Server"; + string linuxArchitectureArg = GetArgument("linuxArchitecture"); Debug.Log($"[BasisHeadlessBuild] Starting {target} build"); Debug.Log($"[BasisHeadlessBuild] projectPath={projectPath}"); @@ -38,6 +39,7 @@ private static void BuildServer(BuildTarget target) Debug.Log($"[BasisHeadlessBuild] activeBuildTarget(before)={EditorUserBuildSettings.activeBuildTarget}"); Debug.Log($"[BasisHeadlessBuild] activeBuildTargetGroup(before)={BuildPipeline.GetBuildTargetGroup(EditorUserBuildSettings.activeBuildTarget)}"); Debug.Log($"[BasisHeadlessBuild] standaloneBuildSubtarget(arg)={standaloneSubtargetArg}"); + Debug.Log($"[BasisHeadlessBuild] linuxArchitecture(arg)={linuxArchitectureArg ?? ""}"); BuildTargetGroup targetGroup = BuildPipeline.GetBuildTargetGroup(target); if (!BuildPipeline.IsBuildTargetSupported(targetGroup, target)) @@ -53,6 +55,12 @@ private static void BuildServer(BuildTarget target) StandaloneBuildSubtarget standaloneSubtarget = ParseStandaloneSubtarget(standaloneSubtargetArg); EditorUserBuildSettings.standaloneBuildSubtarget = standaloneSubtarget; + if (target == BuildTarget.StandaloneLinux64) + { + int linuxArchitecture = ParseLinuxArchitecture(linuxArchitectureArg); + PlayerSettings.SetArchitecture(targetGroup, linuxArchitecture); + Debug.Log($"[BasisHeadlessBuild] Linux architecture(set)={linuxArchitecture}"); + } Debug.Log($"[BasisHeadlessBuild] activeBuildTarget(after)={EditorUserBuildSettings.activeBuildTarget}"); Debug.Log($"[BasisHeadlessBuild] standaloneBuildSubtarget(set)={EditorUserBuildSettings.standaloneBuildSubtarget}"); @@ -166,6 +174,25 @@ private static StandaloneBuildSubtarget ParseStandaloneSubtarget(string value) return StandaloneBuildSubtarget.Server; } + private static int ParseLinuxArchitecture(string value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return 0; + } + + switch (value.Trim().ToUpperInvariant()) + { + case "ARM64": + return 1; + case "UNIVERSAL": + return 2; + case "X64": + default: + return 0; + } + } + private static string RequireArgument(string name) { string value = GetArgument(name); From 4bbc2c3acde2af44ee07b9bff2eb5ed8d33ddd92 Mon Sep 17 00:00:00 2001 From: Toys0125 Date: Thu, 26 Mar 2026 21:19:11 -0500 Subject: [PATCH 13/14] Change the naming on the headless builds. --- .github/workflows/build-docker-server.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/build-docker-server.yml b/.github/workflows/build-docker-server.yml index ad4b3a633..2c6053b82 100644 --- a/.github/workflows/build-docker-server.yml +++ b/.github/workflows/build-docker-server.yml @@ -89,7 +89,7 @@ jobs: run: 'echo "Image pushed with digest: ${{ steps.build.outputs.digest }}"' headless-build: - name: Build headless for ${{ matrix.targetPlatform }} + name: Build headless for ${{ matrix.targetPlatform }}${{ matrix.linuxArchitecture && format(' - {0}', matrix.linuxArchitecture) || '' }} timeout-minutes: 100 runs-on: ${{ matrix.buildPlatform }} permissions: @@ -124,7 +124,6 @@ jobs: buildOutput: WindowsServer artifactName: WindowsHeadless headlessFolderName: HeadlessWindows64 - linuxArchitecture: "" customParameters: -standaloneBuildSubtarget Server buildMethod: BasisHeadlessBuild.BuildWindowsServer needs: [check-secret] From f0c7fc727076bc4b1e48647f18ccbe17a975bab1 Mon Sep 17 00:00:00 2001 From: Toys0125 Date: Thu, 26 Mar 2026 21:34:34 -0500 Subject: [PATCH 14/14] Add script based install arm64 unity package. --- .../scripts/install_unity_package_latest.py | 58 +++++++++++++++++++ .github/workflows/build-docker-server.yml | 12 +--- 2 files changed, 59 insertions(+), 11 deletions(-) create mode 100644 .github/scripts/install_unity_package_latest.py diff --git a/.github/scripts/install_unity_package_latest.py b/.github/scripts/install_unity_package_latest.py new file mode 100644 index 000000000..9a555221c --- /dev/null +++ b/.github/scripts/install_unity_package_latest.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import json +import sys +import urllib.request +from pathlib import Path + + +UNITY_REGISTRY_URL = "https://packages.unity.com" + + +def fetch_latest_version(package_name: str) -> str: + url = f"{UNITY_REGISTRY_URL}/{package_name}" + with urllib.request.urlopen(url, timeout=30) as response: + package_info = json.load(response) + + latest = package_info.get("dist-tags", {}).get("latest") + if not latest: + raise RuntimeError(f"Package {package_name} did not expose dist-tags.latest at {url}") + + return latest + + +def update_manifest_dependency(manifest_path: Path, package_name: str, version: str) -> None: + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + dependencies = manifest.setdefault("dependencies", {}) + previous_version = dependencies.get(package_name) + dependencies[package_name] = version + manifest_path.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8") + + if previous_version is None: + print(f"Added {package_name}@{version} to {manifest_path}") + else: + print(f"Updated {package_name}: {previous_version} -> {version} in {manifest_path}") + + +def main() -> int: + if len(sys.argv) != 3: + print("Usage: install_unity_package_latest.py ", file=sys.stderr) + return 1 + + project_root = Path(sys.argv[1]).resolve() + package_name = sys.argv[2].strip() + manifest_path = project_root / "Packages" / "manifest.json" + + if not manifest_path.exists(): + print(f"Manifest not found: {manifest_path}", file=sys.stderr) + return 1 + + latest_version = fetch_latest_version(package_name) + print(f"Resolved latest {package_name} version: {latest_version}") + update_manifest_dependency(manifest_path, package_name, latest_version) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.github/workflows/build-docker-server.yml b/.github/workflows/build-docker-server.yml index 2c6053b82..86e9b3176 100644 --- a/.github/workflows/build-docker-server.yml +++ b/.github/workflows/build-docker-server.yml @@ -162,17 +162,7 @@ jobs: if: matrix.targetPlatform == 'StandaloneLinux64' && matrix.linuxArchitecture == 'ARM64' shell: bash run: | - python3 - <<'PY' - import json - from pathlib import Path - - manifest_path = Path("Basis/Packages/manifest.json") - manifest = json.loads(manifest_path.read_text(encoding="utf-8")) - dependencies = manifest.setdefault("dependencies", {}) - dependencies["com.unity.sdk.linux-arm64"] = "1.0.2" - manifest_path.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8") - print("Added com.unity.sdk.linux-arm64@1.0.2 to manifest.json") - PY + python3 .github/scripts/install_unity_package_latest.py "${projectPath}" "com.unity.sdk.linux-arm64" - name: "Disable OpenVR editor setup for Windows headless builds" if: runner.os == 'Windows' shell: pwsh